fixes related to automation and celery schedules

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-18 12:22:27 +00:00
parent 3a65fb919a
commit 879ef6ff06
9 changed files with 358 additions and 27 deletions

View File

@@ -186,13 +186,76 @@ class UnifiedSiteSettingsViewSet(viewsets.ViewSet):
# Update automation settings # Update automation settings
if 'automation' in data: if 'automation' in data:
auto = data['automation'] auto = data['automation']
schedule_changed = False
if 'enabled' in auto: if 'enabled' in auto:
if automation_config.is_enabled != auto['enabled']:
schedule_changed = True
automation_config.is_enabled = auto['enabled'] automation_config.is_enabled = auto['enabled']
if 'frequency' in auto: if 'frequency' in auto:
if automation_config.frequency != auto['frequency']:
schedule_changed = True
automation_config.frequency = auto['frequency'] automation_config.frequency = auto['frequency']
if 'time' in auto: if 'time' in auto:
from datetime import datetime from datetime import datetime
automation_config.scheduled_time = datetime.strptime(auto['time'], '%H:%M').time() new_time = datetime.strptime(auto['time'], '%H:%M').time()
if automation_config.scheduled_time != new_time:
schedule_changed = True
automation_config.scheduled_time = new_time
# Reset last_run_at and recalculate next_run_at if any schedule setting changed
if schedule_changed:
automation_config.last_run_at = None
# Recalculate next_run_at based on new schedule
from django.utils import timezone
from datetime import timedelta
now = timezone.now()
scheduled_time = automation_config.scheduled_time
# Calculate next run at the scheduled time
next_run = now.replace(
hour=scheduled_time.hour,
minute=scheduled_time.minute,
second=0,
microsecond=0
)
# If scheduled time has passed today, set to tomorrow (for daily)
# or appropriate next occurrence for weekly/monthly
if next_run <= now:
if automation_config.frequency == 'daily':
next_run = next_run + timedelta(days=1)
elif automation_config.frequency == 'weekly':
# Next Monday
days_until_monday = (7 - now.weekday()) % 7
if days_until_monday == 0:
days_until_monday = 7
next_run = now + timedelta(days=days_until_monday)
next_run = next_run.replace(
hour=scheduled_time.hour,
minute=scheduled_time.minute,
second=0,
microsecond=0
)
elif automation_config.frequency == 'monthly':
# Next 1st of month
if now.month == 12:
next_run = now.replace(year=now.year + 1, month=1, day=1)
else:
next_run = now.replace(month=now.month + 1, day=1)
next_run = next_run.replace(
hour=scheduled_time.hour,
minute=scheduled_time.minute,
second=0,
microsecond=0
)
else:
next_run = next_run + timedelta(days=1)
automation_config.next_run_at = next_run
logger.info(f"[UnifiedSettings] Schedule changed for site {site_id}, reset last_run_at=None, next_run_at={next_run}")
# Update stage configuration # Update stage configuration
if 'stages' in data: if 'stages' in data:

View File

@@ -17,14 +17,14 @@ class AutomationConfigResource(resources.ModelResource):
class Meta: class Meta:
model = AutomationConfig model = AutomationConfig
fields = ('id', 'site__domain', 'is_enabled', 'frequency', 'scheduled_time', fields = ('id', 'site__domain', 'is_enabled', 'frequency', 'scheduled_time',
'within_stage_delay', 'between_stage_delay', 'last_run_at', 'created_at') 'last_run_at', 'next_run_at', 'created_at')
export_order = fields export_order = fields
@admin.register(AutomationConfig) @admin.register(AutomationConfig)
class AutomationConfigAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin): class AutomationConfigAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
resource_class = AutomationConfigResource resource_class = AutomationConfigResource
list_display = ('site', 'is_enabled', 'frequency', 'scheduled_time', 'within_stage_delay', 'between_stage_delay', 'last_run_at') list_display = ('site', 'is_enabled', 'frequency', 'scheduled_time', 'next_scheduled_run', 'last_run_at')
list_filter = ('is_enabled', 'frequency') list_filter = ('is_enabled', 'frequency')
search_fields = ('site__domain',) search_fields = ('site__domain',)
actions = [ actions = [
@@ -34,6 +34,142 @@ class AutomationConfigAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
'bulk_update_delays', 'bulk_update_delays',
] ]
def next_scheduled_run(self, obj):
"""
Calculate the next scheduled run based on:
- Celery Beat schedule (every 15 minutes at :00, :15, :30, :45)
- Frequency (daily, weekly, monthly)
- Scheduled time
- 23-hour block after last_run_at
Celery checks window at :00 for :00-:14, at :15 for :15-:29, etc.
So scheduled_time 12:12 will be picked up at the 12:00 check.
"""
from django.utils import timezone
from datetime import timedelta
if not obj.is_enabled:
return 'Disabled'
now = timezone.now()
scheduled_hour = obj.scheduled_time.hour
scheduled_minute = obj.scheduled_time.minute
# Calculate the Celery window start time for this scheduled_time
# If scheduled at :12, Celery checks at :00 (window :00-:14)
# If scheduled at :35, Celery checks at :30 (window :30-:44)
window_start_minute = (scheduled_minute // 15) * 15
# Calculate next occurrence based on frequency
def get_next_celery_pickup():
# Start with today at the Celery window start time
candidate = now.replace(
hour=scheduled_hour,
minute=window_start_minute,
second=0,
microsecond=0
)
if obj.frequency == 'daily':
# If time has passed today (Celery already checked this window), next is tomorrow
if candidate <= now:
candidate += timedelta(days=1)
elif obj.frequency == 'weekly':
# Run on Mondays
days_until_monday = (7 - now.weekday()) % 7
if days_until_monday == 0:
# Today is Monday - check if time passed
candidate = now.replace(
hour=scheduled_hour,
minute=window_start_minute,
second=0,
microsecond=0
)
if candidate <= now:
days_until_monday = 7
candidate += timedelta(days=7)
else:
candidate = now + timedelta(days=days_until_monday)
candidate = candidate.replace(
hour=scheduled_hour,
minute=window_start_minute,
second=0,
microsecond=0
)
elif obj.frequency == 'monthly':
# Run on 1st of month
candidate = now.replace(
day=1,
hour=scheduled_hour,
minute=window_start_minute,
second=0,
microsecond=0
)
if candidate <= now:
# Next month
if now.month == 12:
candidate = candidate.replace(year=now.year + 1, month=1)
else:
candidate = candidate.replace(month=now.month + 1)
return candidate
next_celery_pickup = get_next_celery_pickup()
# Check 23-hour block
if obj.last_run_at:
earliest_eligible = obj.last_run_at + timedelta(hours=23)
if next_celery_pickup < earliest_eligible:
# Blocked - need to skip to next cycle
if obj.frequency == 'daily':
# Move to next day's window
next_celery_pickup = earliest_eligible.replace(
hour=scheduled_hour,
minute=window_start_minute,
second=0,
microsecond=0
)
if next_celery_pickup < earliest_eligible:
next_celery_pickup += timedelta(days=1)
elif obj.frequency == 'weekly':
# Find next Monday after earliest_eligible
days_until_monday = (7 - earliest_eligible.weekday()) % 7
if days_until_monday == 0:
test_candidate = earliest_eligible.replace(
hour=scheduled_hour,
minute=window_start_minute,
second=0,
microsecond=0
)
if test_candidate <= earliest_eligible:
days_until_monday = 7
next_celery_pickup = earliest_eligible + timedelta(days=days_until_monday)
next_celery_pickup = next_celery_pickup.replace(
hour=scheduled_hour,
minute=window_start_minute,
second=0,
microsecond=0
)
elif obj.frequency == 'monthly':
# Find next 1st of month after earliest_eligible
next_celery_pickup = earliest_eligible.replace(
day=1,
hour=scheduled_hour,
minute=window_start_minute,
second=0,
microsecond=0
)
if next_celery_pickup < earliest_eligible:
if earliest_eligible.month == 12:
next_celery_pickup = next_celery_pickup.replace(year=earliest_eligible.year + 1, month=1)
else:
next_celery_pickup = next_celery_pickup.replace(month=earliest_eligible.month + 1)
# Format nicely
return next_celery_pickup.strftime('%b %d, %Y, %-I:%M %p')
next_scheduled_run.short_description = 'Next Scheduled Run'
def bulk_enable(self, request, queryset): def bulk_enable(self, request, queryset):
"""Enable selected automation configs""" """Enable selected automation configs"""
updated = queryset.update(is_enabled=True) updated = queryset.update(is_enabled=True)

View File

@@ -16,45 +16,63 @@ logger = get_task_logger(__name__)
@shared_task(name='automation.check_scheduled_automations') @shared_task(name='automation.check_scheduled_automations')
def check_scheduled_automations(): def check_scheduled_automations():
""" """
Check for scheduled automation runs (runs every hour) Check for scheduled automation runs (runs every 15 minutes)
Matches automations scheduled within the current 15-minute window.
""" """
logger.info("[AutomationTask] Checking scheduled automations") logger.info("[AutomationTask] Checking scheduled automations")
now = timezone.now() now = timezone.now()
current_time = now.time() current_time = now.time()
# Calculate 15-minute window boundaries
# Window starts at current quarter hour (0, 15, 30, 45)
window_start_minute = (current_time.minute // 15) * 15
window_end_minute = window_start_minute + 14
logger.info(f"[AutomationTask] Current time: {current_time}, checking window {current_time.hour}:{window_start_minute:02d}-{current_time.hour}:{window_end_minute:02d}")
# Find configs that should run now # Find configs that should run now
for config in AutomationConfig.objects.filter(is_enabled=True): for config in AutomationConfig.objects.filter(is_enabled=True):
# Check if it's time to run # Check if it's time to run
should_run = False should_run = False
scheduled_hour = config.scheduled_time.hour
scheduled_minute = config.scheduled_time.minute
# Check if scheduled time falls within current 15-minute window
def is_in_window():
if current_time.hour != scheduled_hour:
return False
return window_start_minute <= scheduled_minute <= window_end_minute
if config.frequency == 'daily': if config.frequency == 'daily':
# Run if current time matches scheduled_time # Run if scheduled_time falls within current 15-minute window
if current_time.hour == config.scheduled_time.hour and current_time.minute < 60: if is_in_window():
should_run = True should_run = True
elif config.frequency == 'weekly': elif config.frequency == 'weekly':
# Run on Mondays at scheduled_time # Run on Mondays within scheduled window
if now.weekday() == 0 and current_time.hour == config.scheduled_time.hour and current_time.minute < 60: if now.weekday() == 0 and is_in_window():
should_run = True should_run = True
elif config.frequency == 'monthly': elif config.frequency == 'monthly':
# Run on 1st of month at scheduled_time # Run on 1st of month within scheduled window
if now.day == 1 and current_time.hour == config.scheduled_time.hour and current_time.minute < 60: if now.day == 1 and is_in_window():
should_run = True should_run = True
logger.debug(f"[AutomationTask] Site {config.site_id}: freq={config.frequency}, scheduled={config.scheduled_time}, should_run={should_run}")
if should_run: if should_run:
# Check if already ran today # Check if already ran within the last 23 hours (prevents duplicate runs)
if config.last_run_at: if config.last_run_at:
time_since_last_run = now - config.last_run_at time_since_last_run = now - config.last_run_at
if time_since_last_run < timedelta(hours=23): if time_since_last_run < timedelta(hours=23):
logger.info(f"[AutomationTask] Skipping site {config.site.id} - already ran today") logger.info(f"[AutomationTask] Skipping site {config.site_id} - already ran {time_since_last_run} ago")
continue continue
# Check if already running OR paused (don't start new if existing in progress) # Check if already running OR paused (don't start new if existing in progress)
if AutomationRun.objects.filter(site=config.site, status__in=['running', 'paused']).exists(): if AutomationRun.objects.filter(site=config.site, status__in=['running', 'paused']).exists():
logger.info(f"[AutomationTask] Skipping site {config.site.id} - automation in progress (running/paused)") logger.info(f"[AutomationTask] Skipping site {config.site_id} - automation in progress (running/paused)")
continue continue
logger.info(f"[AutomationTask] Starting scheduled automation for site {config.site.id}") logger.info(f"[AutomationTask] Starting scheduled automation for site {config.site_id}")
try: try:
service = AutomationService(config.account, config.site) service = AutomationService(config.account, config.site)

View File

@@ -115,13 +115,30 @@ class AutomationViewSet(viewsets.ViewSet):
site=site site=site
) )
# Update fields # Update fields - track if schedule changed
schedule_changed = False
if 'is_enabled' in request.data: if 'is_enabled' in request.data:
if config.is_enabled != request.data['is_enabled']:
schedule_changed = True
config.is_enabled = request.data['is_enabled'] config.is_enabled = request.data['is_enabled']
if 'frequency' in request.data: if 'frequency' in request.data:
if config.frequency != request.data['frequency']:
schedule_changed = True
config.frequency = request.data['frequency'] config.frequency = request.data['frequency']
if 'scheduled_time' in request.data: if 'scheduled_time' in request.data:
config.scheduled_time = request.data['scheduled_time'] new_time = request.data['scheduled_time']
if str(config.scheduled_time) != str(new_time):
schedule_changed = True
config.scheduled_time = new_time
# Reset last_run_at and recalculate next_run_at if any schedule setting changed
if schedule_changed:
config.last_run_at = None
# Recalculate next_run_at based on new schedule
from igny8_core.business.automation.tasks import _calculate_next_run
from django.utils import timezone
config.next_run_at = _calculate_next_run(config, timezone.now())
# Stage enabled toggles # Stage enabled toggles
if 'stage_1_enabled' in request.data: if 'stage_1_enabled' in request.data:
config.stage_1_enabled = request.data['stage_1_enabled'] config.stage_1_enabled = request.data['stage_1_enabled']
@@ -1921,3 +1938,35 @@ class AutomationViewSet(viewsets.ViewSet):
}, },
'initial_snapshot': initial_snapshot 'initial_snapshot': initial_snapshot
} }
@extend_schema(tags=['Automation'])
@action(detail=False, methods=['get'], url_path='server_time')
def server_time(self, request):
"""
GET /api/v1/automation/server_time/
Get current server time (UTC) used for all automation scheduling.
Returns:
- server_time: Current UTC timestamp (ISO 8601 format)
- server_time_formatted: Human-readable UTC time
- timezone: Server timezone setting (always UTC)
- celery_timezone: Celery task timezone setting
- use_tz: Whether Django is timezone-aware
Note: All automation schedules (scheduled_time) are in UTC.
When user sets "02:00", the automation runs at 02:00 UTC.
"""
from django.conf import settings
now = timezone.now()
return Response({
'server_time': now.isoformat(),
'server_time_formatted': now.strftime('%H:%M'),
'server_time_date': now.strftime('%Y-%m-%d'),
'server_time_time': now.strftime('%H:%M:%S'),
'timezone': settings.TIME_ZONE,
'celery_timezone': getattr(settings, 'CELERY_TIMEZONE', settings.TIME_ZONE),
'use_tz': settings.USE_TZ,
'note': 'All automation schedules are in UTC. When you set "02:00", the automation runs at 02:00 UTC.'
})

View File

@@ -57,7 +57,7 @@ app.conf.beat_schedule = {
# Automation Tasks # Automation Tasks
'check-scheduled-automations': { 'check-scheduled-automations': {
'task': 'automation.check_scheduled_automations', 'task': 'automation.check_scheduled_automations',
'schedule': crontab(minute=0), # Every hour at :00 'schedule': crontab(minute='0,15,30,45'), # Every 15 minutes
}, },
# Publishing Scheduler Tasks # Publishing Scheduler Tasks
'schedule-approved-content': { 'schedule-approved-content': {

View File

@@ -32,7 +32,8 @@ import {
CheckCircleIcon, CheckCircleIcon,
ClockIcon, ClockIcon,
PaperPlaneIcon, PaperPlaneIcon,
ArrowRightIcon ArrowRightIcon,
TimeIcon
} from '../../icons'; } from '../../icons';
/** /**
@@ -78,9 +79,31 @@ const AutomationPage: React.FC = () => {
const [stageProgress, setStageProgress] = useState<StageProgress[]>([]); const [stageProgress, setStageProgress] = useState<StageProgress[]>([]);
const [initialSnapshot, setInitialSnapshot] = useState<InitialSnapshot | null>(null); const [initialSnapshot, setInitialSnapshot] = useState<InitialSnapshot | null>(null);
// Server time state - shows the actual time used for all operations
const [serverTime, setServerTime] = useState<string | null>(null);
const [serverTimezone, setServerTimezone] = useState<string>('UTC');
// Track site ID to avoid duplicate calls when activeSite object reference changes // Track site ID to avoid duplicate calls when activeSite object reference changes
const siteId = activeSite?.id; const siteId = activeSite?.id;
// Fetch and update server time every second
useEffect(() => {
const loadServerTime = async () => {
try {
const data = await automationService.getServerTime();
setServerTime(data.server_time_formatted);
setServerTimezone(data.timezone);
} catch (error) {
console.error('Failed to load server time:', error);
}
};
loadServerTime();
const interval = setInterval(loadServerTime, 1000); // Update every second
return () => clearInterval(interval);
}, []);
/** /**
* Calculate time remaining until next scheduled run * Calculate time remaining until next scheduled run
* Returns formatted string like "in 5h 23m" or "in 2d 3h" * Returns formatted string like "in 5h 23m" or "in 2d 3h"
@@ -588,12 +611,9 @@ const AutomationPage: React.FC = () => {
</> </>
)} )}
<div className="h-4 w-px bg-white/25"></div> <div className="h-4 w-px bg-white/25"></div>
<div className="text-sm text-white/90"> <div className="text-sm text-white inline-flex items-center gap-1">
<span className="font-medium">Est:</span>{' '} <TimeIcon className="size-3.5" />
<span className="font-semibold text-white">{estimate?.estimated_credits || 0} content pieces</span> <span className="font-semibold tabular-nums">{serverTime ? serverTime.substring(0, 5) : '--:--'}</span>
{estimate && !estimate.sufficient && (
<span className="ml-1 text-white/90 font-semibold">(Limit reached)</span>
)}
</div> </div>
</div> </div>

View File

@@ -161,11 +161,8 @@ export default function AIAutomationSettings({ siteId }: AIAutomationSettingsPro
}, },
}; };
console.log('[AIAutomationSettings] Saving payload:', JSON.stringify(payload, null, 2));
// Save unified settings // Save unified settings
const updated = await updateUnifiedSiteSettings(siteId, payload); const updated = await updateUnifiedSiteSettings(siteId, payload);
console.log('[AIAutomationSettings] Received updated settings:', JSON.stringify(updated.stages, null, 2));
setSettings(updated); setSettings(updated);
// Save image settings // Save image settings
@@ -816,6 +813,38 @@ export default function AIAutomationSettings({ siteId }: AIAutomationSettingsPro
</div> </div>
</Card> </Card>
</div> </div>
{/* Scheduler Info Card - Full Width */}
<Card className="p-4 bg-gray-50 dark:bg-gray-800/50 border-gray-200 dark:border-gray-700">
<div className="flex items-start gap-4">
<div className="p-2 bg-gray-200 dark:bg-gray-700 rounded-lg flex-shrink-0">
<ClockIcon className="w-5 h-5 text-gray-600 dark:text-gray-400" />
</div>
<div className="flex-1">
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 mb-3">
<div>
<span className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">System</span>
<p className="text-sm font-semibold text-gray-900 dark:text-white">Background Task Queue</p>
</div>
<div>
<span className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Check Frequency</span>
<p className="text-sm font-semibold text-gray-900 dark:text-white">Every 15 minutes</p>
</div>
<div>
<span className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Check Times</span>
<p className="text-sm font-semibold text-gray-900 dark:text-white">:00, :15, :30, :45</p>
</div>
<div>
<span className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Timezone</span>
<p className="text-sm font-semibold text-gray-900 dark:text-white">UTC</p>
</div>
</div>
<p className="text-xs text-gray-600 dark:text-gray-400">
The scheduler checks for due automations every 15 minutes. Your scheduled time will trigger within its 15-minute window (e.g., 14:35 triggers at the 14:30 check). Automations only run once per day if already run, the next run is tomorrow. All times are UTC.
</p>
</div>
</div>
</Card>
</div> </div>
); );
} }

View File

@@ -419,4 +419,20 @@ export const automationService = {
}> => { }> => {
return fetchAPI(buildUrl('/production_stats/', { site_id: siteId })); return fetchAPI(buildUrl('/production_stats/', { site_id: siteId }));
}, },
/**
* Get server time (UTC) used for all automation scheduling
*/
getServerTime: async (): Promise<{
server_time: string;
server_time_formatted: string;
server_time_date: string;
server_time_time: string;
timezone: string;
celery_timezone: string;
use_tz: boolean;
note: string;
}> => {
return fetchAPI(buildUrl('/server_time/'));
},
}; };