Files
CIAgent/apps/api/app/services/scheduling.py
T
saksham 1a4c80958f Initial commit: CI Agent competitive-intelligence monitoring app
FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring,
source collection, LLM-based change analysis, enrichment, and account
security (Turnstile, escalating lockout, email verification).
2026-08-05 10:48:20 -04:00

84 lines
2.8 KiB
Python

"""Schedule validation + next-run computation.
Shared between company creation/monitor-config updates (this phase) and the
Celery Beat dynamic schedule sync (Phase 5) so both paths agree on what a
valid schedule is and when it next fires.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from croniter import CroniterBadCronError, croniter
from app.core.errors import ValidationAppError
from app.models.enums import FREQUENCY_MINUTES, MonitoringFrequency
def validate_and_compute_next_run(
*,
frequency_type: MonitoringFrequency,
interval_minutes: int | None,
cron_expression: str | None,
tz_name: str,
minimum_interval_minutes: int,
from_time: datetime | None = None,
) -> datetime:
now = from_time or datetime.now(UTC)
try:
tzinfo = ZoneInfo(tz_name)
except ZoneInfoNotFoundError as exc:
raise ValidationAppError(f"Unknown timezone: {tz_name}") from exc
if frequency_type == MonitoringFrequency.CUSTOM:
return _compute_custom_next_run(
interval_minutes=interval_minutes,
cron_expression=cron_expression,
tzinfo=tzinfo,
minimum_interval_minutes=minimum_interval_minutes,
now=now,
)
frequency_minutes = FREQUENCY_MINUTES[frequency_type]
if frequency_minutes < minimum_interval_minutes:
raise ValidationAppError(
f"{frequency_type.value} monitoring is more frequent than the minimum allowed "
f"interval of {minimum_interval_minutes} minutes"
)
return now + timedelta(minutes=frequency_minutes)
def _compute_custom_next_run(
*,
interval_minutes: int | None,
cron_expression: str | None,
tzinfo: ZoneInfo,
minimum_interval_minutes: int,
now: datetime,
) -> datetime:
if interval_minutes is not None:
if interval_minutes < minimum_interval_minutes:
raise ValidationAppError(
f"Custom interval must be at least {minimum_interval_minutes} minutes"
)
return now + timedelta(minutes=interval_minutes)
if cron_expression:
try:
base = now.astimezone(tzinfo)
next_local = croniter(cron_expression, base).get_next(datetime)
except (CroniterBadCronError, ValueError) as exc:
raise ValidationAppError(f"Invalid cron expression: {cron_expression}") from exc
next_utc = next_local.astimezone(UTC)
if (next_utc - now) < timedelta(minutes=minimum_interval_minutes):
raise ValidationAppError(
"Custom schedule resolves to less than the minimum allowed interval of "
f"{minimum_interval_minutes} minutes"
)
return next_utc
raise ValidationAppError("Custom frequency requires either interval_minutes or cron_expression")