feat: add campaign copying scheduling and residual handling
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from collections.abc import Mapping
|
||||
|
||||
|
||||
DEFAULT_COPY_OPTIONS: dict[str, bool] = {
|
||||
"include_recipients": True,
|
||||
"include_files": True,
|
||||
"include_shares": False,
|
||||
"include_policies": True,
|
||||
"include_mail_profile": True,
|
||||
}
|
||||
|
||||
|
||||
def campaign_copy_configuration(
|
||||
source: Mapping[str, object],
|
||||
options: Mapping[str, object],
|
||||
) -> dict[str, object]:
|
||||
"""Return an editable configuration copy without operational evidence."""
|
||||
|
||||
selected = {**DEFAULT_COPY_OPTIONS, **dict(options)}
|
||||
raw_json = copy.deepcopy(dict(source))
|
||||
if not selected["include_recipients"]:
|
||||
raw_json["recipients"] = {}
|
||||
raw_json["entries"] = {"inline": [], "imports": []}
|
||||
if not selected["include_files"]:
|
||||
raw_json["attachments"] = {}
|
||||
entries = raw_json.get("entries")
|
||||
if isinstance(entries, dict):
|
||||
inline = entries.get("inline")
|
||||
if isinstance(inline, list):
|
||||
for entry in inline:
|
||||
if isinstance(entry, dict):
|
||||
entry["attachments"] = []
|
||||
entry["combine_attachments"] = True
|
||||
if not selected["include_policies"]:
|
||||
raw_json["validation_policy"] = {}
|
||||
if not selected["include_mail_profile"]:
|
||||
raw_json["server"] = {}
|
||||
return raw_json
|
||||
|
||||
|
||||
__all__ = ["DEFAULT_COPY_OPTIONS", "campaign_copy_configuration"]
|
||||
@@ -12,6 +12,7 @@ from sqlalchemy.orm import Session
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignJob,
|
||||
CampaignSchedule,
|
||||
CampaignShare,
|
||||
CampaignVersion,
|
||||
)
|
||||
@@ -19,7 +20,7 @@ from govoplan_core.auth import ApiPrincipal, has_scope
|
||||
|
||||
|
||||
POLICY_ID = "campaign.lifecycle"
|
||||
POLICY_VERSION = "1"
|
||||
POLICY_VERSION = "2"
|
||||
|
||||
_ACTIVE_QUEUE_STATES = {"queued", "sending"}
|
||||
_ACTIVE_SEND_STATES = {"queued", "claimed", "sending", "outcome_unknown"}
|
||||
@@ -104,6 +105,12 @@ def campaign_lifecycle_policy(
|
||||
.order_by(CampaignShare.id.asc())
|
||||
.all()
|
||||
)
|
||||
schedules = (
|
||||
session.query(CampaignSchedule)
|
||||
.filter(CampaignSchedule.campaign_id == campaign.id)
|
||||
.order_by(CampaignSchedule.id.asc())
|
||||
.all()
|
||||
)
|
||||
selected_version = next(
|
||||
(version for version in versions if version.id == version_id),
|
||||
None,
|
||||
@@ -116,6 +123,10 @@ def campaign_lifecycle_policy(
|
||||
"id": campaign.id,
|
||||
"status": campaign.status,
|
||||
"current_version_id": campaign.current_version_id,
|
||||
"settings_sha256": _canonical_hash(campaign.settings or {}),
|
||||
"mail_profile_policy_sha256": _canonical_hash(
|
||||
campaign.mail_profile_policy or {}
|
||||
),
|
||||
"updated_at": _timestamp(campaign.updated_at),
|
||||
},
|
||||
"versions": [
|
||||
@@ -129,6 +140,7 @@ def campaign_lifecycle_policy(
|
||||
"published_at": _timestamp(version.published_at),
|
||||
"execution_snapshot_at": _timestamp(version.execution_snapshot_at),
|
||||
"archived_at": _timestamp(version.archived_at),
|
||||
"configuration_sha256": _canonical_hash(version.raw_json or {}),
|
||||
"updated_at": _timestamp(version.updated_at),
|
||||
}
|
||||
for version in versions
|
||||
@@ -145,13 +157,34 @@ def campaign_lifecycle_policy(
|
||||
}
|
||||
for job in jobs
|
||||
],
|
||||
"active_share_ids": [share.id for share in shares],
|
||||
"active_shares": [
|
||||
{
|
||||
"id": share.id,
|
||||
"target_type": share.target_type,
|
||||
"target_id": share.target_id,
|
||||
"permission": share.permission,
|
||||
"updated_at": _timestamp(share.updated_at),
|
||||
}
|
||||
for share in shares
|
||||
],
|
||||
"schedules": [
|
||||
{
|
||||
"id": schedule.id,
|
||||
"active": schedule.active,
|
||||
"resource_revision": schedule.resource_revision,
|
||||
"next_fire_at": _timestamp(schedule.next_fire_at),
|
||||
"occurrence_count": schedule.occurrence_count,
|
||||
"updated_at": _timestamp(schedule.updated_at),
|
||||
}
|
||||
for schedule in schedules
|
||||
],
|
||||
"selected_version_id": version_id,
|
||||
}
|
||||
token = _canonical_hash(snapshot)
|
||||
|
||||
active_delivery = any(_active_delivery(job) for job in jobs)
|
||||
protected_versions = any(_protected_version(version) for version in versions)
|
||||
active_schedules = any(schedule.active for schedule in schedules)
|
||||
|
||||
archive = LifecycleDecision(True)
|
||||
if not has_scope(principal, "campaigns:campaign:archive"):
|
||||
@@ -163,6 +196,11 @@ def campaign_lifecycle_policy(
|
||||
False,
|
||||
"Active or uncertain delivery must be resolved before archiving.",
|
||||
)
|
||||
elif active_schedules:
|
||||
archive = LifecycleDecision(
|
||||
False,
|
||||
"Pause active Campaign schedules before archiving.",
|
||||
)
|
||||
|
||||
delete = LifecycleDecision(True)
|
||||
if not has_scope(principal, "campaigns:campaign:delete"):
|
||||
@@ -184,12 +222,15 @@ def campaign_lifecycle_policy(
|
||||
False,
|
||||
"Revoke active campaign shares before deleting the untouched draft.",
|
||||
)
|
||||
elif schedules:
|
||||
delete = LifecycleDecision(
|
||||
False,
|
||||
"Campaigns with schedule evidence must be archived instead of deleted.",
|
||||
)
|
||||
|
||||
copy = LifecycleDecision(True)
|
||||
if not has_scope(principal, "campaigns:campaign:copy"):
|
||||
copy = LifecycleDecision(False, "Missing campaign copy permission.")
|
||||
elif not has_scope(principal, "campaigns:recipient:read"):
|
||||
copy = LifecycleDecision(False, "Recipient read permission is required to copy a campaign.")
|
||||
elif version_id is not None and selected_version is None:
|
||||
copy = LifecycleDecision(False, "The selected source version does not exist.")
|
||||
|
||||
@@ -220,9 +261,10 @@ def campaign_lifecycle_policy(
|
||||
"campaign_state",
|
||||
"retained_evidence",
|
||||
"active_delivery",
|
||||
"scheduled_automation",
|
||||
"optimistic_concurrency",
|
||||
),
|
||||
"evidence_retention": "Versions, delivery outcomes, reports, and audit records are never deleted by archival.",
|
||||
"evidence_retention": "Versions, schedule occurrences, delivery outcomes, reports, and audit records are never deleted by archival.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -468,6 +468,30 @@ class AttachmentBasePathConfig(StrictModel):
|
||||
source: str | None = None
|
||||
|
||||
|
||||
class ResidualFileMode(StrEnum):
|
||||
NONE = "none"
|
||||
REPORT = "report"
|
||||
ATTACH = "attach"
|
||||
|
||||
|
||||
class ResidualFileDispositionConfig(StrictModel):
|
||||
mode: ResidualFileMode = ResidualFileMode.NONE
|
||||
recipient: RecipientConfig | None = None
|
||||
subject: str = "Unassigned files in campaign {{local:campaign_name}}"
|
||||
text: str = (
|
||||
"The campaign build found {{local:residual_file_count}} file(s) that "
|
||||
"were not assigned to a recipient.\n\n{{local:residual_file_list}}"
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_recipient_for_routing(self) -> "ResidualFileDispositionConfig":
|
||||
if self.mode != ResidualFileMode.NONE and self.recipient is None:
|
||||
raise ValueError(
|
||||
"Residual-file report or attachment routing requires a recipient."
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class AttachmentConfig(StrictModel):
|
||||
id: str | None = None
|
||||
label: str | None = None
|
||||
@@ -509,6 +533,9 @@ class AttachmentsConfig(StrictModel):
|
||||
global_: list[AttachmentConfig] = Field(default_factory=list, alias="global")
|
||||
missing_behavior: Behavior = Behavior.WARN
|
||||
ambiguous_behavior: Behavior = Behavior.ASK
|
||||
residual_files: ResidualFileDispositionConfig = Field(
|
||||
default_factory=ResidualFileDispositionConfig
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def normalize_send_without_attachments_behavior(self) -> "AttachmentsConfig":
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.campaign.copying import campaign_copy_configuration
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignSchedule,
|
||||
CampaignScheduleOccurrence,
|
||||
CampaignShare,
|
||||
CampaignVersion,
|
||||
)
|
||||
from govoplan_campaign.backend.persistence.campaigns import (
|
||||
create_campaign_version_from_json,
|
||||
)
|
||||
from govoplan_core.audit.logging import audit_event
|
||||
|
||||
|
||||
RECURRENCE_KINDS = frozenset({"once", "daily", "weekly", "monthly"})
|
||||
SCHEDULE_SOURCE_SCHEMA = "govoplan.campaign.schedule-source.v1"
|
||||
|
||||
|
||||
def canonical_configuration_hash(value: Mapping[str, object]) -> str:
|
||||
return hashlib.sha256(
|
||||
json.dumps(
|
||||
value,
|
||||
ensure_ascii=True,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def campaign_schedule_source_snapshot(
|
||||
*,
|
||||
configuration: Mapping[str, object],
|
||||
campaign_settings: Mapping[str, object],
|
||||
mail_profile_policy: Mapping[str, object],
|
||||
shares: list[Mapping[str, object]],
|
||||
) -> dict[str, object]:
|
||||
"""Seal every selected source domain so worker execution cannot drift."""
|
||||
|
||||
return {
|
||||
"schema": SCHEDULE_SOURCE_SCHEMA,
|
||||
"configuration": copy.deepcopy(dict(configuration)),
|
||||
"campaign_settings": copy.deepcopy(dict(campaign_settings)),
|
||||
"mail_profile_policy": copy.deepcopy(dict(mail_profile_policy)),
|
||||
"shares": [copy.deepcopy(dict(item)) for item in shares],
|
||||
}
|
||||
|
||||
|
||||
def next_schedule_fire(
|
||||
scheduled_for: datetime,
|
||||
*,
|
||||
recurrence_kind: str,
|
||||
interval_count: int,
|
||||
timezone_name: str,
|
||||
) -> datetime | None:
|
||||
if recurrence_kind == "once":
|
||||
return None
|
||||
if recurrence_kind not in RECURRENCE_KINDS:
|
||||
raise ValueError(f"Unsupported campaign recurrence: {recurrence_kind}")
|
||||
if interval_count < 1:
|
||||
raise ValueError("Campaign recurrence interval must be positive")
|
||||
try:
|
||||
zone = ZoneInfo(timezone_name)
|
||||
except ZoneInfoNotFoundError as exc:
|
||||
raise ValueError(f"Unknown campaign schedule timezone: {timezone_name}") from exc
|
||||
local = _as_utc(scheduled_for).astimezone(zone)
|
||||
if recurrence_kind == "daily":
|
||||
upcoming = local + timedelta(days=interval_count)
|
||||
elif recurrence_kind == "weekly":
|
||||
upcoming = local + timedelta(weeks=interval_count)
|
||||
else:
|
||||
month_index = local.year * 12 + local.month - 1 + interval_count
|
||||
year, month_offset = divmod(month_index, 12)
|
||||
month = month_offset + 1
|
||||
day = min(local.day, calendar.monthrange(year, month)[1])
|
||||
upcoming = local.replace(year=year, month=month, day=day)
|
||||
return upcoming.astimezone(UTC)
|
||||
|
||||
|
||||
def dispatch_due_campaign_schedules(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
now: datetime | None = None,
|
||||
limit: int = 50,
|
||||
) -> dict[str, object]:
|
||||
observed_at = _as_utc(now or datetime.now(UTC))
|
||||
query = session.query(CampaignSchedule).filter(
|
||||
CampaignSchedule.active.is_(True),
|
||||
CampaignSchedule.next_fire_at.is_not(None),
|
||||
CampaignSchedule.next_fire_at <= observed_at,
|
||||
)
|
||||
if tenant_id is not None:
|
||||
query = query.filter(CampaignSchedule.tenant_id == tenant_id)
|
||||
schedules = (
|
||||
query.order_by(CampaignSchedule.next_fire_at.asc(), CampaignSchedule.id.asc())
|
||||
.with_for_update(skip_locked=True)
|
||||
.limit(max(1, min(limit, 250)))
|
||||
.all()
|
||||
)
|
||||
result: dict[str, object] = {
|
||||
"selected": len(schedules),
|
||||
"prepared": 0,
|
||||
"failed": 0,
|
||||
"completed": 0,
|
||||
"coalesced": 0,
|
||||
"campaign_ids": [],
|
||||
"operator_actions": [],
|
||||
}
|
||||
for schedule in schedules:
|
||||
scheduled_for = _as_utc(schedule.next_fire_at or observed_at)
|
||||
try:
|
||||
with session.begin_nested():
|
||||
campaign, version, skipped = _prepare_occurrence(
|
||||
session,
|
||||
schedule=schedule,
|
||||
scheduled_for=scheduled_for,
|
||||
observed_at=observed_at,
|
||||
)
|
||||
result["prepared"] = int(result["prepared"]) + 1
|
||||
result["coalesced"] = int(result["coalesced"]) + skipped
|
||||
result["campaign_ids"].append(campaign.id) # type: ignore[union-attr]
|
||||
if not schedule.active:
|
||||
result["completed"] = int(result["completed"]) + 1
|
||||
except Exception as exc: # noqa: BLE001 - persist bounded operator evidence
|
||||
session.add(
|
||||
CampaignScheduleOccurrence(
|
||||
tenant_id=schedule.tenant_id,
|
||||
schedule_id=schedule.id,
|
||||
scheduled_for=scheduled_for,
|
||||
status="failed",
|
||||
error=str(exc)[:4000],
|
||||
)
|
||||
)
|
||||
schedule.active = False
|
||||
schedule.last_error = str(exc)[:4000]
|
||||
schedule.resource_revision += 1
|
||||
session.add(schedule)
|
||||
result["failed"] = int(result["failed"]) + 1
|
||||
result["operator_actions"].append( # type: ignore[union-attr]
|
||||
{
|
||||
"schedule_id": schedule.id,
|
||||
"campaign_id": schedule.campaign_id,
|
||||
"reason": "draft_preparation_failed",
|
||||
}
|
||||
)
|
||||
session.flush()
|
||||
return result
|
||||
|
||||
|
||||
def _prepare_occurrence(
|
||||
session: Session,
|
||||
*,
|
||||
schedule: CampaignSchedule,
|
||||
scheduled_for: datetime,
|
||||
observed_at: datetime,
|
||||
) -> tuple[Campaign, CampaignVersion, int]:
|
||||
existing = (
|
||||
session.query(CampaignScheduleOccurrence)
|
||||
.filter(
|
||||
CampaignScheduleOccurrence.schedule_id == schedule.id,
|
||||
CampaignScheduleOccurrence.scheduled_for == scheduled_for,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if existing is not None:
|
||||
raise RuntimeError("Campaign schedule occurrence was already recorded")
|
||||
|
||||
source_campaign = session.get(Campaign, schedule.campaign_id)
|
||||
if source_campaign is None or source_campaign.tenant_id != schedule.tenant_id:
|
||||
raise RuntimeError("Campaign schedule source is no longer available")
|
||||
source_version = session.get(CampaignVersion, schedule.source_version_id)
|
||||
if source_version is None or source_version.campaign_id != source_campaign.id:
|
||||
raise RuntimeError("Campaign schedule source version is no longer available")
|
||||
if canonical_configuration_hash(schedule.source_snapshot) != schedule.source_snapshot_hash:
|
||||
raise RuntimeError("Campaign schedule source snapshot integrity check failed")
|
||||
snapshot = _schedule_snapshot(schedule.source_snapshot)
|
||||
|
||||
sequence = schedule.occurrence_count + 1
|
||||
external_id = _scheduled_external_id(
|
||||
source_campaign.external_id,
|
||||
schedule.id,
|
||||
sequence,
|
||||
)
|
||||
local_date = scheduled_for.astimezone(ZoneInfo(schedule.timezone)).date().isoformat()
|
||||
generated_name = f"{schedule.name} - {local_date}"
|
||||
raw_json = campaign_copy_configuration(
|
||||
snapshot["configuration"],
|
||||
schedule.copy_options,
|
||||
)
|
||||
metadata = raw_json.get("campaign")
|
||||
if not isinstance(metadata, dict):
|
||||
raise RuntimeError("Campaign schedule snapshot has no campaign metadata")
|
||||
metadata["id"] = external_id
|
||||
metadata["name"] = generated_name
|
||||
metadata["mode"] = "draft"
|
||||
|
||||
generated_campaign, generated_version = create_campaign_version_from_json(
|
||||
session,
|
||||
tenant_id=schedule.tenant_id,
|
||||
user_id=schedule.created_by_user_id,
|
||||
raw_json=raw_json,
|
||||
source_filename=None,
|
||||
source_base_path=schedule.source_base_path,
|
||||
commit=False,
|
||||
)
|
||||
if bool(schedule.copy_options.get("include_policies", True)):
|
||||
generated_campaign.settings = copy.deepcopy(snapshot["campaign_settings"])
|
||||
if bool(schedule.copy_options.get("include_mail_profile", True)):
|
||||
generated_campaign.mail_profile_policy = copy.deepcopy(
|
||||
snapshot["mail_profile_policy"]
|
||||
)
|
||||
if bool(schedule.copy_options.get("include_shares", False)):
|
||||
_copy_snapshot_shares(
|
||||
session,
|
||||
schedule=schedule,
|
||||
generated_campaign=generated_campaign,
|
||||
shares=snapshot["shares"],
|
||||
)
|
||||
|
||||
occurrence = CampaignScheduleOccurrence(
|
||||
tenant_id=schedule.tenant_id,
|
||||
schedule_id=schedule.id,
|
||||
scheduled_for=scheduled_for,
|
||||
status="prepared",
|
||||
generated_campaign_id=generated_campaign.id,
|
||||
generated_version_id=generated_version.id,
|
||||
)
|
||||
session.add(occurrence)
|
||||
schedule.occurrence_count = sequence
|
||||
schedule.last_fired_at = scheduled_for
|
||||
schedule.last_campaign_id = generated_campaign.id
|
||||
schedule.last_error = None
|
||||
|
||||
next_fire = next_schedule_fire(
|
||||
scheduled_for,
|
||||
recurrence_kind=schedule.recurrence_kind,
|
||||
interval_count=schedule.interval_count,
|
||||
timezone_name=schedule.timezone,
|
||||
)
|
||||
coalesced = 0
|
||||
while next_fire is not None and next_fire <= observed_at:
|
||||
next_fire = next_schedule_fire(
|
||||
next_fire,
|
||||
recurrence_kind=schedule.recurrence_kind,
|
||||
interval_count=schedule.interval_count,
|
||||
timezone_name=schedule.timezone,
|
||||
)
|
||||
coalesced += 1
|
||||
if (
|
||||
next_fire is None
|
||||
or sequence >= schedule.max_occurrences
|
||||
or (schedule.ends_at is not None and next_fire > _as_utc(schedule.ends_at))
|
||||
):
|
||||
schedule.active = False
|
||||
schedule.next_fire_at = None
|
||||
else:
|
||||
schedule.next_fire_at = next_fire
|
||||
schedule.resource_revision += 1
|
||||
session.add(schedule)
|
||||
audit_event(
|
||||
session,
|
||||
tenant_id=schedule.tenant_id,
|
||||
user_id=schedule.created_by_user_id,
|
||||
action="campaign.schedule.draft_prepared",
|
||||
object_type="campaign_schedule",
|
||||
object_id=schedule.id,
|
||||
details={
|
||||
"source_campaign_id": source_campaign.id,
|
||||
"source_version_id": source_version.id,
|
||||
"scheduled_for": scheduled_for.isoformat(),
|
||||
"generated_campaign_id": generated_campaign.id,
|
||||
"generated_version_id": generated_version.id,
|
||||
"occurrence": sequence,
|
||||
"coalesced_missed_intervals": coalesced,
|
||||
"delivery_started": False,
|
||||
},
|
||||
commit=False,
|
||||
)
|
||||
return generated_campaign, generated_version, coalesced
|
||||
|
||||
|
||||
def _copy_snapshot_shares(
|
||||
session: Session,
|
||||
*,
|
||||
schedule: CampaignSchedule,
|
||||
generated_campaign: Campaign,
|
||||
shares: object,
|
||||
) -> None:
|
||||
if not isinstance(shares, list):
|
||||
raise RuntimeError("Campaign schedule share snapshot is invalid")
|
||||
for source in shares:
|
||||
if not isinstance(source, Mapping):
|
||||
raise RuntimeError("Campaign schedule share snapshot is invalid")
|
||||
target_type = str(source.get("target_type") or "")
|
||||
target_id = str(source.get("target_id") or "")
|
||||
permission = str(source.get("permission") or "read")
|
||||
if not target_type or not target_id:
|
||||
raise RuntimeError("Campaign schedule share snapshot is incomplete")
|
||||
session.add(
|
||||
CampaignShare(
|
||||
tenant_id=schedule.tenant_id,
|
||||
campaign_id=generated_campaign.id,
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
permission=permission,
|
||||
created_by_user_id=schedule.created_by_user_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _schedule_snapshot(value: object) -> dict[str, object]:
|
||||
if not isinstance(value, Mapping) or value.get("schema") != SCHEDULE_SOURCE_SCHEMA:
|
||||
raise RuntimeError("Campaign schedule source snapshot schema is invalid")
|
||||
configuration = value.get("configuration")
|
||||
settings = value.get("campaign_settings")
|
||||
mail_policy = value.get("mail_profile_policy")
|
||||
shares = value.get("shares")
|
||||
if (
|
||||
not isinstance(configuration, Mapping)
|
||||
or not isinstance(settings, Mapping)
|
||||
or not isinstance(mail_policy, Mapping)
|
||||
or not isinstance(shares, list)
|
||||
):
|
||||
raise RuntimeError("Campaign schedule source snapshot is incomplete")
|
||||
return {
|
||||
"configuration": dict(configuration),
|
||||
"campaign_settings": dict(settings),
|
||||
"mail_profile_policy": dict(mail_policy),
|
||||
"shares": shares,
|
||||
}
|
||||
|
||||
|
||||
def _scheduled_external_id(source: str, schedule_id: str, sequence: int) -> str:
|
||||
suffix = f"-scheduled-{schedule_id[:8]}-{sequence}"
|
||||
return f"{source[:255 - len(suffix)]}{suffix}"
|
||||
|
||||
|
||||
def _as_utc(value: datetime) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=UTC)
|
||||
return value.astimezone(UTC)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"RECURRENCE_KINDS",
|
||||
"SCHEDULE_SOURCE_SCHEMA",
|
||||
"campaign_schedule_source_snapshot",
|
||||
"canonical_configuration_hash",
|
||||
"dispatch_due_campaign_schedules",
|
||||
"next_schedule_fire",
|
||||
]
|
||||
Reference in New Issue
Block a user