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).
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user