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",
|
||||
]
|
||||
@@ -14,6 +14,7 @@ from govoplan_core.core.campaigns import (
|
||||
CampaignPolicyContext,
|
||||
CampaignPolicyContextProvider,
|
||||
CampaignRetentionProvider,
|
||||
CampaignScheduleProvider,
|
||||
)
|
||||
from govoplan_core.core.ownership import (
|
||||
OwnershipActionDecision,
|
||||
@@ -715,6 +716,37 @@ def delivery_tasks_capability(context: object) -> CampaignDeliveryTaskService:
|
||||
return CampaignDeliveryTaskService()
|
||||
|
||||
|
||||
class CampaignScheduleService(CampaignScheduleProvider):
|
||||
def dispatch_due(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
now=None,
|
||||
limit: int = 50,
|
||||
) -> Mapping[str, object]:
|
||||
from govoplan_campaign.backend.campaign.scheduling import (
|
||||
dispatch_due_campaign_schedules,
|
||||
)
|
||||
|
||||
return dispatch_due_campaign_schedules(
|
||||
session, # type: ignore[arg-type]
|
||||
tenant_id=tenant_id,
|
||||
now=now,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
def schedules_capability(context: object) -> CampaignScheduleService:
|
||||
from govoplan_campaign.backend.runtime import configure_runtime
|
||||
|
||||
configure_runtime(
|
||||
registry=getattr(context, "registry", None),
|
||||
settings=getattr(context, "settings", None),
|
||||
)
|
||||
return CampaignScheduleService()
|
||||
|
||||
|
||||
class CampaignRetentionService(CampaignRetentionProvider):
|
||||
def apply_retention(self, session: object, *, dry_run, now, policy_for_campaign_id):
|
||||
from govoplan_campaign.backend.retention import apply_campaign_retention
|
||||
|
||||
@@ -168,6 +168,91 @@ class CampaignShare(Base, TimestampMixin):
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
|
||||
|
||||
class CampaignSchedule(Base, TimestampMixin):
|
||||
__tablename__ = "campaign_schedules"
|
||||
__table_args__ = (
|
||||
Index("ix_campaign_schedules_due", "tenant_id", "active", "next_fire_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
campaign_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("campaigns.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
source_version_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("campaign_versions.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
created_by_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("access_users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
recurrence_kind: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default="once",
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
interval_count: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
timezone: Mapped[str] = mapped_column(String(100), default="UTC", nullable=False)
|
||||
starts_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
next_fire_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
ends_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
max_occurrences: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
occurrence_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
|
||||
resource_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
copy_options: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
source_snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||
source_snapshot_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
source_base_path: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_campaign_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("campaigns.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
|
||||
class CampaignScheduleOccurrence(Base, TimestampMixin):
|
||||
__tablename__ = "campaign_schedule_occurrences"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"schedule_id",
|
||||
"scheduled_for",
|
||||
name="uq_campaign_schedule_occurrence",
|
||||
),
|
||||
Index("ix_campaign_schedule_occurrences_schedule", "schedule_id", "scheduled_for"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
schedule_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("campaign_schedules.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
scheduled_for: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(30), default="preparing", nullable=False, index=True)
|
||||
generated_campaign_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("campaigns.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
generated_version_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("campaign_versions.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
|
||||
class RecipientImportMappingProfile(Base, TimestampMixin):
|
||||
__tablename__ = "campaign_recipient_import_mapping_profiles"
|
||||
__table_args__ = (
|
||||
|
||||
@@ -46,6 +46,7 @@ _ADDRESSES_SOURCE_INTEGRATION = "addresses.recipient_source"
|
||||
_DISTRIBUTION_LIST_SOURCE_INTEGRATION = "dist_lists.source"
|
||||
_DISTRIBUTION_LIST_EXPAND_INTEGRATION = "dist_lists.expand"
|
||||
_TEMPLATE_CATALOG_INTEGRATION = "templates.catalog"
|
||||
_TEMPLATE_CONTENT_LIBRARY_INTEGRATION = "templates.content_library"
|
||||
_TEMPLATE_RENDERER_INTEGRATION = "templates.renderer"
|
||||
_CALENDAR_INVITATION_INTEGRATION = "calendar.invitations"
|
||||
_NOTIFICATIONS_INTEGRATION = "notifications.dispatch"
|
||||
@@ -73,6 +74,7 @@ def _workflow_topic(
|
||||
links: tuple[DocumentationLink, ...] = (DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),),
|
||||
related_modules: tuple[str, ...] = (),
|
||||
limitations: tuple[str, ...] = (),
|
||||
translations: dict[str, dict[str, str]] | None = None,
|
||||
) -> DocumentationTopic:
|
||||
metadata: dict[str, object] = {
|
||||
"kind": "workflow",
|
||||
@@ -106,6 +108,7 @@ def _workflow_topic(
|
||||
links=links,
|
||||
related_modules=related_modules,
|
||||
unlocks=(outcome,),
|
||||
translations=translations or {},
|
||||
source_module_id="campaigns",
|
||||
metadata=metadata,
|
||||
)
|
||||
@@ -163,26 +166,109 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
||||
topic_id="campaigns.workflow.copy-campaign",
|
||||
title="Copy a campaign into a new draft",
|
||||
summary="Reuse a selected campaign version as configuration for a new campaign without copying operational or audit evidence.",
|
||||
body="Copy campaign is different from creating an editable successor. It creates a separately owned campaign with a generated identifier and one editable version. Delivery jobs, outcomes, explicit shares, locks, and audit evidence stay exclusively with the source campaign.",
|
||||
body="Copy campaign is different from creating an editable successor. It creates a separately owned campaign with a chosen or generated identifier and one editable version. Recipients, attachment rules, active shares, campaign policies, and the Mail profile reference are explicit independent choices. Delivery jobs, outcomes, locks, reports, and audit evidence always stay exclusively with the source campaign.",
|
||||
order=32,
|
||||
audience=("campaign_manager", "campaign_author"),
|
||||
required_scopes=("campaigns:campaign:read", "campaigns:campaign:copy", "campaigns:recipient:read"),
|
||||
required_scopes=("campaigns:campaign:read", "campaigns:campaign:copy"),
|
||||
route="/campaigns/{campaign_id}",
|
||||
screen="Campaign overview",
|
||||
help_contexts=("campaign.overview",),
|
||||
prerequisites=(
|
||||
"You may read the selected campaign and its recipient configuration.",
|
||||
"You may read the selected campaign. Copying recipient data additionally requires recipient-read authority.",
|
||||
"You may create campaign copies in the active tenant.",
|
||||
),
|
||||
steps=(
|
||||
"Open the campaign overview and choose the current or a historical source version.",
|
||||
"Choose Copy campaign or Copy as new campaign and review the evidence-isolation consequence.",
|
||||
"Choose Copy campaign or Copy as new campaign, enter the new identity, and select recipients, files, shares, policies, and Mail profile independently.",
|
||||
"If a choice is unavailable, obtain the corresponding recipient-read or campaign-share authority or leave that content excluded.",
|
||||
"Confirm while the lifecycle state token is current; reload if another actor changed the source state.",
|
||||
"Open the newly created campaign, review its generated identifier and ownership, and validate all inherited configuration before use.",
|
||||
),
|
||||
outcome="A new editable campaign draft containing configuration from the selected version and no copied operational evidence.",
|
||||
verification="The destination has a distinct campaign ID and owner, one editable version, and no source jobs, outcomes, shares, or locks.",
|
||||
outcome="A new editable campaign draft containing only the explicitly selected configuration and no copied operational evidence.",
|
||||
verification="The destination has a distinct campaign ID and owner, one editable version, the selected configuration domains, and no source jobs, outcomes, locks, reports, or audit evidence.",
|
||||
related_topic_ids=("campaigns.workflow.create-editable-successor", "campaigns.workflow.prepare-validate-and-build"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Kampagne als neuen Entwurf kopieren",
|
||||
"summary": "Eine ausgewählte Kampagnenversion als Konfiguration wiederverwenden, ohne Betriebs- oder Auditnachweise zu kopieren.",
|
||||
"body": "Kampagne kopieren erzeugt eine eigenständige Kampagne mit eigener Kennung und einer bearbeitbaren Version. Empfänger, Dateiregeln, aktive Freigaben, Kampagnenrichtlinien und die Mailprofil-Referenz werden unabhängig ausgewählt. Sendeaufträge, Ergebnisse, Sperren, Berichte und Auditnachweise verbleiben immer ausschließlich bei der Quellkampagne.",
|
||||
}
|
||||
},
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.reuse-content-library",
|
||||
title="Reuse Campaign content through Templates",
|
||||
summary="Insert scoped, versioned fragments or complete message parts and save new content as an unpublished Templates draft.",
|
||||
body="The reusable library is owned by Templates. Loading a fragment inserts it at the selected Campaign field and cursor; applying a complete part replaces the current subject and body in the editable Campaign draft. Saving from Campaign creates a personal or tenant Templates draft and never publishes it automatically. Neither operation changes an existing Template revision or a historical Campaign version.",
|
||||
order=33,
|
||||
audience=("campaign_manager", "campaign_author"),
|
||||
required_scopes=("campaigns:campaign:read", "campaigns:campaign:update"),
|
||||
required_modules=("campaigns", "templates"),
|
||||
required_capabilities=(
|
||||
_TEMPLATE_CATALOG_INTEGRATION,
|
||||
_TEMPLATE_CONTENT_LIBRARY_INTEGRATION,
|
||||
),
|
||||
route="/campaigns/{campaign_id}/template",
|
||||
screen="Campaign template",
|
||||
help_contexts=("campaign.template", "campaign.template.content-library"),
|
||||
prerequisites=(
|
||||
"The Campaign version is editable.",
|
||||
"Templates is active and you may read its library; saving additionally requires Template write authority.",
|
||||
),
|
||||
steps=(
|
||||
"Open Template and choose Load from library to search content visible in the active Templates scope.",
|
||||
"Insert a fragment into its declared subject, text, or HTML target, or explicitly apply a complete Campaign part.",
|
||||
"Review placeholders and save the Campaign draft normally.",
|
||||
"To retain new content, choose Save to library, select fragment or complete part plus personal or tenant visibility, and create the unpublished draft.",
|
||||
"Open Templates to review, revise, and publish shared content.",
|
||||
),
|
||||
outcome="Reusable content remains centrally versioned while each Campaign records its own deliberate draft changes.",
|
||||
verification="The Campaign draft shows the inserted content, and saved library content appears in Templates as an unpublished revision with Campaign provenance.",
|
||||
related_topic_ids=("campaigns.workflow.prepare-validate-and-build",),
|
||||
related_modules=("templates",),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Kampagneninhalte über Templates wiederverwenden",
|
||||
"summary": "Bereichsbezogene, versionierte Bausteine oder vollständige Nachrichtenteile einfügen und neue Inhalte als unveröffentlichten Templates-Entwurf speichern.",
|
||||
"body": "Die wiederverwendbare Bibliothek gehört Templates. Ein Baustein wird in das ausgewählte Kampagnenfeld an der Cursorposition eingefügt; ein vollständiger Teil ersetzt Betreff und Nachrichtentext im bearbeitbaren Kampagnenentwurf. Das Speichern aus Campaign legt einen persönlichen oder mandantenweiten Templates-Entwurf an und veröffentlicht ihn niemals automatisch. Bestehende Template-Revisionen und historische Kampagnenversionen bleiben unverändert.",
|
||||
}
|
||||
},
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.schedule-drafts",
|
||||
title="Schedule bounded recurring campaign drafts",
|
||||
summary="Prepare fresh campaign drafts at a future time without bypassing validation, review, approval, or delivery controls.",
|
||||
body="A Campaign schedule stores an integrity-sealed snapshot of the selected version, campaign policy, Mail profile policy, and optional shares, plus a bounded one-time, daily, weekly, or monthly recurrence. Each due occurrence creates a separately owned draft and an occurrence record. Missed intervals are coalesced instead of producing a catch-up storm. A schedule never validates, approves, queues, retries, or sends a campaign, and a preparation failure pauses it for operator review. Pause and resume reject stale browser state.",
|
||||
order=34,
|
||||
audience=("campaign_manager", "campaign_author", "operator"),
|
||||
required_scopes=("campaigns:campaign:read", "campaigns:campaign:copy", "campaigns:campaign:schedule"),
|
||||
route="/campaigns/{campaign_id}",
|
||||
screen="Campaign overview",
|
||||
help_contexts=("campaign.overview", "campaigns.action.schedule-drafts"),
|
||||
prerequisites=(
|
||||
"Choose the exact campaign version whose configuration should seed future drafts.",
|
||||
"Recipient data and active shares require their corresponding read or share authority.",
|
||||
"A worker and scheduler process must be running for automatic due-time preparation.",
|
||||
),
|
||||
steps=(
|
||||
"Open the campaign overview and choose Schedule.",
|
||||
"Set the first occurrence, timezone, recurrence, and bounded maximum occurrence count.",
|
||||
"Select which configuration domains may be copied and create the schedule.",
|
||||
"Review each generated draft independently before validating, building, approving, and sending it.",
|
||||
"Pause the schedule when the approved plan changes; a failed occurrence is paused automatically and remains visible as evidence.",
|
||||
),
|
||||
outcome="A bounded sequence of independent campaign drafts with durable schedule and occurrence evidence.",
|
||||
verification="The Schedules section shows the next occurrence and generated count; each prepared occurrence links to a distinct draft with no delivery jobs or outcomes.",
|
||||
related_topic_ids=("campaigns.workflow.copy-campaign", "campaigns.workflow.prepare-validate-and-build"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Begrenzte wiederkehrende Kampagnenentwürfe planen",
|
||||
"summary": "Künftige Kampagnenentwürfe vorbereiten, ohne Validierung, Prüfung, Freigabe oder Versandkontrollen zu umgehen.",
|
||||
"body": "Ein Kampagnenzeitplan speichert einen integritätsgesicherten Stand der ausgewählten Version, Kampagnenrichtlinie, Mail-Profilrichtlinie und optionalen Freigaben sowie eine begrenzte einmalige, tägliche, wöchentliche oder monatliche Wiederholung. Jede fällige Ausführung erzeugt einen eigenständigen Entwurf und einen Ausführungsnachweis. Verpasste Intervalle werden zusammengefasst, statt unkontrolliert nachgeholt zu werden. Der Zeitplan validiert, genehmigt, startet, wiederholt oder versendet niemals eine Kampagne; ein Fehler pausiert ihn zur betrieblichen Prüfung. Pausieren und Fortsetzen weisen veraltete Browserstände zurück.",
|
||||
"outcome": "Eine begrenzte Folge eigenständiger Kampagnenentwürfe mit dauerhaftem Zeitplan- und Ausführungsnachweis.",
|
||||
"verification": "Der Abschnitt Zeitpläne zeigt die nächste Ausführung und die Zahl erzeugter Entwürfe; jede Ausführung verweist auf einen eigenen Entwurf ohne Versandaufträge oder Ergebnisse.",
|
||||
}
|
||||
},
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.import-recipients",
|
||||
@@ -366,6 +452,41 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
||||
),
|
||||
related_modules=("files",),
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.route-unassigned-files",
|
||||
title="Review and route unassigned campaign files",
|
||||
summary="Turn files left in a watched source into an explicit report or reviewed attachment message instead of silently overlooking them.",
|
||||
body="Campaign already compares watched attachment sources with the exact files assigned to built recipient messages. An optional residual-file disposition turns the remaining set into one additional Campaign row addressed to a configured mailbox. Report mode lists the files; attach mode also includes them. The row always needs review and follows the normal build, approval, delivery, reporting, and audit lifecycle. Saving or building never sends it directly.",
|
||||
order=35,
|
||||
audience=("campaign_manager", "campaign_author", "campaign_reviewer"),
|
||||
required_scopes=("campaigns:campaign:read", "campaigns:campaign:update", "campaigns:campaign:build"),
|
||||
route="/campaigns/{campaign_id}/files",
|
||||
screen="Attachments",
|
||||
help_contexts=("campaign.attachments", "campaign.attachments.residual-files"),
|
||||
prerequisites=(
|
||||
"Enable Unsent on every attachment source that must be checked.",
|
||||
"Configure the ordinary warning or blocking policy even if no routed message is wanted.",
|
||||
"For routing, provide a reviewed recipient, subject, and report body.",
|
||||
),
|
||||
steps=(
|
||||
"Open Attachments and set the unassigned-file action to warning only, report, or report with attachments.",
|
||||
"Save and build the campaign; Campaign compares resolved recipient files with every file in the watched sources.",
|
||||
"Open the residual-file row in Review and send, inspect its exact list and any attached files, and record the review decision.",
|
||||
"Queue or send it through the same controlled Campaign lifecycle, or correct the attachment rules and rebuild instead.",
|
||||
),
|
||||
outcome="Every watched file is either assigned, deliberately reported, or represented by a visible policy finding.",
|
||||
verification="The build contains no hidden residual set: it shows either the configured warning/blocking issue or one needs-review row with residual-file provenance and the configured recipient.",
|
||||
related_topic_ids=("campaigns.workflow.use-managed-attachments", "campaigns.workflow.prepare-validate-and-build"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Nicht zugeordnete Kampagnendateien prüfen und weiterleiten",
|
||||
"summary": "Übrig gebliebene Dateien aus überwachten Quellen ausdrücklich melden oder als geprüfte Nachricht vorbereiten, statt sie unbemerkt zu übergehen.",
|
||||
"body": "Campaign vergleicht überwachte Anhangsquellen bereits mit den Dateien, die den erzeugten Empfängernachrichten tatsächlich zugeordnet sind. Eine optionale Restdatei-Behandlung erzeugt aus der verbleibenden Menge eine zusätzliche Kampagnenzeile an ein konfiguriertes Postfach. Der Berichtsmodus listet die Dateien auf; der Anhangsmodus fügt sie zusätzlich bei. Die Zeile muss immer geprüft werden und durchläuft den normalen Erzeugungs-, Freigabe-, Versand-, Berichts- und Auditablauf. Speichern oder Erzeugen versendet sie niemals unmittelbar.",
|
||||
"outcome": "Jede überwachte Datei ist zugeordnet, bewusst gemeldet oder durch einen sichtbaren Richtlinienbefund erfasst.",
|
||||
"verification": "Der Build enthält keine verborgene Restmenge: Er zeigt entweder den konfigurierten Warn- oder Sperrbefund oder eine zu prüfende Zeile mit Restdatei-Provenienz und dem konfigurierten Empfänger.",
|
||||
}
|
||||
},
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.send-calendar-invitations",
|
||||
title="Send individualized calendar invitations",
|
||||
|
||||
@@ -38,9 +38,12 @@ from govoplan_core.core.postbox import (
|
||||
)
|
||||
from govoplan_core.core.templates import (
|
||||
CAPABILITY_TEMPLATE_CATALOG,
|
||||
CAPABILITY_TEMPLATE_CONTENT_LIBRARY,
|
||||
CAPABILITY_TEMPLATE_RENDERER,
|
||||
TemplateCatalogProvider,
|
||||
TemplateCompatibility,
|
||||
TemplateContentDraftRequest,
|
||||
TemplateContentLibraryProvider,
|
||||
TemplateRef,
|
||||
TemplateRenderRequest,
|
||||
TemplateRenderResult,
|
||||
@@ -56,6 +59,7 @@ POSTBOX_DIRECTORY_CAPABILITY = CAPABILITY_POSTBOX_DIRECTORY
|
||||
POSTBOX_EVIDENCE_CAPABILITY = CAPABILITY_POSTBOX_EVIDENCE
|
||||
APPROVALS_CAPABILITY = CAPABILITY_APPROVAL_REQUESTS
|
||||
TEMPLATE_CATALOG_CAPABILITY = CAPABILITY_TEMPLATE_CATALOG
|
||||
TEMPLATE_CONTENT_LIBRARY_CAPABILITY = CAPABILITY_TEMPLATE_CONTENT_LIBRARY
|
||||
TEMPLATE_RENDERER_CAPABILITY = CAPABILITY_TEMPLATE_RENDERER
|
||||
CALENDAR_INVITATIONS_CAPABILITY = CAPABILITY_CALENDAR_INVITATIONS
|
||||
|
||||
@@ -527,6 +531,7 @@ class TemplatesCampaignIntegration:
|
||||
self,
|
||||
catalog_delegate: object | None = None,
|
||||
renderer_delegate: object | None = None,
|
||||
content_library_delegate: object | None = None,
|
||||
) -> None:
|
||||
self._catalog = (
|
||||
catalog_delegate
|
||||
@@ -538,11 +543,24 @@ class TemplatesCampaignIntegration:
|
||||
if isinstance(renderer_delegate, TemplateRendererProvider)
|
||||
else None
|
||||
)
|
||||
self._content_library = (
|
||||
content_library_delegate
|
||||
if isinstance(content_library_delegate, TemplateContentLibraryProvider)
|
||||
else None
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return self._catalog is not None and self._renderer is not None
|
||||
|
||||
@property
|
||||
def content_available(self) -> bool:
|
||||
return self._catalog is not None
|
||||
|
||||
@property
|
||||
def content_writable(self) -> bool:
|
||||
return self._content_library is not None
|
||||
|
||||
def list_templates(
|
||||
self,
|
||||
session: object,
|
||||
@@ -563,6 +581,43 @@ class TemplatesCampaignIntegration:
|
||||
)
|
||||
)
|
||||
|
||||
def list_content_templates(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
query: str = "",
|
||||
limit: int = 100,
|
||||
) -> tuple[TemplateRef, ...]:
|
||||
if self._catalog is None:
|
||||
return ()
|
||||
return tuple(
|
||||
self._catalog.list_templates(
|
||||
session,
|
||||
principal,
|
||||
query=query,
|
||||
usage="campaign.content",
|
||||
limit=limit,
|
||||
)
|
||||
)
|
||||
|
||||
def create_content_draft(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: TemplateContentDraftRequest,
|
||||
) -> TemplateRef:
|
||||
if self._content_library is None:
|
||||
raise TemplateOutputUnavailable(
|
||||
"Saving reusable Campaign content requires the Templates content-library capability."
|
||||
)
|
||||
return self._content_library.create_content_draft(
|
||||
session,
|
||||
principal,
|
||||
request=request,
|
||||
)
|
||||
|
||||
def check_compatibility(
|
||||
self,
|
||||
session: object,
|
||||
@@ -799,6 +854,7 @@ def templates_integration() -> TemplatesCampaignIntegration:
|
||||
return TemplatesCampaignIntegration(
|
||||
capability(TEMPLATE_CATALOG_CAPABILITY),
|
||||
capability(TEMPLATE_RENDERER_CAPABILITY),
|
||||
capability(TEMPLATE_CONTENT_LIBRARY_CAPABILITY),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from govoplan_core.core.campaigns import (
|
||||
CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT,
|
||||
CAPABILITY_CAMPAIGNS_POLICY_CONTEXT,
|
||||
CAPABILITY_CAMPAIGNS_RETENTION,
|
||||
CAPABILITY_CAMPAIGNS_SCHEDULES,
|
||||
)
|
||||
from govoplan_core.core.calendar import CAPABILITY_CALENDAR_INVITATIONS
|
||||
from govoplan_core.core.module_guards import (
|
||||
@@ -43,6 +44,7 @@ from govoplan_core.core.distribution_lists import (
|
||||
)
|
||||
from govoplan_core.core.templates import (
|
||||
CAPABILITY_TEMPLATE_CATALOG,
|
||||
CAPABILITY_TEMPLATE_CONTENT_LIBRARY,
|
||||
CAPABILITY_TEMPLATE_RENDERER,
|
||||
)
|
||||
from govoplan_core.core.operations import OperationalCheckProviderRegistration
|
||||
@@ -108,6 +110,12 @@ PERMISSIONS = (
|
||||
"Create campaigns or working versions from existing campaigns.",
|
||||
"Campaigns",
|
||||
),
|
||||
_permission(
|
||||
"campaigns:campaign:schedule",
|
||||
"Schedule campaign drafts",
|
||||
"Prepare fresh campaign drafts at a governed time or bounded recurrence.",
|
||||
"Campaigns",
|
||||
),
|
||||
_permission(
|
||||
"campaigns:campaign:archive",
|
||||
"Archive campaigns",
|
||||
@@ -274,6 +282,7 @@ ROLE_TEMPLATES = (
|
||||
"campaigns:campaign:create",
|
||||
"campaigns:campaign:update",
|
||||
"campaigns:campaign:copy",
|
||||
"campaigns:campaign:schedule",
|
||||
"campaigns:campaign:validate",
|
||||
"campaigns:campaign:build",
|
||||
"campaigns:ownership:accept_group",
|
||||
@@ -385,6 +394,7 @@ manifest = ModuleManifest(
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name="campaigns.access", version="0.1.6"),
|
||||
ModuleInterfaceProvider(name="campaigns.delivery_tasks", version="0.1.6"),
|
||||
ModuleInterfaceProvider(name="campaigns.schedules", version="0.1.0"),
|
||||
ModuleInterfaceProvider(name="campaigns.mail_policy_context", version="0.1.6"),
|
||||
ModuleInterfaceProvider(name="campaigns.policy_context", version="0.1.6"),
|
||||
ModuleInterfaceProvider(name="campaigns.retention", version="0.1.6"),
|
||||
@@ -454,6 +464,12 @@ manifest = ModuleManifest(
|
||||
version_max_exclusive="0.2.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name=CAPABILITY_TEMPLATE_CONTENT_LIBRARY,
|
||||
version_min="0.1.18",
|
||||
version_max_exclusive="0.2.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name=CAPABILITY_CALENDAR_INVITATIONS,
|
||||
version_min="0.2.0",
|
||||
@@ -577,6 +593,8 @@ manifest = ModuleManifest(
|
||||
retirement_supported=True,
|
||||
retirement_provider=drop_table_retirement_provider(
|
||||
campaign_models.Campaign,
|
||||
campaign_models.CampaignSchedule,
|
||||
campaign_models.CampaignScheduleOccurrence,
|
||||
campaign_models.CampaignShare,
|
||||
campaign_models.RecipientImportMappingProfile,
|
||||
campaign_models.CampaignVersion,
|
||||
@@ -597,6 +615,8 @@ manifest = ModuleManifest(
|
||||
uninstall_guard_providers=(
|
||||
persistent_table_uninstall_guard(
|
||||
campaign_models.Campaign,
|
||||
campaign_models.CampaignSchedule,
|
||||
campaign_models.CampaignScheduleOccurrence,
|
||||
campaign_models.CampaignShare,
|
||||
campaign_models.RecipientImportMappingProfile,
|
||||
campaign_models.CampaignVersion,
|
||||
@@ -1149,6 +1169,10 @@ manifest = ModuleManifest(
|
||||
"govoplan_campaign.backend.capabilities",
|
||||
fromlist=["delivery_tasks_capability"],
|
||||
).delivery_tasks_capability(context),
|
||||
CAPABILITY_CAMPAIGNS_SCHEDULES: lambda context: __import__(
|
||||
"govoplan_campaign.backend.capabilities",
|
||||
fromlist=["schedules_capability"],
|
||||
).schedules_capability(context),
|
||||
CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT: lambda context: __import__(
|
||||
"govoplan_campaign.backend.capabilities",
|
||||
fromlist=["mail_policy_context_capability"],
|
||||
|
||||
@@ -24,11 +24,13 @@ from govoplan_campaign.backend.campaign.entries import load_campaign_entries
|
||||
from govoplan_campaign.backend.campaign.field_values import ignored_entry_field_overrides
|
||||
from govoplan_campaign.backend.campaign.models import (
|
||||
Behavior,
|
||||
AttachmentConfig,
|
||||
BuildStatus,
|
||||
CampaignConfig,
|
||||
EntryConfig,
|
||||
MissingAddressBehavior,
|
||||
RecipientConfig,
|
||||
ResidualFileMode,
|
||||
SendStatus,
|
||||
TemplateBodyMode,
|
||||
ZipArchiveConfig,
|
||||
@@ -92,6 +94,13 @@ class _MimeBuildResult:
|
||||
attachment_count: int
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _ResidualFileGroup:
|
||||
source_name: str
|
||||
directory: Path
|
||||
files: list[Path]
|
||||
|
||||
|
||||
def _resolve(campaign_file: str | Path, raw_path: str) -> Path:
|
||||
campaign_path = Path(campaign_file).resolve()
|
||||
path = Path(raw_path).expanduser()
|
||||
@@ -890,17 +899,13 @@ def build_entry_message(
|
||||
|
||||
|
||||
|
||||
def _unsent_attachment_issues(
|
||||
def _residual_attachment_files(
|
||||
*,
|
||||
config: CampaignConfig,
|
||||
campaign_file: str | Path,
|
||||
built_messages: list[BuiltMessage],
|
||||
attachment_match_index: AttachmentMatchIndex | None = None,
|
||||
) -> list[MessageIssue]:
|
||||
behavior = config.validation_policy.unsent_attachment_files.value
|
||||
if behavior == Behavior.CONTINUE.value:
|
||||
return []
|
||||
|
||||
) -> list[_ResidualFileGroup]:
|
||||
matched_files = {
|
||||
Path(match).resolve()
|
||||
for built in built_messages
|
||||
@@ -908,7 +913,7 @@ def _unsent_attachment_issues(
|
||||
for match in attachment.matches
|
||||
}
|
||||
|
||||
issues: list[MessageIssue] = []
|
||||
groups: list[_ResidualFileGroup] = []
|
||||
for base_path in config.attachments.base_paths:
|
||||
if not base_path.unsent_warning:
|
||||
continue
|
||||
@@ -922,20 +927,182 @@ def _unsent_attachment_issues(
|
||||
unsent = [path for path in all_files if path not in matched_files]
|
||||
if not unsent:
|
||||
continue
|
||||
groups.append(
|
||||
_ResidualFileGroup(
|
||||
source_name=base_path.name,
|
||||
directory=directory,
|
||||
files=unsent,
|
||||
)
|
||||
)
|
||||
return groups
|
||||
|
||||
|
||||
def _unsent_attachment_issues(
|
||||
*,
|
||||
config: CampaignConfig,
|
||||
residual_groups: list[_ResidualFileGroup],
|
||||
) -> list[MessageIssue]:
|
||||
behavior = config.validation_policy.unsent_attachment_files.value
|
||||
if (
|
||||
behavior == Behavior.CONTINUE.value
|
||||
or config.attachments.residual_files.mode != ResidualFileMode.NONE
|
||||
):
|
||||
return []
|
||||
issues: list[MessageIssue] = []
|
||||
for group in residual_groups:
|
||||
unsent = group.files
|
||||
directory = group.directory
|
||||
shown = ", ".join(str(path.relative_to(directory)) for path in unsent[:10])
|
||||
if len(unsent) > 10:
|
||||
shown += f", … (+{len(unsent) - 10} more)"
|
||||
issues.append(
|
||||
_issue_from_behavior(
|
||||
code="unsent_attachment_files",
|
||||
message=f"{len(unsent)} file(s) in attachment source {base_path.name!r} are not used by any message: {shown}",
|
||||
message=f"{len(unsent)} file(s) in attachment source {group.source_name!r} are not used by any message: {shown}",
|
||||
behavior=behavior,
|
||||
source=f"attachments:{base_path.name}",
|
||||
source=f"attachments:{group.source_name}",
|
||||
)
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def _build_residual_file_message(
|
||||
*,
|
||||
config: CampaignConfig,
|
||||
campaign_file: Path,
|
||||
residual_groups: list[_ResidualFileGroup],
|
||||
entry_index: int,
|
||||
output_dir: Path | None,
|
||||
write_eml: bool,
|
||||
work_dir: Path,
|
||||
attachment_match_index: AttachmentMatchIndex,
|
||||
) -> BuiltMessage | None:
|
||||
disposition = config.attachments.residual_files
|
||||
if disposition.mode == ResidualFileMode.NONE or disposition.recipient is None:
|
||||
return None
|
||||
files = sorted({path.resolve() for group in residual_groups for path in group.files})
|
||||
if not files:
|
||||
return None
|
||||
file_lines = [
|
||||
f"{group.source_name}: {path.relative_to(group.directory)}"
|
||||
for group in residual_groups
|
||||
for path in group.files
|
||||
]
|
||||
entry = EntryConfig(
|
||||
id="__residual_files__",
|
||||
to=[disposition.recipient],
|
||||
merge_to=False,
|
||||
combine_attachments=True,
|
||||
fields={
|
||||
"campaign_name": config.campaign.name,
|
||||
"residual_file_count": len(files),
|
||||
"residual_file_list": "\n".join(file_lines),
|
||||
},
|
||||
)
|
||||
residual_config = config.model_copy(deep=True)
|
||||
residual_config.attachments.global_ = []
|
||||
residual_config.attachments.zip.enabled = False
|
||||
if disposition.mode == ResidualFileMode.ATTACH:
|
||||
residual_config.attachments.global_ = [
|
||||
AttachmentConfig(
|
||||
id=f"residual-file-{index}",
|
||||
label=path.name,
|
||||
base_dir=str(path.parent),
|
||||
file_filter=path.name,
|
||||
required=True,
|
||||
allow_multiple=False,
|
||||
)
|
||||
for index, path in enumerate(files, start=1)
|
||||
]
|
||||
context = _entry_message_context(
|
||||
config=residual_config,
|
||||
campaign_file=campaign_file,
|
||||
entry=entry,
|
||||
entry_index=entry_index,
|
||||
attachment_match_index=attachment_match_index,
|
||||
)
|
||||
context.validation_status = _validate_required_sender(
|
||||
context.senders,
|
||||
context.issues,
|
||||
context.validation_status,
|
||||
)
|
||||
context.validation_status = _validate_required_recipients(
|
||||
residual_config,
|
||||
context.recipients,
|
||||
context.issues,
|
||||
context.validation_status,
|
||||
)
|
||||
values = build_template_values(residual_config, entry)
|
||||
rendered = _RenderedMessageTemplate(
|
||||
subject=_render_template(disposition.subject, values, keep_missing=True),
|
||||
text_body=_render_template(disposition.text, values, keep_missing=True),
|
||||
html_body=None,
|
||||
body_mode=TemplateBodyMode.TEXT.value,
|
||||
values=values,
|
||||
)
|
||||
context.validation_status = _validate_rendered_template(
|
||||
residual_config,
|
||||
rendered,
|
||||
context.issues,
|
||||
context.validation_status,
|
||||
)
|
||||
context.issues.append(
|
||||
MessageIssue(
|
||||
severity="warning",
|
||||
code="residual_attachment_disposition",
|
||||
message=(
|
||||
f"{len(files)} unassigned file(s) are routed as a reviewed "
|
||||
f"{disposition.mode.value} message."
|
||||
),
|
||||
behavior="ask",
|
||||
source="attachments:residual_files",
|
||||
details={
|
||||
"mode": disposition.mode.value,
|
||||
"file_count": len(files),
|
||||
"source_count": len(residual_groups),
|
||||
},
|
||||
)
|
||||
)
|
||||
if context.validation_status not in {
|
||||
MessageValidationStatus.BLOCKED,
|
||||
MessageValidationStatus.EXCLUDED,
|
||||
}:
|
||||
context.validation_status = MessageValidationStatus.NEEDS_REVIEW
|
||||
mime_result = _build_mime_message(
|
||||
config=residual_config,
|
||||
entry=entry,
|
||||
entry_index=entry_index,
|
||||
output_dir=output_dir,
|
||||
work_dir=work_dir,
|
||||
context=context,
|
||||
rendered=rendered,
|
||||
)
|
||||
eml_path: str | None = None
|
||||
eml_size: int | None = None
|
||||
if write_eml and output_dir is not None and mime_result.message is not None:
|
||||
eml_path, eml_size = _write_eml(
|
||||
mime_result.message,
|
||||
output_dir,
|
||||
entry,
|
||||
entry_index,
|
||||
)
|
||||
return BuiltMessage(
|
||||
draft=_message_draft(
|
||||
config=residual_config,
|
||||
entry=entry,
|
||||
entry_index=entry_index,
|
||||
context=context,
|
||||
build_status=mime_result.build_status,
|
||||
validation_status=mime_result.validation_status,
|
||||
subject=rendered.subject,
|
||||
attachment_count=mime_result.attachment_count,
|
||||
eml_path=eml_path,
|
||||
eml_size=eml_size,
|
||||
),
|
||||
mime=mime_result.message,
|
||||
)
|
||||
|
||||
|
||||
def _apply_campaign_level_issues(built_messages: list[BuiltMessage], issues: list[MessageIssue]) -> None:
|
||||
if not issues:
|
||||
return
|
||||
@@ -978,15 +1145,31 @@ def build_campaign_messages(
|
||||
for index, entry in enumerate(entries, start=1)
|
||||
if entry.active
|
||||
]
|
||||
residual_groups = _residual_attachment_files(
|
||||
config=config,
|
||||
campaign_file=campaign_path,
|
||||
built_messages=built_messages,
|
||||
attachment_match_index=attachment_match_index,
|
||||
)
|
||||
_apply_campaign_level_issues(
|
||||
built_messages,
|
||||
_unsent_attachment_issues(
|
||||
config=config,
|
||||
campaign_file=campaign_path,
|
||||
built_messages=built_messages,
|
||||
attachment_match_index=attachment_match_index,
|
||||
residual_groups=residual_groups,
|
||||
),
|
||||
)
|
||||
residual_message = _build_residual_file_message(
|
||||
config=config,
|
||||
campaign_file=campaign_path,
|
||||
residual_groups=residual_groups,
|
||||
entry_index=len(entries) + 1,
|
||||
output_dir=output_path,
|
||||
write_eml=write_eml,
|
||||
work_dir=work_dir,
|
||||
attachment_match_index=attachment_match_index,
|
||||
)
|
||||
if residual_message is not None:
|
||||
built_messages.append(residual_message)
|
||||
|
||||
rules_resolved = sum(len(built.draft.attachments) for built in built_messages)
|
||||
report = CampaignBuildReport(
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
"""add campaign schedule optimistic-concurrency revisions
|
||||
|
||||
Revision ID: a5b6c7d8e9f0
|
||||
Revises: f4a5b6c7d8e9
|
||||
Create Date: 2026-08-07 12:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
|
||||
|
||||
_migration = import_module(
|
||||
"govoplan_campaign.backend.migrations.versions."
|
||||
"a5b6c7d8e9f0_v0120_campaign_schedule_revisions"
|
||||
)
|
||||
revision = _migration.revision
|
||||
down_revision = _migration.down_revision
|
||||
branch_labels = _migration.branch_labels
|
||||
depends_on = _migration.depends_on
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_migration.upgrade()
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_migration.downgrade()
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
"""add durable campaign schedules and occurrence evidence
|
||||
|
||||
Revision ID: f4a5b6c7d8e9
|
||||
Revises: e3c8f4a5b6d7
|
||||
Create Date: 2026-08-07 10:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
|
||||
|
||||
_migration = import_module(
|
||||
"govoplan_campaign.backend.migrations.versions."
|
||||
"f4a5b6c7d8e9_v0119_campaign_schedules"
|
||||
)
|
||||
revision = _migration.revision
|
||||
down_revision = _migration.down_revision
|
||||
branch_labels = _migration.branch_labels
|
||||
depends_on = _migration.depends_on
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_migration.upgrade()
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_migration.downgrade()
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
"""add campaign schedule optimistic-concurrency revisions
|
||||
|
||||
Revision ID: a5b6c7d8e9f0
|
||||
Revises: f4a5b6c7d8e9
|
||||
Create Date: 2026-08-07 12:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "a5b6c7d8e9f0"
|
||||
down_revision = "f4a5b6c7d8e9"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if inspector.has_table("campaign_schedules"):
|
||||
columns = {column["name"] for column in inspector.get_columns("campaign_schedules")}
|
||||
if "resource_revision" not in columns:
|
||||
op.add_column(
|
||||
"campaign_schedules",
|
||||
sa.Column(
|
||||
"resource_revision",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="1",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if inspector.has_table("campaign_schedules"):
|
||||
columns = {column["name"] for column in inspector.get_columns("campaign_schedules")}
|
||||
if "resource_revision" in columns:
|
||||
op.drop_column("campaign_schedules", "resource_revision")
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
"""add durable campaign schedules and occurrence evidence
|
||||
|
||||
Revision ID: f4a5b6c7d8e9
|
||||
Revises: e3c8f4a5b6d7
|
||||
Create Date: 2026-08-07 10:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "f4a5b6c7d8e9"
|
||||
down_revision = "e3c8f4a5b6d7"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if not inspector.has_table("campaign_schedules"):
|
||||
op.create_table(
|
||||
"campaign_schedules",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("campaign_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("source_version_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("recurrence_kind", sa.String(length=20), nullable=False),
|
||||
sa.Column("interval_count", sa.Integer(), nullable=False),
|
||||
sa.Column("timezone", sa.String(length=100), nullable=False),
|
||||
sa.Column("starts_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("next_fire_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("ends_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("max_occurrences", sa.Integer(), nullable=False),
|
||||
sa.Column("occurrence_count", sa.Integer(), nullable=False),
|
||||
sa.Column("active", sa.Boolean(), nullable=False),
|
||||
sa.Column("resource_revision", sa.Integer(), nullable=False),
|
||||
sa.Column("copy_options", sa.JSON(), nullable=False),
|
||||
sa.Column("source_snapshot", sa.JSON(), nullable=False),
|
||||
sa.Column("source_snapshot_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("source_base_path", sa.String(length=1000), nullable=True),
|
||||
sa.Column("last_fired_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_campaign_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("last_error", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["campaign_id"], ["campaigns.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["source_version_id"], ["campaign_versions.id"], ondelete="RESTRICT"),
|
||||
sa.ForeignKeyConstraint(["created_by_user_id"], ["access_users.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["last_campaign_id"], ["campaigns.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
for name, columns in (
|
||||
("ix_campaign_schedules_tenant_id", ["tenant_id"]),
|
||||
("ix_campaign_schedules_campaign_id", ["campaign_id"]),
|
||||
("ix_campaign_schedules_source_version_id", ["source_version_id"]),
|
||||
("ix_campaign_schedules_created_by_user_id", ["created_by_user_id"]),
|
||||
("ix_campaign_schedules_recurrence_kind", ["recurrence_kind"]),
|
||||
("ix_campaign_schedules_next_fire_at", ["next_fire_at"]),
|
||||
("ix_campaign_schedules_active", ["active"]),
|
||||
("ix_campaign_schedules_source_snapshot_hash", ["source_snapshot_hash"]),
|
||||
("ix_campaign_schedules_last_campaign_id", ["last_campaign_id"]),
|
||||
("ix_campaign_schedules_due", ["tenant_id", "active", "next_fire_at"]),
|
||||
):
|
||||
op.create_index(name, "campaign_schedules", columns, unique=False)
|
||||
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if not inspector.has_table("campaign_schedule_occurrences"):
|
||||
op.create_table(
|
||||
"campaign_schedule_occurrences",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("schedule_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("scheduled_for", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("status", sa.String(length=30), nullable=False),
|
||||
sa.Column("generated_campaign_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("generated_version_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("error", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["schedule_id"], ["campaign_schedules.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["generated_campaign_id"], ["campaigns.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["generated_version_id"], ["campaign_versions.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("schedule_id", "scheduled_for", name="uq_campaign_schedule_occurrence"),
|
||||
)
|
||||
for name, columns in (
|
||||
("ix_campaign_schedule_occurrences_tenant_id", ["tenant_id"]),
|
||||
("ix_campaign_schedule_occurrences_schedule_id", ["schedule_id"]),
|
||||
("ix_campaign_schedule_occurrences_status", ["status"]),
|
||||
("ix_campaign_schedule_occurrences_generated_campaign_id", ["generated_campaign_id"]),
|
||||
("ix_campaign_schedule_occurrences_generated_version_id", ["generated_version_id"]),
|
||||
("ix_campaign_schedule_occurrences_schedule", ["schedule_id", "scheduled_for"]),
|
||||
):
|
||||
op.create_index(name, "campaign_schedule_occurrences", columns, unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if inspector.has_table("campaign_schedule_occurrences"):
|
||||
op.drop_table("campaign_schedule_occurrences")
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if inspector.has_table("campaign_schedules"):
|
||||
op.drop_table("campaign_schedules")
|
||||
@@ -8,6 +8,7 @@ from govoplan_campaign.backend.routes.delivery import router as delivery_router
|
||||
from govoplan_campaign.backend.routes.jobs import router as jobs_router
|
||||
from govoplan_campaign.backend.routes.operations import router as operations_router
|
||||
from govoplan_campaign.backend.routes.reports import router as reports_router
|
||||
from govoplan_campaign.backend.routes.schedules import router as schedules_router
|
||||
from govoplan_campaign.backend.routes.sharing import router as sharing_router
|
||||
from govoplan_campaign.backend.routes.versions import router as versions_router
|
||||
|
||||
@@ -19,6 +20,7 @@ for workflow_router in (
|
||||
versions_router,
|
||||
jobs_router,
|
||||
reports_router,
|
||||
schedules_router,
|
||||
sharing_router,
|
||||
delivery_router,
|
||||
attachments_router,
|
||||
|
||||
@@ -14,6 +14,7 @@ from govoplan_campaign.backend.schemas import (
|
||||
CampaignCreateResponse,
|
||||
CampaignCreateMinimalRequest,
|
||||
CampaignCopyRequest,
|
||||
CampaignContentLibrarySaveRequest,
|
||||
CampaignLifecycleMutationRequest,
|
||||
CampaignLifecyclePolicyResponse,
|
||||
CampaignAddressLookupCandidate,
|
||||
@@ -42,6 +43,7 @@ from govoplan_campaign.backend.schemas import (
|
||||
)
|
||||
from govoplan_core.auth import ApiPrincipal, has_scope, require_scope
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_core.core.templates import TemplateContentDraftRequest, TemplateRef
|
||||
from govoplan_core.core.change_sequence import (
|
||||
decode_sequence_watermark,
|
||||
encode_sequence_watermark,
|
||||
@@ -60,6 +62,7 @@ from govoplan_campaign.backend.change_tracking import (
|
||||
)
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignShare,
|
||||
CampaignVersion,
|
||||
RecipientImportMappingProfile,
|
||||
)
|
||||
@@ -70,6 +73,7 @@ from govoplan_campaign.backend.campaign.lifecycle import (
|
||||
assert_lifecycle_state_token,
|
||||
campaign_lifecycle_policy,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.copying import campaign_copy_configuration
|
||||
from govoplan_campaign.backend.integrations import (
|
||||
calendar_integration,
|
||||
PostboxDeliveryUnavailable,
|
||||
@@ -214,6 +218,13 @@ def _campaign_copy_external_id(
|
||||
)
|
||||
|
||||
|
||||
def _campaign_copy_configuration(
|
||||
source: dict[str, object],
|
||||
payload: CampaignCopyRequest,
|
||||
) -> dict[str, object]:
|
||||
return campaign_copy_configuration(source, payload.model_dump())
|
||||
|
||||
|
||||
@router.post("", response_model=CampaignCreateResponse)
|
||||
def create_campaign(
|
||||
payload: CampaignCreateRequest,
|
||||
@@ -924,6 +935,192 @@ def campaign_print_templates(
|
||||
}
|
||||
|
||||
|
||||
def _campaign_content_library_item(template: TemplateRef) -> dict[str, object]:
|
||||
revision = template.revision
|
||||
revision_metadata = dict(revision.metadata) if revision else {}
|
||||
raw_targets = revision_metadata.get("campaign_targets")
|
||||
targets = (
|
||||
[str(value) for value in raw_targets if str(value) in {"subject", "text", "html"}]
|
||||
if isinstance(raw_targets, (list, tuple))
|
||||
else []
|
||||
)
|
||||
if not targets and revision is not None:
|
||||
if template.template_type == "email":
|
||||
targets = ["subject", "text", "html"]
|
||||
else:
|
||||
if revision.content_text:
|
||||
targets.append("text")
|
||||
if revision.content_html:
|
||||
targets.append("html")
|
||||
kind = str(revision_metadata.get("campaign_kind") or "").strip()
|
||||
if kind not in {"fragment", "campaign_part"}:
|
||||
kind = "fragment" if template.template_type == "content_fragment" else "campaign_part"
|
||||
return {
|
||||
"id": template.id,
|
||||
"name": template.name,
|
||||
"description": template.description,
|
||||
"template_type": template.template_type,
|
||||
"kind": kind,
|
||||
"status": template.status,
|
||||
"scope_type": template.scope_type,
|
||||
"scope_id": template.scope_id,
|
||||
"read_only": template.read_only,
|
||||
"current_revision": template.current_revision,
|
||||
"revision": revision.revision if revision else template.current_revision,
|
||||
"revision_id": revision.id if revision else template.current_revision_id,
|
||||
"locale": revision.locale if revision else None,
|
||||
"published": bool(template.published_revision_id),
|
||||
"targets": list(dict.fromkeys(targets)),
|
||||
"subject": revision_metadata.get("campaign_subject"),
|
||||
"text": revision.content_text if revision else None,
|
||||
"html": revision.content_html if revision else None,
|
||||
"body_mode": revision_metadata.get("campaign_body_mode") or "both",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/content-library")
|
||||
def campaign_content_library(
|
||||
campaign_id: str,
|
||||
query: str = Query(default="", min_length=0, max_length=200),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal)
|
||||
integration = templates_integration()
|
||||
if not integration.content_available:
|
||||
return {
|
||||
"available": False,
|
||||
"writable": False,
|
||||
"reason": "The Templates content library is not active.",
|
||||
"items": [],
|
||||
}
|
||||
try:
|
||||
templates = integration.list_content_templates(
|
||||
session,
|
||||
principal,
|
||||
query=query,
|
||||
limit=250,
|
||||
)
|
||||
except PermissionError as exc:
|
||||
return {
|
||||
"available": True,
|
||||
"writable": False,
|
||||
"reason": str(exc),
|
||||
"items": [],
|
||||
}
|
||||
return {
|
||||
"available": True,
|
||||
"writable": integration.content_writable,
|
||||
"items": [_campaign_content_library_item(template) for template in templates],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/content-library", status_code=status.HTTP_201_CREATED)
|
||||
def save_campaign_content_library_item(
|
||||
campaign_id: str,
|
||||
payload: CampaignContentLibrarySaveRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
|
||||
):
|
||||
campaign = _get_campaign_for_principal(
|
||||
session,
|
||||
campaign_id,
|
||||
principal,
|
||||
write=True,
|
||||
)
|
||||
integration = templates_integration()
|
||||
if not integration.content_writable:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="The Templates content-library capability is not active.",
|
||||
)
|
||||
target = payload.target if payload.kind == "fragment" else None
|
||||
content_text = (
|
||||
payload.subject
|
||||
if target == "subject"
|
||||
else payload.text
|
||||
if target == "text"
|
||||
else None
|
||||
)
|
||||
content_html = payload.html if target == "html" else None
|
||||
if payload.kind == "campaign_part":
|
||||
content_text = payload.text
|
||||
content_html = payload.html
|
||||
metadata: dict[str, object] = {
|
||||
"campaign_kind": payload.kind,
|
||||
"campaign_targets": (
|
||||
[target]
|
||||
if target
|
||||
else [
|
||||
field
|
||||
for field, value in (
|
||||
("subject", payload.subject),
|
||||
("text", payload.text),
|
||||
("html", payload.html),
|
||||
)
|
||||
if value and value.strip()
|
||||
]
|
||||
),
|
||||
"campaign_body_mode": payload.body_mode,
|
||||
"source_module": "campaigns",
|
||||
"source_campaign_id": campaign.id,
|
||||
}
|
||||
if payload.kind == "campaign_part" and payload.subject:
|
||||
metadata["campaign_subject"] = payload.subject
|
||||
try:
|
||||
template = integration.create_content_draft(
|
||||
session,
|
||||
principal,
|
||||
request=TemplateContentDraftRequest(
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
template_type=(
|
||||
"content_fragment" if payload.kind == "fragment" else "email"
|
||||
),
|
||||
usages=("campaign.content",),
|
||||
content_text=content_text,
|
||||
content_html=content_html,
|
||||
locale=payload.locale,
|
||||
scope_type=("user" if payload.visibility == "personal" else "tenant"),
|
||||
scope_id=(
|
||||
principal.account_id if payload.visibility == "personal" else None
|
||||
),
|
||||
metadata=metadata,
|
||||
),
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.content_library_saved",
|
||||
object_type="campaign",
|
||||
object_id=campaign.id,
|
||||
details={
|
||||
"template_id": template.id,
|
||||
"template_revision_id": (
|
||||
template.revision.id if template.revision else None
|
||||
),
|
||||
"kind": payload.kind,
|
||||
"target": payload.target,
|
||||
"visibility": payload.visibility,
|
||||
},
|
||||
commit=False,
|
||||
)
|
||||
session.commit()
|
||||
except PermissionError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except ValueError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
return {"template": _campaign_content_library_item(template)}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/recipient-address-sources/snapshot",
|
||||
response_model=CampaignRecipientAddressSourceSnapshotResponse,
|
||||
@@ -1652,7 +1849,10 @@ def copy_campaign(
|
||||
action="copy_campaign",
|
||||
version_id=payload.source_version_id,
|
||||
)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
if payload.include_recipients:
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
if payload.include_shares:
|
||||
_require_permission(principal, "campaigns:campaign:share")
|
||||
source_version = (
|
||||
session.query(CampaignVersion)
|
||||
.filter(
|
||||
@@ -1674,7 +1874,7 @@ def copy_campaign(
|
||||
requested=payload.external_id,
|
||||
)
|
||||
name = (payload.name or f"{source_campaign.name} (copy)").strip()
|
||||
raw_json = copy.deepcopy(source_version.raw_json)
|
||||
raw_json = _campaign_copy_configuration(source_version.raw_json, payload)
|
||||
campaign_metadata = raw_json.get("campaign")
|
||||
if not isinstance(campaign_metadata, dict):
|
||||
raise HTTPException(
|
||||
@@ -1696,6 +1896,36 @@ def copy_campaign(
|
||||
source_base_path=source_version.source_base_path,
|
||||
commit=False,
|
||||
)
|
||||
if payload.include_policies:
|
||||
campaign.settings = copy.deepcopy(source_campaign.settings or {})
|
||||
if payload.include_mail_profile:
|
||||
campaign.mail_profile_policy = copy.deepcopy(
|
||||
source_campaign.mail_profile_policy or {}
|
||||
)
|
||||
copied_share_count = 0
|
||||
if payload.include_shares:
|
||||
source_shares = (
|
||||
session.query(CampaignShare)
|
||||
.filter(
|
||||
CampaignShare.tenant_id == principal.tenant_id,
|
||||
CampaignShare.campaign_id == source_campaign.id,
|
||||
CampaignShare.revoked_at.is_(None),
|
||||
)
|
||||
.order_by(CampaignShare.id.asc())
|
||||
.all()
|
||||
)
|
||||
for source_share in source_shares:
|
||||
session.add(
|
||||
CampaignShare(
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
target_type=source_share.target_type,
|
||||
target_id=source_share.target_id,
|
||||
permission=source_share.permission,
|
||||
created_by_user_id=principal.user.id,
|
||||
)
|
||||
)
|
||||
copied_share_count = len(source_shares)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
@@ -1707,6 +1937,14 @@ def copy_campaign(
|
||||
"source_version_id": source_version.id,
|
||||
"destination_version_id": version.id,
|
||||
"copied_evidence": False,
|
||||
"copy_options": {
|
||||
"recipients": payload.include_recipients,
|
||||
"files": payload.include_files,
|
||||
"shares": payload.include_shares,
|
||||
"policies": payload.include_policies,
|
||||
"mail_profile": payload.include_mail_profile,
|
||||
},
|
||||
"copied_share_count": copied_share_count,
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.campaign.scheduling import (
|
||||
campaign_schedule_source_snapshot,
|
||||
canonical_configuration_hash,
|
||||
)
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
CampaignSchedule,
|
||||
CampaignScheduleOccurrence,
|
||||
CampaignShare,
|
||||
CampaignVersion,
|
||||
)
|
||||
from govoplan_campaign.backend.route_support import (
|
||||
_get_campaign_for_principal,
|
||||
_require_permission,
|
||||
)
|
||||
from govoplan_campaign.backend.schemas import (
|
||||
CampaignScheduleCreateRequest,
|
||||
CampaignScheduleListResponse,
|
||||
CampaignScheduleOccurrenceResponse,
|
||||
CampaignScheduleResponse,
|
||||
CampaignScheduleStateRequest,
|
||||
)
|
||||
from govoplan_core.auth import ApiPrincipal, require_scope
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_core.db.session import get_session
|
||||
|
||||
|
||||
router = APIRouter(prefix="/campaigns", tags=["campaign-schedules"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{campaign_id}/schedules",
|
||||
response_model=CampaignScheduleListResponse,
|
||||
)
|
||||
def list_campaign_schedules(
|
||||
campaign_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal)
|
||||
schedules = (
|
||||
session.query(CampaignSchedule)
|
||||
.filter(
|
||||
CampaignSchedule.tenant_id == principal.tenant_id,
|
||||
CampaignSchedule.campaign_id == campaign_id,
|
||||
)
|
||||
.order_by(CampaignSchedule.created_at.desc(), CampaignSchedule.id.asc())
|
||||
.all()
|
||||
)
|
||||
occurrences = _occurrences_by_schedule(session, schedules)
|
||||
return CampaignScheduleListResponse(
|
||||
items=[
|
||||
_schedule_response(item, occurrences.get(item.id, []))
|
||||
for item in schedules
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/schedules",
|
||||
response_model=CampaignScheduleResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_campaign_schedule(
|
||||
campaign_id: str,
|
||||
payload: CampaignScheduleCreateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:schedule")),
|
||||
):
|
||||
campaign = _get_campaign_for_principal(session, campaign_id, principal)
|
||||
_require_permission(principal, "campaigns:campaign:copy")
|
||||
if payload.include_recipients:
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
if payload.include_shares:
|
||||
_require_permission(principal, "campaigns:campaign:share")
|
||||
source_version = (
|
||||
session.query(CampaignVersion)
|
||||
.filter(
|
||||
CampaignVersion.id == payload.source_version_id,
|
||||
CampaignVersion.campaign_id == campaign.id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if source_version is None:
|
||||
raise HTTPException(status_code=404, detail="Campaign version not found")
|
||||
starts_at = payload.starts_at.astimezone(UTC)
|
||||
if starts_at < datetime.now(UTC) - timedelta(minutes=5):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="Campaign schedules cannot start in the past.",
|
||||
)
|
||||
try:
|
||||
ZoneInfo(payload.timezone)
|
||||
except ZoneInfoNotFoundError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="Unknown campaign schedule timezone.",
|
||||
) from exc
|
||||
|
||||
source_shares = (
|
||||
session.query(CampaignShare)
|
||||
.filter(
|
||||
CampaignShare.tenant_id == principal.tenant_id,
|
||||
CampaignShare.campaign_id == campaign.id,
|
||||
CampaignShare.revoked_at.is_(None),
|
||||
)
|
||||
.order_by(CampaignShare.id.asc())
|
||||
.all()
|
||||
if payload.include_shares
|
||||
else []
|
||||
)
|
||||
snapshot = campaign_schedule_source_snapshot(
|
||||
configuration=source_version.raw_json,
|
||||
campaign_settings=campaign.settings or {},
|
||||
mail_profile_policy=campaign.mail_profile_policy or {},
|
||||
shares=[
|
||||
{
|
||||
"target_type": item.target_type,
|
||||
"target_id": item.target_id,
|
||||
"permission": item.permission,
|
||||
}
|
||||
for item in source_shares
|
||||
],
|
||||
)
|
||||
schedule = CampaignSchedule(
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
source_version_id=source_version.id,
|
||||
created_by_user_id=principal.user.id,
|
||||
name=payload.name.strip(),
|
||||
recurrence_kind=payload.recurrence_kind,
|
||||
interval_count=payload.interval_count,
|
||||
timezone=payload.timezone,
|
||||
starts_at=starts_at,
|
||||
next_fire_at=starts_at,
|
||||
ends_at=payload.ends_at.astimezone(UTC) if payload.ends_at else None,
|
||||
max_occurrences=payload.max_occurrences,
|
||||
copy_options={
|
||||
"include_recipients": payload.include_recipients,
|
||||
"include_files": payload.include_files,
|
||||
"include_shares": payload.include_shares,
|
||||
"include_policies": payload.include_policies,
|
||||
"include_mail_profile": payload.include_mail_profile,
|
||||
},
|
||||
source_snapshot=snapshot,
|
||||
source_snapshot_hash=canonical_configuration_hash(snapshot),
|
||||
source_base_path=source_version.source_base_path,
|
||||
)
|
||||
session.add(schedule)
|
||||
session.flush()
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.schedule.created",
|
||||
object_type="campaign_schedule",
|
||||
object_id=schedule.id,
|
||||
details={
|
||||
"campaign_id": campaign.id,
|
||||
"source_version_id": source_version.id,
|
||||
"recurrence_kind": schedule.recurrence_kind,
|
||||
"interval_count": schedule.interval_count,
|
||||
"starts_at": schedule.starts_at.isoformat(),
|
||||
"ends_at": schedule.ends_at.isoformat() if schedule.ends_at else None,
|
||||
"max_occurrences": schedule.max_occurrences,
|
||||
"delivery_started": False,
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
session.refresh(schedule)
|
||||
return _schedule_response(schedule, [])
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{campaign_id}/schedules/{schedule_id}",
|
||||
response_model=CampaignScheduleResponse,
|
||||
)
|
||||
def set_campaign_schedule_state(
|
||||
campaign_id: str,
|
||||
schedule_id: str,
|
||||
payload: CampaignScheduleStateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:schedule")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal)
|
||||
schedule = _schedule_for_campaign(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
schedule_id=schedule_id,
|
||||
for_update=True,
|
||||
)
|
||||
if schedule.resource_revision != payload.base_revision:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Campaign schedule changed. Reload it before changing its state.",
|
||||
)
|
||||
if payload.active and schedule.next_fire_at is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="A completed campaign schedule cannot be resumed.",
|
||||
)
|
||||
schedule.active = payload.active
|
||||
schedule.last_error = None if payload.active else schedule.last_error
|
||||
schedule.resource_revision += 1
|
||||
session.add(schedule)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action=(
|
||||
"campaign.schedule.resumed"
|
||||
if payload.active
|
||||
else "campaign.schedule.paused"
|
||||
),
|
||||
object_type="campaign_schedule",
|
||||
object_id=schedule.id,
|
||||
details={"campaign_id": campaign_id},
|
||||
commit=True,
|
||||
)
|
||||
session.refresh(schedule)
|
||||
occurrences = _occurrences_by_schedule(session, [schedule]).get(schedule.id, [])
|
||||
return _schedule_response(schedule, occurrences)
|
||||
|
||||
|
||||
def _schedule_for_campaign(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
schedule_id: str,
|
||||
for_update: bool = False,
|
||||
) -> CampaignSchedule:
|
||||
query = session.query(CampaignSchedule)
|
||||
if for_update:
|
||||
query = query.with_for_update()
|
||||
schedule = (
|
||||
query
|
||||
.filter(
|
||||
CampaignSchedule.id == schedule_id,
|
||||
CampaignSchedule.tenant_id == tenant_id,
|
||||
CampaignSchedule.campaign_id == campaign_id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if schedule is None:
|
||||
raise HTTPException(status_code=404, detail="Campaign schedule not found")
|
||||
return schedule
|
||||
|
||||
|
||||
def _occurrences_by_schedule(
|
||||
session: Session,
|
||||
schedules: list[CampaignSchedule],
|
||||
) -> dict[str, list[CampaignScheduleOccurrence]]:
|
||||
ids = [item.id for item in schedules]
|
||||
if not ids:
|
||||
return {}
|
||||
rows = (
|
||||
session.query(CampaignScheduleOccurrence)
|
||||
.filter(CampaignScheduleOccurrence.schedule_id.in_(ids))
|
||||
.order_by(
|
||||
CampaignScheduleOccurrence.scheduled_for.desc(),
|
||||
CampaignScheduleOccurrence.id.asc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
grouped: dict[str, list[CampaignScheduleOccurrence]] = {}
|
||||
for row in rows:
|
||||
grouped.setdefault(row.schedule_id, []).append(row)
|
||||
return grouped
|
||||
|
||||
|
||||
def _schedule_response(
|
||||
schedule: CampaignSchedule,
|
||||
occurrences: list[CampaignScheduleOccurrence],
|
||||
) -> CampaignScheduleResponse:
|
||||
response = CampaignScheduleResponse.model_validate(schedule)
|
||||
return response.model_copy(
|
||||
update={
|
||||
"occurrences": [
|
||||
CampaignScheduleOccurrenceResponse.model_validate(item)
|
||||
for item in occurrences
|
||||
]
|
||||
}
|
||||
)
|
||||
@@ -371,6 +371,9 @@
|
||||
"warn"
|
||||
],
|
||||
"default": "ask"
|
||||
},
|
||||
"residual_files": {
|
||||
"$ref": "#/$defs/residual_file_disposition"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
@@ -1455,6 +1458,34 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"residual_file_disposition": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["none", "report", "attach"],
|
||||
"default": "none",
|
||||
"description": "Keep normal warning policy, prepare a reviewed report message, or prepare a reviewed message with the residual files attached."
|
||||
},
|
||||
"recipient": {
|
||||
"oneOf": [
|
||||
{ "$ref": "#/$defs/recipient" },
|
||||
{ "type": "null" }
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"subject": {
|
||||
"type": "string",
|
||||
"default": "Unassigned files in campaign {{local:campaign_name}}"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"default": "The campaign build found {{local:residual_file_count}} file(s) that were not assigned to a recipient.\n\n{{local:residual_file_list}}"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"default": { "mode": "none", "recipient": null }
|
||||
},
|
||||
"zip_config": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -52,6 +52,126 @@ class CampaignCopyRequest(CampaignLifecycleMutationRequest):
|
||||
source_version_id: str = Field(min_length=1, max_length=36)
|
||||
external_id: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
include_recipients: bool = True
|
||||
include_files: bool = True
|
||||
include_shares: bool = False
|
||||
include_policies: bool = True
|
||||
include_mail_profile: bool = True
|
||||
|
||||
|
||||
class CampaignScheduleCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
source_version_id: str = Field(min_length=1, max_length=36)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
recurrence_kind: Literal["once", "daily", "weekly", "monthly"] = "once"
|
||||
interval_count: int = Field(default=1, ge=1, le=365)
|
||||
timezone: str = Field(default="UTC", min_length=1, max_length=100)
|
||||
starts_at: datetime
|
||||
ends_at: datetime | None = None
|
||||
max_occurrences: int = Field(default=1, ge=1, le=1000)
|
||||
include_recipients: bool = True
|
||||
include_files: bool = True
|
||||
include_shares: bool = False
|
||||
include_policies: bool = True
|
||||
include_mail_profile: bool = True
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_schedule(self) -> "CampaignScheduleCreateRequest":
|
||||
if self.starts_at.tzinfo is None:
|
||||
raise ValueError("Campaign schedule start must include a timezone.")
|
||||
if self.ends_at is not None:
|
||||
if self.ends_at.tzinfo is None:
|
||||
raise ValueError("Campaign schedule end must include a timezone.")
|
||||
if self.ends_at <= self.starts_at:
|
||||
raise ValueError("Campaign schedule end must be after its start.")
|
||||
if self.recurrence_kind == "once":
|
||||
self.max_occurrences = 1
|
||||
self.interval_count = 1
|
||||
elif self.max_occurrences < 2:
|
||||
raise ValueError("A recurring campaign schedule needs at least two occurrences.")
|
||||
return self
|
||||
|
||||
|
||||
class CampaignScheduleStateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
active: bool
|
||||
base_revision: int = Field(ge=1)
|
||||
|
||||
|
||||
class CampaignScheduleOccurrenceResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
schedule_id: str
|
||||
scheduled_for: datetime
|
||||
status: str
|
||||
generated_campaign_id: str | None = None
|
||||
generated_version_id: str | None = None
|
||||
error: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class CampaignScheduleResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
campaign_id: str
|
||||
source_version_id: str
|
||||
name: str
|
||||
recurrence_kind: str
|
||||
interval_count: int
|
||||
timezone: str
|
||||
starts_at: datetime
|
||||
next_fire_at: datetime | None = None
|
||||
ends_at: datetime | None = None
|
||||
max_occurrences: int
|
||||
occurrence_count: int
|
||||
active: bool
|
||||
resource_revision: int
|
||||
last_fired_at: datetime | None = None
|
||||
last_campaign_id: str | None = None
|
||||
last_error: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
occurrences: list[CampaignScheduleOccurrenceResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CampaignScheduleListResponse(BaseModel):
|
||||
items: list[CampaignScheduleResponse]
|
||||
|
||||
|
||||
class CampaignContentLibrarySaveRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str = Field(min_length=1, max_length=300)
|
||||
description: str | None = Field(default=None, max_length=4000)
|
||||
kind: Literal["fragment", "campaign_part"]
|
||||
target: Literal["subject", "text", "html"] | None = None
|
||||
subject: str | None = Field(default=None, max_length=1000)
|
||||
text: str | None = Field(default=None, max_length=1_000_000)
|
||||
html: str | None = Field(default=None, max_length=2_000_000)
|
||||
body_mode: Literal["text", "html", "both"] = "both"
|
||||
locale: str = Field(default="de", min_length=2, max_length=35)
|
||||
visibility: Literal["personal", "tenant"] = "personal"
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_content(self) -> "CampaignContentLibrarySaveRequest":
|
||||
if self.kind == "fragment" and self.target is None:
|
||||
raise ValueError("A content fragment requires a target field.")
|
||||
values = {
|
||||
"subject": self.subject,
|
||||
"text": self.text,
|
||||
"html": self.html,
|
||||
}
|
||||
if self.kind == "fragment":
|
||||
selected = values[self.target or "text"]
|
||||
if not selected or not selected.strip():
|
||||
raise ValueError("The selected fragment field is empty.")
|
||||
elif not any(value and value.strip() for value in (self.text, self.html)):
|
||||
raise ValueError("A campaign part requires text or HTML body content.")
|
||||
return self
|
||||
|
||||
|
||||
class CampaignCreateMinimalRequest(BaseModel):
|
||||
|
||||
@@ -240,6 +240,7 @@ def test_process_loss_orphan_is_deleted_once_and_same_request_replays(
|
||||
def test_competing_node_cannot_acquire_cleanup_authority(
|
||||
recovery_session_factory,
|
||||
) -> None:
|
||||
lease_observed_at = datetime.now(timezone.utc)
|
||||
with recovery_session_factory() as session:
|
||||
claim = acquire_lease(
|
||||
session,
|
||||
@@ -248,7 +249,7 @@ def test_competing_node_cannot_acquire_cleanup_authority(
|
||||
holder_node_id="node-other",
|
||||
holder_incarnation="run-other",
|
||||
ttl_seconds=900,
|
||||
now=NOW,
|
||||
now=lease_observed_at,
|
||||
)
|
||||
assert claim is not None
|
||||
session.commit()
|
||||
|
||||
@@ -275,6 +275,92 @@ class CampaignAttachmentBuildTests(unittest.TestCase):
|
||||
self.assertEqual(archive.namelist(), ["matched.xlsx"])
|
||||
self.assertEqual(archive.read("matched.xlsx"), b"matched workbook")
|
||||
|
||||
def test_residual_files_become_a_separate_reviewed_report_or_attachment_message(self) -> None:
|
||||
for mode, expected_attachment_count in (("report", 0), ("attach", 1)):
|
||||
with self.subTest(mode=mode), tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
watched = root / "watched"
|
||||
watched.mkdir()
|
||||
(watched / "assigned.txt").write_text("assigned", encoding="utf-8")
|
||||
(watched / "residual.txt").write_text("residual", encoding="utf-8")
|
||||
campaign_file = root / "campaign.json"
|
||||
campaign_file.write_text("{}", encoding="utf-8")
|
||||
config = CampaignConfig.model_validate({
|
||||
"version": "1.0",
|
||||
"campaign": {"id": f"residual-{mode}", "name": "Monthly import", "mode": "test"},
|
||||
"fields": [],
|
||||
"global_values": {},
|
||||
"server": {
|
||||
"mail_profile_id": "profile-1",
|
||||
"profile_capabilities": {"smtp_available": True},
|
||||
},
|
||||
"recipients": {
|
||||
"from": {"email": "sender@example.org", "type": "to"},
|
||||
"allow_individual_to": True,
|
||||
},
|
||||
"template": {"subject": "Normal message", "text": "Normal body"},
|
||||
"attachments": {
|
||||
"base_paths": [{
|
||||
"id": "watched",
|
||||
"name": "Watched folder",
|
||||
"path": "watched",
|
||||
"unsent_warning": True,
|
||||
}],
|
||||
"global": [{
|
||||
"id": "assigned",
|
||||
"base_path_id": "watched",
|
||||
"base_dir": "watched",
|
||||
"file_filter": "assigned.txt",
|
||||
"required": True,
|
||||
}],
|
||||
"residual_files": {
|
||||
"mode": mode,
|
||||
"recipient": {"email": "operator@example.org", "name": "Operator"},
|
||||
"subject": "Residual files for {{local:campaign_name}}",
|
||||
"text": "{{local:residual_file_count}} file(s):\n{{local:residual_file_list}}",
|
||||
},
|
||||
},
|
||||
"entries": {"inline": [{
|
||||
"id": "recipient-1",
|
||||
"to": [{"email": "recipient@example.org", "type": "to"}],
|
||||
}]},
|
||||
"validation_policy": {
|
||||
"missing_email": "block",
|
||||
"template_error": "block",
|
||||
"unsent_attachment_files": "block",
|
||||
},
|
||||
"delivery": {"imap_append_sent": {"enabled": False}},
|
||||
})
|
||||
|
||||
result = build_campaign_messages(
|
||||
config,
|
||||
campaign_file=campaign_file,
|
||||
output_dir=root / "out",
|
||||
write_eml=True,
|
||||
)
|
||||
|
||||
self.assertEqual(len(result.report.messages), 2)
|
||||
normal, residual = result.report.messages
|
||||
self.assertEqual(normal.validation_status.value, "ready")
|
||||
self.assertEqual(residual.entry_id, "__residual_files__")
|
||||
self.assertEqual(residual.validation_status.value, "needs_review")
|
||||
self.assertEqual(residual.to[0].email, "operator@example.org")
|
||||
self.assertEqual(residual.subject, "Residual files for Monthly import")
|
||||
self.assertEqual(residual.attachment_count, expected_attachment_count)
|
||||
self.assertIn(
|
||||
"residual_attachment_disposition",
|
||||
{issue.code for issue in residual.issues},
|
||||
)
|
||||
self.assertNotIn(
|
||||
"unsent_attachment_files",
|
||||
{issue.code for message in result.report.messages for issue in message.issues},
|
||||
)
|
||||
mime = result.built_messages[1].mime
|
||||
self.assertIsNotNone(mime)
|
||||
self.assertIn("residual.txt", mime.get_body(preferencelist=("plain",)).get_content())
|
||||
filenames = [part.get_filename() for part in mime.iter_attachments()]
|
||||
self.assertEqual(filenames, ["residual.txt"] if mode == "attach" else [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from govoplan_campaign.backend.integrations import TemplatesCampaignIntegration
|
||||
from govoplan_campaign.backend.routes.campaigns import _campaign_content_library_item
|
||||
from govoplan_campaign.backend.schemas import CampaignContentLibrarySaveRequest
|
||||
from govoplan_core.core.templates import (
|
||||
TemplateContentDraftRequest,
|
||||
TemplateRef,
|
||||
TemplateRevisionRef,
|
||||
)
|
||||
|
||||
|
||||
class _Catalog:
|
||||
def __init__(self, template: TemplateRef) -> None:
|
||||
self.template = template
|
||||
self.calls: list[dict[str, object]] = []
|
||||
|
||||
def list_templates(self, session, principal, **kwargs):
|
||||
del session, principal
|
||||
self.calls.append(kwargs)
|
||||
return (self.template,)
|
||||
|
||||
def get_template(self, session, principal, **kwargs):
|
||||
del session, principal, kwargs
|
||||
return self.template
|
||||
|
||||
def check_compatibility(self, session, principal, **kwargs):
|
||||
del session, principal, kwargs
|
||||
raise AssertionError("Compatibility is not needed for raw reusable content.")
|
||||
|
||||
|
||||
class _ContentLibrary:
|
||||
def __init__(self, template: TemplateRef) -> None:
|
||||
self.template = template
|
||||
self.request: TemplateContentDraftRequest | None = None
|
||||
|
||||
def create_content_draft(self, session, principal, *, request):
|
||||
del session, principal
|
||||
self.request = request
|
||||
return self.template
|
||||
|
||||
|
||||
def _template_ref() -> TemplateRef:
|
||||
revision = TemplateRevisionRef(
|
||||
id="revision-1",
|
||||
template_id="template-1",
|
||||
revision=3,
|
||||
definition_hash="a" * 64,
|
||||
template_type="content_fragment",
|
||||
usages=("campaign.content",),
|
||||
locale="de",
|
||||
required_fields=(),
|
||||
output_profiles=(),
|
||||
content_text="Mit freundlichen Grussen",
|
||||
metadata={
|
||||
"campaign_kind": "fragment",
|
||||
"campaign_targets": ["subject"],
|
||||
},
|
||||
published_at=datetime.now(tz=UTC),
|
||||
)
|
||||
return TemplateRef(
|
||||
id="template-1",
|
||||
tenant_id="tenant-1",
|
||||
name="Closing",
|
||||
slug="closing",
|
||||
template_type="content_fragment",
|
||||
status="published",
|
||||
current_revision=3,
|
||||
current_revision_id=revision.id,
|
||||
published_revision_id=revision.id,
|
||||
revision=revision,
|
||||
)
|
||||
|
||||
|
||||
def test_campaign_content_integration_filters_usage_and_delegates_draft_creation() -> None:
|
||||
template = _template_ref()
|
||||
catalog = _Catalog(template)
|
||||
writer = _ContentLibrary(template)
|
||||
integration = TemplatesCampaignIntegration(catalog, None, writer)
|
||||
|
||||
listed = integration.list_content_templates(object(), object(), query="close")
|
||||
request = TemplateContentDraftRequest(
|
||||
name="Closing",
|
||||
template_type="content_fragment",
|
||||
usages=("campaign.content",),
|
||||
content_text="Regards",
|
||||
)
|
||||
created = integration.create_content_draft(
|
||||
object(),
|
||||
object(),
|
||||
request=request,
|
||||
)
|
||||
|
||||
assert listed == (template,)
|
||||
assert catalog.calls == [
|
||||
{"query": "close", "usage": "campaign.content", "limit": 100}
|
||||
]
|
||||
assert writer.request is request
|
||||
assert created is template
|
||||
|
||||
|
||||
def test_campaign_content_payload_keeps_revision_and_declared_target() -> None:
|
||||
payload = _campaign_content_library_item(_template_ref())
|
||||
|
||||
assert payload["kind"] == "fragment"
|
||||
assert payload["targets"] == ["subject"]
|
||||
assert payload["text"] == "Mit freundlichen Grussen"
|
||||
assert payload["published"] is True
|
||||
assert payload["revision"] == 3
|
||||
|
||||
|
||||
def test_content_save_request_rejects_empty_selected_fragment() -> None:
|
||||
try:
|
||||
CampaignContentLibrarySaveRequest(
|
||||
name="Empty",
|
||||
kind="fragment",
|
||||
target="html",
|
||||
text="Only text",
|
||||
)
|
||||
except ValueError as exc:
|
||||
assert "selected fragment field is empty" in str(exc)
|
||||
else:
|
||||
raise AssertionError("Expected empty target validation to fail")
|
||||
@@ -13,10 +13,12 @@ from govoplan_campaign.backend.campaign.lifecycle import campaign_lifecycle_poli
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignJob,
|
||||
CampaignSchedule,
|
||||
CampaignShare,
|
||||
CampaignVersion,
|
||||
)
|
||||
from govoplan_campaign.backend.routes.campaigns import (
|
||||
_campaign_copy_configuration,
|
||||
archive_campaign_version,
|
||||
copy_campaign,
|
||||
delete_draft_campaign,
|
||||
@@ -26,6 +28,7 @@ from govoplan_campaign.backend.schemas import (
|
||||
CampaignLifecycleMutationRequest,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
|
||||
|
||||
class _Principal:
|
||||
@@ -65,6 +68,8 @@ class CampaignLifecycleTests(unittest.TestCase):
|
||||
CampaignVersion.__table__,
|
||||
CampaignShare.__table__,
|
||||
CampaignJob.__table__,
|
||||
CampaignSchedule.__table__,
|
||||
ChangeSequenceEntry.__table__,
|
||||
],
|
||||
)
|
||||
self.SessionLocal = sessionmaker(
|
||||
@@ -73,7 +78,14 @@ class CampaignLifecycleTests(unittest.TestCase):
|
||||
expire_on_commit=False,
|
||||
)
|
||||
with self.SessionLocal() as session:
|
||||
session.execute(access_users.insert().values(id="user-1"))
|
||||
user_values = {"id": "user-1"}
|
||||
if "tenant_id" in access_users.c:
|
||||
user_values.update(
|
||||
tenant_id="tenant-1",
|
||||
account_id="account-1",
|
||||
email="user-1@example.test",
|
||||
)
|
||||
session.execute(access_users.insert().values(**user_values))
|
||||
campaign = Campaign(
|
||||
id="campaign-1",
|
||||
tenant_id="tenant-1",
|
||||
@@ -153,6 +165,50 @@ class CampaignLifecycleTests(unittest.TestCase):
|
||||
self.assertFalse(policy["actions"]["archive_campaign"]["allowed"])
|
||||
self.assertIn("Active or uncertain", policy["actions"]["archive_campaign"]["reason"])
|
||||
|
||||
def test_schedule_evidence_blocks_destructive_lifecycle_actions(self) -> None:
|
||||
with self.SessionLocal() as session:
|
||||
session.add(
|
||||
CampaignSchedule(
|
||||
id="schedule-1",
|
||||
tenant_id="tenant-1",
|
||||
campaign_id="campaign-1",
|
||||
source_version_id="version-2",
|
||||
created_by_user_id="user-1",
|
||||
name="Recurring draft",
|
||||
recurrence_kind="daily",
|
||||
interval_count=1,
|
||||
timezone="UTC",
|
||||
starts_at=datetime(2026, 8, 8, tzinfo=UTC),
|
||||
next_fire_at=datetime(2026, 8, 8, tzinfo=UTC),
|
||||
max_occurrences=2,
|
||||
copy_options={},
|
||||
source_snapshot={"schema": "test"},
|
||||
source_snapshot_hash="a" * 64,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
active_policy = self._policy(session)
|
||||
self.assertFalse(active_policy["actions"]["archive_campaign"]["allowed"])
|
||||
self.assertIn(
|
||||
"Pause active",
|
||||
active_policy["actions"]["archive_campaign"]["reason"],
|
||||
)
|
||||
self.assertFalse(active_policy["actions"]["delete_campaign"]["allowed"])
|
||||
self.assertIn(
|
||||
"schedule evidence",
|
||||
active_policy["actions"]["delete_campaign"]["reason"],
|
||||
)
|
||||
|
||||
schedule = session.get(CampaignSchedule, "schedule-1")
|
||||
assert schedule is not None
|
||||
schedule.active = False
|
||||
schedule.resource_revision += 1
|
||||
session.commit()
|
||||
paused_policy = self._policy(session)
|
||||
self.assertTrue(paused_policy["actions"]["archive_campaign"]["allowed"])
|
||||
self.assertFalse(paused_policy["actions"]["delete_campaign"]["allowed"])
|
||||
|
||||
def test_stale_delete_token_is_rejected(self) -> None:
|
||||
with self.SessionLocal() as session:
|
||||
policy = self._policy(session)
|
||||
@@ -203,6 +259,10 @@ class CampaignLifecycleTests(unittest.TestCase):
|
||||
|
||||
def test_whole_campaign_copy_starts_without_operational_evidence(self) -> None:
|
||||
with self.SessionLocal() as session:
|
||||
source_campaign = session.get(Campaign, "campaign-1")
|
||||
assert source_campaign is not None
|
||||
source_campaign.settings = {"retention": "source-policy"}
|
||||
source_campaign.mail_profile_policy = {"profile_id": "mail-profile-1"}
|
||||
session.add_all(
|
||||
(
|
||||
CampaignShare(
|
||||
@@ -269,6 +329,8 @@ class CampaignLifecycleTests(unittest.TestCase):
|
||||
CampaignCopyRequest(
|
||||
source_version_id="version-2",
|
||||
expected_state_token=policy["state_token"],
|
||||
include_policies=False,
|
||||
include_mail_profile=True,
|
||||
),
|
||||
session=session,
|
||||
principal=self.principal,
|
||||
@@ -277,6 +339,13 @@ class CampaignLifecycleTests(unittest.TestCase):
|
||||
self.assertEqual(response.campaign.external_id, "campaign-1-copy")
|
||||
self.assertEqual(response.campaign.owner_user_id, "user-1")
|
||||
self.assertEqual(captured["raw_json"]["campaign"]["mode"], "draft")
|
||||
copied_campaign = session.get(Campaign, "campaign-copy")
|
||||
assert copied_campaign is not None
|
||||
self.assertEqual({}, copied_campaign.settings)
|
||||
self.assertEqual(
|
||||
{"profile_id": "mail-profile-1"},
|
||||
copied_campaign.mail_profile_policy,
|
||||
)
|
||||
self.assertEqual(
|
||||
session.query(CampaignJob)
|
||||
.filter(CampaignJob.campaign_id == "campaign-copy")
|
||||
@@ -290,6 +359,64 @@ class CampaignLifecycleTests(unittest.TestCase):
|
||||
0,
|
||||
)
|
||||
|
||||
def test_copy_choices_reset_only_selected_configuration_domains(self) -> None:
|
||||
source = {
|
||||
"version": "1.0",
|
||||
"campaign": {"id": "campaign-1", "name": "Campaign"},
|
||||
"fields": [{"name": "case_id"}],
|
||||
"global_values": {"sender": "Office"},
|
||||
"recipients": {"to": [{"email": "team@example.test"}]},
|
||||
"entries": {
|
||||
"inline": [
|
||||
{
|
||||
"id": "one",
|
||||
"to": [{"email": "person@example.test"}],
|
||||
"attachments": [{"base_dir": ".", "file_filter": "one.pdf"}],
|
||||
}
|
||||
]
|
||||
},
|
||||
"template": {"subject": "Hello", "text": "Body"},
|
||||
"attachments": {
|
||||
"global": [{"base_dir": ".", "file_filter": "global.pdf"}]
|
||||
},
|
||||
"validation_policy": {"missing_required_attachment": "warn"},
|
||||
"server": {"mail_profile_id": "profile-1"},
|
||||
"delivery": {"rate_limit": {"messages_per_minute": 20}},
|
||||
}
|
||||
|
||||
copied = _campaign_copy_configuration(
|
||||
source,
|
||||
CampaignCopyRequest(
|
||||
source_version_id="version-2",
|
||||
expected_state_token="a" * 64,
|
||||
include_recipients=True,
|
||||
include_files=False,
|
||||
include_policies=False,
|
||||
include_mail_profile=False,
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(source["attachments"]["global"][0]["file_filter"], "global.pdf")
|
||||
self.assertEqual({}, copied["attachments"])
|
||||
self.assertEqual([], copied["entries"]["inline"][0]["attachments"])
|
||||
self.assertEqual("person@example.test", copied["entries"]["inline"][0]["to"][0]["email"])
|
||||
self.assertEqual({}, copied["validation_policy"])
|
||||
self.assertEqual({}, copied["server"])
|
||||
self.assertEqual(20, copied["delivery"]["rate_limit"]["messages_per_minute"])
|
||||
|
||||
def test_copy_without_recipient_permission_is_allowed_when_recipient_data_is_excluded(self) -> None:
|
||||
principal = _Principal("campaigns:campaign:read", "campaigns:campaign:copy")
|
||||
with self.SessionLocal() as session:
|
||||
campaign = session.get(Campaign, "campaign-1")
|
||||
assert campaign is not None
|
||||
policy = campaign_lifecycle_policy(
|
||||
session,
|
||||
campaign=campaign,
|
||||
principal=principal,
|
||||
version_id="version-2",
|
||||
)
|
||||
self.assertTrue(policy["actions"]["copy_campaign"]["allowed"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import Column, String, Table, create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from govoplan_campaign.backend.campaign.scheduling import (
|
||||
campaign_schedule_source_snapshot,
|
||||
canonical_configuration_hash,
|
||||
dispatch_due_campaign_schedules,
|
||||
next_schedule_fire,
|
||||
)
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignSchedule,
|
||||
CampaignScheduleOccurrence,
|
||||
CampaignShare,
|
||||
CampaignVersion,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
|
||||
|
||||
def _access_table(name: str) -> Table:
|
||||
existing = Base.metadata.tables.get(name)
|
||||
if existing is not None:
|
||||
return existing
|
||||
return Table(
|
||||
name,
|
||||
Base.metadata,
|
||||
Column("id", String(36), primary_key=True),
|
||||
)
|
||||
|
||||
|
||||
def _create_generated_campaign(session: Session, **kwargs):
|
||||
raw = kwargs["raw_json"]
|
||||
metadata = raw["campaign"]
|
||||
campaign = Campaign(
|
||||
tenant_id=kwargs["tenant_id"],
|
||||
created_by_user_id=kwargs["user_id"],
|
||||
owner_user_id=kwargs["user_id"],
|
||||
external_id=metadata["id"],
|
||||
name=metadata["name"],
|
||||
status="draft",
|
||||
)
|
||||
session.add(campaign)
|
||||
session.flush()
|
||||
version = CampaignVersion(
|
||||
campaign_id=campaign.id,
|
||||
version_number=1,
|
||||
raw_json=raw,
|
||||
)
|
||||
session.add(version)
|
||||
session.flush()
|
||||
campaign.current_version_id = version.id
|
||||
return campaign, version
|
||||
|
||||
|
||||
class TestCampaignScheduling:
|
||||
def setup_method(self):
|
||||
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
users = _access_table("access_users")
|
||||
groups = _access_table("access_groups")
|
||||
Base.metadata.create_all(
|
||||
self.engine,
|
||||
tables=[
|
||||
users,
|
||||
groups,
|
||||
Campaign.__table__,
|
||||
CampaignVersion.__table__,
|
||||
CampaignShare.__table__,
|
||||
CampaignSchedule.__table__,
|
||||
CampaignScheduleOccurrence.__table__,
|
||||
ChangeSequenceEntry.__table__,
|
||||
],
|
||||
)
|
||||
self.SessionLocal = sessionmaker(
|
||||
bind=self.engine,
|
||||
class_=Session,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
configuration = {
|
||||
"version": "1.0",
|
||||
"campaign": {"id": "source", "name": "Monthly notice"},
|
||||
}
|
||||
snapshot = campaign_schedule_source_snapshot(
|
||||
configuration=configuration,
|
||||
campaign_settings={"retention": "sealed"},
|
||||
mail_profile_policy={"profile_id": "profile-1"},
|
||||
shares=[],
|
||||
)
|
||||
with self.SessionLocal() as session:
|
||||
user_values = {"id": "user-1"}
|
||||
if "tenant_id" in users.c:
|
||||
user_values.update(
|
||||
tenant_id="tenant-1",
|
||||
account_id="account-1",
|
||||
email="user-1@example.test",
|
||||
)
|
||||
session.execute(users.insert().values(**user_values))
|
||||
campaign = Campaign(
|
||||
id="campaign-1",
|
||||
tenant_id="tenant-1",
|
||||
created_by_user_id="user-1",
|
||||
owner_user_id="user-1",
|
||||
external_id="source",
|
||||
name="Monthly notice",
|
||||
status="sent",
|
||||
current_version_id="version-1",
|
||||
)
|
||||
version = CampaignVersion(
|
||||
id="version-1",
|
||||
campaign_id=campaign.id,
|
||||
version_number=1,
|
||||
workflow_state="completed",
|
||||
raw_json=configuration,
|
||||
)
|
||||
schedule = CampaignSchedule(
|
||||
id="schedule-1",
|
||||
tenant_id="tenant-1",
|
||||
campaign_id=campaign.id,
|
||||
source_version_id=version.id,
|
||||
created_by_user_id="user-1",
|
||||
name="Monthly notice",
|
||||
recurrence_kind="daily",
|
||||
interval_count=1,
|
||||
timezone="Europe/Berlin",
|
||||
starts_at=datetime(2026, 8, 7, 8, tzinfo=UTC),
|
||||
next_fire_at=datetime(2026, 8, 7, 8, tzinfo=UTC),
|
||||
max_occurrences=2,
|
||||
copy_options={
|
||||
"include_recipients": True,
|
||||
"include_files": True,
|
||||
"include_shares": False,
|
||||
"include_policies": True,
|
||||
"include_mail_profile": True,
|
||||
},
|
||||
source_snapshot=snapshot,
|
||||
source_snapshot_hash=canonical_configuration_hash(snapshot),
|
||||
)
|
||||
session.add_all((campaign, version, schedule))
|
||||
session.commit()
|
||||
|
||||
def teardown_method(self):
|
||||
self.engine.dispose()
|
||||
|
||||
def test_due_occurrences_prepare_distinct_drafts_and_complete_bound(self):
|
||||
with self.SessionLocal() as session, patch(
|
||||
"govoplan_campaign.backend.campaign.scheduling.create_campaign_version_from_json",
|
||||
side_effect=lambda *args, **kwargs: _create_generated_campaign(session, **kwargs),
|
||||
), patch("govoplan_campaign.backend.campaign.scheduling.audit_event"):
|
||||
first = dispatch_due_campaign_schedules(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
now=datetime(2026, 8, 7, 8, tzinfo=UTC),
|
||||
)
|
||||
session.commit()
|
||||
assert first["prepared"] == 1
|
||||
schedule = session.get(CampaignSchedule, "schedule-1")
|
||||
assert schedule is not None
|
||||
assert schedule.active is True
|
||||
assert schedule.occurrence_count == 1
|
||||
assert schedule.next_fire_at is not None
|
||||
|
||||
second = dispatch_due_campaign_schedules(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
now=datetime(2026, 8, 8, 8, tzinfo=UTC),
|
||||
)
|
||||
session.commit()
|
||||
assert second["prepared"] == 1
|
||||
assert schedule.active is False
|
||||
assert schedule.next_fire_at is None
|
||||
occurrences = session.query(CampaignScheduleOccurrence).all()
|
||||
assert len(occurrences) == 2
|
||||
assert len({item.generated_campaign_id for item in occurrences}) == 2
|
||||
assert session.get(Campaign, "campaign-1").status == "sent"
|
||||
|
||||
def test_snapshot_integrity_failure_pauses_schedule_for_operator(self):
|
||||
with self.SessionLocal() as session:
|
||||
schedule = session.get(CampaignSchedule, "schedule-1")
|
||||
assert schedule is not None
|
||||
schedule.source_snapshot["configuration"]["campaign"]["name"] = "Tampered"
|
||||
flag_modified(schedule, "source_snapshot")
|
||||
session.commit()
|
||||
with patch("govoplan_campaign.backend.campaign.scheduling.audit_event"):
|
||||
result = dispatch_due_campaign_schedules(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
now=datetime(2026, 8, 7, 8, tzinfo=UTC),
|
||||
)
|
||||
session.commit()
|
||||
assert result["failed"] == 1
|
||||
assert schedule.active is False
|
||||
assert "integrity" in (schedule.last_error or "")
|
||||
occurrence = session.query(CampaignScheduleOccurrence).one()
|
||||
assert occurrence.status == "failed"
|
||||
|
||||
def test_monthly_recurrence_clamps_end_of_month(self):
|
||||
result = next_schedule_fire(
|
||||
datetime(2026, 1, 31, 9, tzinfo=UTC),
|
||||
recurrence_kind="monthly",
|
||||
interval_count=1,
|
||||
timezone_name="UTC",
|
||||
)
|
||||
assert result == datetime(2026, 2, 28, 9, tzinfo=UTC)
|
||||
|
||||
def test_occurrence_uses_sealed_policy_state_and_advances_revision(self):
|
||||
with self.SessionLocal() as session:
|
||||
source = session.get(Campaign, "campaign-1")
|
||||
assert source is not None
|
||||
source.settings = {"retention": "changed-after-scheduling"}
|
||||
source.mail_profile_policy = {"profile_id": "profile-2"}
|
||||
session.commit()
|
||||
|
||||
with patch(
|
||||
"govoplan_campaign.backend.campaign.scheduling.create_campaign_version_from_json",
|
||||
side_effect=lambda *args, **kwargs: _create_generated_campaign(
|
||||
session,
|
||||
**kwargs,
|
||||
),
|
||||
), patch("govoplan_campaign.backend.campaign.scheduling.audit_event"):
|
||||
result = dispatch_due_campaign_schedules(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
now=datetime(2026, 8, 7, 8, tzinfo=UTC),
|
||||
)
|
||||
session.commit()
|
||||
|
||||
assert result["prepared"] == 1
|
||||
schedule = session.get(CampaignSchedule, "schedule-1")
|
||||
assert schedule is not None
|
||||
generated = session.get(Campaign, schedule.last_campaign_id)
|
||||
assert generated is not None
|
||||
assert generated.settings == {"retention": "sealed"}
|
||||
assert generated.mail_profile_policy == {"profile_id": "profile-1"}
|
||||
assert schedule.resource_revision == 2
|
||||
@@ -416,7 +416,10 @@ def test_static_campaign_handbook_has_unique_ids_help_contexts_and_no_planned_re
|
||||
"campaign.settings",
|
||||
"campaign.fields",
|
||||
"campaign.template",
|
||||
"campaign.template.content-library",
|
||||
"campaigns.action.schedule-drafts",
|
||||
"campaign.attachments",
|
||||
"campaign.attachments.residual-files",
|
||||
"campaign.recipients",
|
||||
"campaign.recipient-data",
|
||||
"campaign.server-settings",
|
||||
|
||||
@@ -19,7 +19,9 @@ def test_operator_queue_is_an_integrated_campaign_view() -> None:
|
||||
assert "/operator" not in {item.path for item in manifest.frontend.nav_items}
|
||||
|
||||
routes = {route.path: route for route in manifest.frontend.routes}
|
||||
assert "/operator" not in routes
|
||||
legacy_redirect = routes["/operator"]
|
||||
assert legacy_redirect.component == "OperatorQueueRedirect"
|
||||
assert legacy_redirect.surface_id == "campaigns.route.operator-redirect"
|
||||
queue = routes["/campaigns/queue"]
|
||||
assert queue.component == "OperatorQueuePage"
|
||||
assert queue.required_all == ("campaigns:campaign:read",)
|
||||
|
||||
@@ -9,6 +9,7 @@ from govoplan_campaign.backend.routes.delivery import router as delivery_router
|
||||
from govoplan_campaign.backend.routes.jobs import router as jobs_router
|
||||
from govoplan_campaign.backend.routes.operations import router as operations_router
|
||||
from govoplan_campaign.backend.routes.reports import router as reports_router
|
||||
from govoplan_campaign.backend.routes.schedules import router as schedules_router
|
||||
from govoplan_campaign.backend.routes.sharing import router as sharing_router
|
||||
from govoplan_campaign.backend.routes.versions import router as versions_router
|
||||
|
||||
@@ -28,6 +29,7 @@ def test_campaign_router_composes_every_workflow_operation_once() -> None:
|
||||
versions_router,
|
||||
jobs_router,
|
||||
reports_router,
|
||||
schedules_router,
|
||||
sharing_router,
|
||||
delivery_router,
|
||||
attachments_router,
|
||||
@@ -40,7 +42,7 @@ def test_campaign_router_composes_every_workflow_operation_once() -> None:
|
||||
actual = _operation_keys(router)
|
||||
|
||||
assert actual == expected
|
||||
assert len(actual) == 72
|
||||
assert len(actual) == 80
|
||||
assert not [operation for operation, count in Counter(actual).items() if count > 1]
|
||||
|
||||
|
||||
@@ -54,6 +56,7 @@ def test_key_routes_are_owned_by_their_focused_router() -> None:
|
||||
(versions_router, ("POST", "/campaigns/versions/{version_id}/build")),
|
||||
(jobs_router, ("GET", "/campaigns/{campaign_id}/jobs")),
|
||||
(reports_router, ("GET", "/campaigns/{campaign_id}/report")),
|
||||
(schedules_router, ("GET", "/campaigns/{campaign_id}/schedules")),
|
||||
(sharing_router, ("POST", "/campaigns/{campaign_id}/shares")),
|
||||
(delivery_router, ("POST", "/campaigns/{campaign_id}/send-now")),
|
||||
(
|
||||
|
||||
@@ -20,6 +20,7 @@ from govoplan_core.core.search import (
|
||||
SearchBackfillRequest,
|
||||
SearchResourceReference,
|
||||
)
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
@@ -34,6 +35,7 @@ class CampaignSearchSourceTests(unittest.TestCase):
|
||||
Group.__table__,
|
||||
Campaign.__table__,
|
||||
CampaignShare.__table__,
|
||||
ChangeSequenceEntry.__table__,
|
||||
),
|
||||
)
|
||||
self.session = Session(self.engine)
|
||||
|
||||
+162
-2
@@ -102,6 +102,66 @@ export type CampaignLifecyclePolicy = {
|
||||
provenance: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CampaignCopyOptions = {
|
||||
name?: string;
|
||||
external_id?: string;
|
||||
include_recipients: boolean;
|
||||
include_files: boolean;
|
||||
include_shares: boolean;
|
||||
include_policies: boolean;
|
||||
include_mail_profile: boolean;
|
||||
};
|
||||
|
||||
export type CampaignScheduleOccurrence = {
|
||||
id: string;
|
||||
schedule_id: string;
|
||||
scheduled_for: string;
|
||||
status: "prepared" | "failed" | string;
|
||||
generated_campaign_id?: string | null;
|
||||
generated_version_id?: string | null;
|
||||
error?: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type CampaignSchedule = {
|
||||
id: string;
|
||||
campaign_id: string;
|
||||
source_version_id: string;
|
||||
name: string;
|
||||
recurrence_kind: "once" | "daily" | "weekly" | "monthly";
|
||||
interval_count: number;
|
||||
timezone: string;
|
||||
starts_at: string;
|
||||
next_fire_at?: string | null;
|
||||
ends_at?: string | null;
|
||||
max_occurrences: number;
|
||||
occurrence_count: number;
|
||||
active: boolean;
|
||||
resource_revision: number;
|
||||
last_fired_at?: string | null;
|
||||
last_campaign_id?: string | null;
|
||||
last_error?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
occurrences: CampaignScheduleOccurrence[];
|
||||
};
|
||||
|
||||
export type CampaignScheduleCreate = {
|
||||
source_version_id: string;
|
||||
name: string;
|
||||
recurrence_kind: CampaignSchedule["recurrence_kind"];
|
||||
interval_count: number;
|
||||
timezone: string;
|
||||
starts_at: string;
|
||||
ends_at?: string | null;
|
||||
max_occurrences: number;
|
||||
include_recipients: boolean;
|
||||
include_files: boolean;
|
||||
include_shares: boolean;
|
||||
include_policies: boolean;
|
||||
include_mail_profile: boolean;
|
||||
};
|
||||
|
||||
export type CampaignVersionDetail = CampaignVersionListItem & {
|
||||
raw_json: Record<string, unknown>;
|
||||
campaign_json?: Record<string, unknown>;
|
||||
@@ -431,6 +491,50 @@ export type CampaignPrintTemplatesResponse = {
|
||||
templates: CampaignPrintTemplate[];
|
||||
};
|
||||
|
||||
export type CampaignContentLibraryTarget = "subject" | "text" | "html";
|
||||
|
||||
export type CampaignContentLibraryItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
template_type: string;
|
||||
kind: "fragment" | "campaign_part";
|
||||
status: string;
|
||||
scope_type: "tenant" | "group" | "user";
|
||||
scope_id?: string | null;
|
||||
read_only: boolean;
|
||||
current_revision: number;
|
||||
revision: number;
|
||||
revision_id: string;
|
||||
locale?: string | null;
|
||||
published: boolean;
|
||||
targets: CampaignContentLibraryTarget[];
|
||||
subject?: string | null;
|
||||
text?: string | null;
|
||||
html?: string | null;
|
||||
body_mode: "text" | "html" | "both";
|
||||
};
|
||||
|
||||
export type CampaignContentLibraryResponse = {
|
||||
available: boolean;
|
||||
writable: boolean;
|
||||
reason?: string | null;
|
||||
items: CampaignContentLibraryItem[];
|
||||
};
|
||||
|
||||
export type CampaignContentLibrarySaveInput = {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
kind: "fragment" | "campaign_part";
|
||||
target?: CampaignContentLibraryTarget | null;
|
||||
subject?: string | null;
|
||||
text?: string | null;
|
||||
html?: string | null;
|
||||
body_mode: "text" | "html" | "both";
|
||||
locale: string;
|
||||
visibility: "personal" | "tenant";
|
||||
};
|
||||
|
||||
export type CampaignRecipientSnapshotItem = {
|
||||
contact_id: string;
|
||||
display_name: string;
|
||||
@@ -991,6 +1095,26 @@ query = "")
|
||||
return apiFetch<CampaignPrintTemplatesResponse>(settings, `/api/v1/campaigns/${campaignId}/print-templates${suffix}`);
|
||||
}
|
||||
|
||||
export async function listCampaignContentLibrary(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
query = "")
|
||||
: Promise<CampaignContentLibraryResponse> {
|
||||
const suffix = query.trim() ? `?query=${encodeURIComponent(query.trim())}` : "";
|
||||
return apiFetch<CampaignContentLibraryResponse>(settings, `/api/v1/campaigns/${campaignId}/content-library${suffix}`);
|
||||
}
|
||||
|
||||
export async function saveCampaignContentLibraryItem(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
payload: CampaignContentLibrarySaveInput)
|
||||
: Promise<{template: CampaignContentLibraryItem;}> {
|
||||
return apiFetch<{template: CampaignContentLibraryItem;}>(settings, `/api/v1/campaigns/${campaignId}/content-library`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export async function snapshotCampaignRecipientAddressSource(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
@@ -1052,17 +1176,53 @@ export async function copyCampaign(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
sourceVersionId: string,
|
||||
expectedStateToken: string)
|
||||
expectedStateToken: string,
|
||||
options: CampaignCopyOptions)
|
||||
: Promise<CampaignCreateResponse> {
|
||||
return apiFetch<CampaignCreateResponse>(settings, `/api/v1/campaigns/${campaignId}/copies`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
source_version_id: sourceVersionId,
|
||||
expected_state_token: expectedStateToken
|
||||
expected_state_token: expectedStateToken,
|
||||
...options,
|
||||
name: options.name?.trim() || undefined,
|
||||
external_id: options.external_id?.trim() || undefined
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export async function listCampaignSchedules(
|
||||
settings: ApiSettings,
|
||||
campaignId: string)
|
||||
: Promise<CampaignSchedule[]> {
|
||||
const response = await apiFetch<{items: CampaignSchedule[]}>(settings, `/api/v1/campaigns/${campaignId}/schedules`);
|
||||
return response.items;
|
||||
}
|
||||
|
||||
export async function createCampaignSchedule(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
payload: CampaignScheduleCreate)
|
||||
: Promise<CampaignSchedule> {
|
||||
return apiFetch<CampaignSchedule>(settings, `/api/v1/campaigns/${campaignId}/schedules`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export async function setCampaignScheduleState(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
scheduleId: string,
|
||||
active: boolean,
|
||||
baseRevision: number)
|
||||
: Promise<CampaignSchedule> {
|
||||
return apiFetch<CampaignSchedule>(settings, `/api/v1/campaigns/${campaignId}/schedules/${scheduleId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ active, base_revision: baseRevision })
|
||||
});
|
||||
}
|
||||
|
||||
export async function archiveCampaignVersion(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Card } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import { MetricCard } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||
@@ -56,6 +57,9 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
|
||||
const basePaths = useMemo(() => normalizeAttachmentBasePaths(attachments.base_paths, attachments), [attachments]);
|
||||
const globalRules = useMemo(() => normalizeAttachmentRules(attachments.global), [attachments.global]);
|
||||
const zipConfig = useMemo(() => normalizeAttachmentZipCollection(attachments.zip), [attachments.zip]);
|
||||
const residualFiles = asRecord(attachments.residual_files);
|
||||
const residualMode = ["report", "attach"].includes(String(residualFiles.mode)) ? String(residualFiles.mode) : "none";
|
||||
const residualRecipient = asRecord(residualFiles.recipient);
|
||||
const filenameFieldOptions = useMemo(() => buildZipFilenameFieldOptions(displayDraft), [displayDraft]);
|
||||
const passwordFields = useMemo(() => getDraftFields(displayDraft).filter((field) => field.type === "password"), [displayDraft]);
|
||||
const zipArchiveNameValidation = useMemo(
|
||||
@@ -96,6 +100,18 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function patchResidualFiles(next: Record<string, unknown>) {
|
||||
if (locked) return;
|
||||
patch(["attachments", "residual_files"], {
|
||||
mode: "none",
|
||||
recipient: null,
|
||||
subject: "Unassigned files in campaign {{local:campaign_name}}",
|
||||
text: "The campaign build found {{local:residual_file_count}} file(s) that were not assigned to a recipient.\n\n{{local:residual_file_list}}",
|
||||
...residualFiles,
|
||||
...next
|
||||
});
|
||||
}
|
||||
|
||||
function patchBasePath(index: number, patch: Partial<AttachmentBasePath>) {
|
||||
patchBasePaths(basePaths.map((basePath, currentIndex) => currentIndex === index ? { ...basePath, ...patch } : basePath));
|
||||
}
|
||||
@@ -271,6 +287,48 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Unassigned file disposition" collapsible>
|
||||
<div className="campaign-residual-file-form">
|
||||
<FormField label="Action" help="Only sources with Unsent enabled are inspected. The existing warning policy remains active when no disposition is selected.">
|
||||
<select value={residualMode} disabled={locked} onChange={(event) => {
|
||||
const mode = event.target.value;
|
||||
patchResidualFiles({
|
||||
mode,
|
||||
recipient: mode === "none" ? null : {
|
||||
email: String(residualRecipient.email ?? ""),
|
||||
name: String(residualRecipient.name ?? "") || null
|
||||
}
|
||||
});
|
||||
}}>
|
||||
<option value="none">Apply warning policy only</option>
|
||||
<option value="report">Prepare report message</option>
|
||||
<option value="attach">Prepare report with files attached</option>
|
||||
</select>
|
||||
</FormField>
|
||||
{residualMode !== "none" && <>
|
||||
<FormField label="Recipient email">
|
||||
<input type="email" value={String(residualRecipient.email ?? "")} disabled={locked} onChange={(event) => patchResidualFiles({ recipient: { ...residualRecipient, email: event.target.value } })} />
|
||||
</FormField>
|
||||
<FormField label="Recipient name">
|
||||
<input value={String(residualRecipient.name ?? "")} disabled={locked} onChange={(event) => patchResidualFiles({ recipient: { ...residualRecipient, name: event.target.value || null } })} />
|
||||
</FormField>
|
||||
<div className="campaign-residual-file-wide">
|
||||
<FormField label="Subject">
|
||||
<input value={String(residualFiles.subject ?? "Unassigned files in campaign {{local:campaign_name}}") } disabled={locked} onChange={(event) => patchResidualFiles({ subject: event.target.value })} />
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="campaign-residual-file-wide">
|
||||
<FormField label="Report text" help="Available values: {{local:campaign_name}}, {{local:residual_file_count}}, and {{local:residual_file_list}}.">
|
||||
<textarea rows={5} value={String(residualFiles.text ?? "")} disabled={locked} onChange={(event) => patchResidualFiles({ text: event.target.value })} />
|
||||
</FormField>
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
{residualMode !== "none" && <DismissibleAlert tone="info" dismissible={false} compact>
|
||||
A residual-file message becomes a separate campaign row that always needs review. It uses the normal build, approval, queue, delivery, report, and audit lifecycle; saving this setting never sends it.
|
||||
</DismissibleAlert>}
|
||||
</Card>
|
||||
|
||||
<Card title="i18n:govoplan-campaign.zip_attachments.6b58ed68" collapsible>
|
||||
<div className="attachment-zip-master-toggle">
|
||||
<ToggleSwitch
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Archive, Copy, ExternalLink, LockKeyhole, LockOpen, Trash2 } from "lucide-react";
|
||||
import { Archive, CalendarClock, Copy, ExternalLink, LockKeyhole, LockOpen, Pause, Play, Trash2 } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import type { ApiSettings, AuthInfo } from "../../types";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { Dialog } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import { MetricCard } from "@govoplan/core-webui";
|
||||
@@ -17,13 +18,19 @@ import {
|
||||
archiveCampaign,
|
||||
archiveCampaignVersion,
|
||||
copyCampaign,
|
||||
createCampaignSchedule,
|
||||
deleteCampaign,
|
||||
getCampaignLifecyclePolicy,
|
||||
lockCampaignVersionPermanently,
|
||||
lockCampaignVersionTemporarily,
|
||||
unlockCampaignVersionUserLock,
|
||||
listCampaignSchedules,
|
||||
setCampaignScheduleState,
|
||||
updateCampaignMetadata,
|
||||
type CampaignSchedule,
|
||||
type CampaignScheduleCreate,
|
||||
type CampaignLifecyclePolicy,
|
||||
type CampaignCopyOptions,
|
||||
type CampaignVersionDetail,
|
||||
type CampaignVersionListItem } from
|
||||
"../../api/campaigns";
|
||||
@@ -51,6 +58,34 @@ type PendingLifecycleAction = {
|
||||
policy: CampaignLifecyclePolicy;
|
||||
version?: CampaignVersionListItem;
|
||||
} | null;
|
||||
const defaultCopyOptions: CampaignCopyOptions = {
|
||||
name: "",
|
||||
external_id: "",
|
||||
include_recipients: true,
|
||||
include_files: true,
|
||||
include_shares: false,
|
||||
include_policies: true,
|
||||
include_mail_profile: true
|
||||
};
|
||||
|
||||
function defaultScheduleDraft(): CampaignScheduleCreate {
|
||||
const start = new Date(Date.now() + 60 * 60 * 1000);
|
||||
start.setSeconds(0, 0);
|
||||
return {
|
||||
source_version_id: "",
|
||||
name: "",
|
||||
recurrence_kind: "once",
|
||||
interval_count: 1,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
||||
starts_at: localDateTimeValue(start),
|
||||
max_occurrences: 1,
|
||||
include_recipients: true,
|
||||
include_files: true,
|
||||
include_shares: false,
|
||||
include_policies: true,
|
||||
include_mail_profile: true
|
||||
};
|
||||
}
|
||||
|
||||
export default function CampaignOverviewPage({ settings, auth, campaignId }: {settings: ApiSettings;auth: AuthInfo;campaignId: string;}) {
|
||||
const navigate = useGuardedNavigate();
|
||||
@@ -65,12 +100,18 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
||||
const [pendingLockAction, setPendingLockAction] = useState<PendingLockAction>(null);
|
||||
const [lockBusy, setLockBusy] = useState(false);
|
||||
const [pendingLifecycleAction, setPendingLifecycleAction] = useState<PendingLifecycleAction>(null);
|
||||
const [copyOptions, setCopyOptions] = useState<CampaignCopyOptions>(defaultCopyOptions);
|
||||
const [lifecycleBusy, setLifecycleBusy] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [schedules, setSchedules] = useState<CampaignSchedule[]>([]);
|
||||
const [scheduleDialogOpen, setScheduleDialogOpen] = useState(false);
|
||||
const [scheduleDraft, setScheduleDraft] = useState<CampaignScheduleCreate>(defaultScheduleDraft);
|
||||
const [scheduleBusy, setScheduleBusy] = useState(false);
|
||||
const versionMetrics = useMemo(() => campaignVersionMetrics(data.currentVersion), [data.currentVersion]);
|
||||
const canArchive = Boolean(campaign) && campaign?.status !== "archived" && hasScope(auth, "campaigns:campaign:archive");
|
||||
const canDelete = Boolean(campaign) && campaign?.status === "draft" && hasScope(auth, "campaigns:campaign:delete");
|
||||
const canCopy = Boolean(data.currentVersion) && hasScope(auth, "campaigns:campaign:copy") && hasScope(auth, "campaigns:recipient:read");
|
||||
const canCopy = Boolean(data.currentVersion) && hasScope(auth, "campaigns:campaign:copy");
|
||||
const canSchedule = Boolean(data.currentVersion) && hasScope(auth, "campaigns:campaign:schedule") && hasScope(auth, "campaigns:campaign:copy");
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty: identityDirty,
|
||||
@@ -97,6 +138,20 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
||||
});
|
||||
}, [campaign, identityDirty]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!campaign?.id) {
|
||||
setSchedules([]);
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
void listCampaignSchedules(settings, campaign.id).then((items) => {
|
||||
if (active) setSchedules(items);
|
||||
}).catch((err) => {
|
||||
if (active) setError(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [campaign?.id, settings, setError]);
|
||||
|
||||
function patchIdentity(key: keyof typeof identity, value: string) {
|
||||
setIdentity((current) => ({ ...current, [key]: value }));
|
||||
setIdentityDirty(true);
|
||||
@@ -177,6 +232,13 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
||||
setError(decision?.reason || "This lifecycle action is not available for the current campaign state.");
|
||||
return;
|
||||
}
|
||||
if (action === "copy_campaign") {
|
||||
setCopyOptions({
|
||||
...defaultCopyOptions,
|
||||
name: `${campaign.name} (copy)`,
|
||||
include_recipients: hasScope(auth, "campaigns:recipient:read")
|
||||
});
|
||||
}
|
||||
setPendingLifecycleAction({ action, policy, version });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
@@ -201,7 +263,7 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
||||
navigate("/campaigns");
|
||||
return;
|
||||
} else if (pending.action === "copy_campaign" && pending.version) {
|
||||
const created = await copyCampaign(settings, campaign.id, pending.version.id, pending.policy.state_token);
|
||||
const created = await copyCampaign(settings, campaign.id, pending.version.id, pending.policy.state_token, copyOptions);
|
||||
setPendingLifecycleAction(null);
|
||||
navigate(`/campaigns/${created.campaign.id}`);
|
||||
return;
|
||||
@@ -218,6 +280,57 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
||||
}
|
||||
}
|
||||
|
||||
function openScheduleDialog() {
|
||||
if (!campaign || !data.currentVersion) return;
|
||||
setScheduleDraft({
|
||||
...defaultScheduleDraft(),
|
||||
source_version_id: data.currentVersion.id,
|
||||
name: campaign.name,
|
||||
include_recipients: hasScope(auth, "campaigns:recipient:read")
|
||||
});
|
||||
setScheduleDialogOpen(true);
|
||||
}
|
||||
|
||||
async function submitSchedule() {
|
||||
if (!campaign || scheduleBusy) return;
|
||||
setScheduleBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const created = await createCampaignSchedule(settings, campaign.id, {
|
||||
...scheduleDraft,
|
||||
starts_at: new Date(scheduleDraft.starts_at).toISOString(),
|
||||
max_occurrences: scheduleDraft.recurrence_kind === "once" ? 1 : scheduleDraft.max_occurrences
|
||||
});
|
||||
setSchedules((current) => [created, ...current]);
|
||||
setScheduleDialogOpen(false);
|
||||
setMessage("Campaign schedule created. Each occurrence prepares a fresh draft for review; it does not send automatically.");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setScheduleBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleSchedule(schedule: CampaignSchedule) {
|
||||
if (!campaign || scheduleBusy) return;
|
||||
setScheduleBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const updated = await setCampaignScheduleState(
|
||||
settings,
|
||||
campaign.id,
|
||||
schedule.id,
|
||||
!schedule.active,
|
||||
schedule.resource_revision
|
||||
);
|
||||
setSchedules((current) => current.map((item) => item.id === updated.id ? updated : item));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setScheduleBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
@@ -233,6 +346,13 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
||||
<Copy size={16} aria-hidden="true" />
|
||||
Copy campaign
|
||||
</Button>}
|
||||
{canSchedule && <Button
|
||||
onClick={openScheduleDialog}
|
||||
disabled={loading || savingIdentity || lockBusy || lifecycleBusy || identityDirty}
|
||||
disabledReason={identityDirty ? "Save or discard overview changes before scheduling." : undefined}>
|
||||
<CalendarClock size={16} aria-hidden="true" />
|
||||
Schedule
|
||||
</Button>}
|
||||
{canDelete && <Button
|
||||
variant="danger"
|
||||
onClick={() => void prepareLifecycleAction("delete_campaign")}
|
||||
@@ -287,6 +407,37 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{(canSchedule || schedules.length > 0) && <Card
|
||||
title="Schedules"
|
||||
collapsible
|
||||
actions={canSchedule ? <Button onClick={openScheduleDialog}>
|
||||
<CalendarClock size={16} aria-hidden="true" />
|
||||
Add schedule
|
||||
</Button> : undefined}>
|
||||
<p className="muted small-note">Due occurrences prepare independent campaign drafts. Validation, review, approval, and delivery are never started by the schedule.</p>
|
||||
{schedules.length === 0 ? <p className="muted">No schedules configured.</p> : <div className="campaign-schedule-list">
|
||||
{schedules.map((schedule) => <div className="campaign-schedule-row" key={schedule.id}>
|
||||
<div className="campaign-schedule-main">
|
||||
<strong>{schedule.name}</strong>
|
||||
<span>{scheduleCadence(schedule)} · {schedule.occurrence_count}/{schedule.max_occurrences} prepared</span>
|
||||
{schedule.next_fire_at && <span>Next: {formatDateTime(schedule.next_fire_at)}</span>}
|
||||
{schedule.last_error && <span className="danger-text">Paused: {schedule.last_error}</span>}
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
{schedule.last_campaign_id && <Link className="btn btn-secondary" to={`/campaigns/${schedule.last_campaign_id}`}>Open latest draft</Link>}
|
||||
{schedule.next_fire_at && <Button
|
||||
iconOnly
|
||||
aria-label={schedule.active ? "Pause schedule" : "Resume schedule"}
|
||||
title={schedule.active ? "Pause schedule" : "Resume schedule"}
|
||||
disabled={scheduleBusy || !canSchedule}
|
||||
onClick={() => void toggleSchedule(schedule)}>
|
||||
{schedule.active ? <Pause size={16} aria-hidden="true" /> : <Play size={16} aria-hidden="true" />}
|
||||
</Button>}
|
||||
</div>
|
||||
</div>)}
|
||||
</div>}
|
||||
</Card>}
|
||||
|
||||
<Card title="Versions" collapsible actions={<div className="button-row compact-actions">
|
||||
{archivedVersionCount > 0 && <ToggleSwitch
|
||||
label={`Show archived (${archivedVersionCount})`}
|
||||
@@ -335,7 +486,7 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
||||
</LoadingFrame>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(pendingLifecycleAction)}
|
||||
open={Boolean(pendingLifecycleAction && pendingLifecycleAction.action !== "copy_campaign")}
|
||||
title={lifecycleDialogTitle(pendingLifecycleAction)}
|
||||
message={lifecycleDialogMessage(pendingLifecycleAction)}
|
||||
confirmLabel={lifecycleDialogLabel(pendingLifecycleAction)}
|
||||
@@ -344,6 +495,119 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
||||
onCancel={() => setPendingLifecycleAction(null)}
|
||||
onConfirm={() => void applyLifecycleAction()} />
|
||||
|
||||
<Dialog
|
||||
open={pendingLifecycleAction?.action === "copy_campaign"}
|
||||
title="Copy campaign"
|
||||
className="campaign-copy-dialog"
|
||||
helpContextId="campaigns.action.copy-campaign"
|
||||
closeDisabled={lifecycleBusy}
|
||||
onClose={() => setPendingLifecycleAction(null)}
|
||||
footer={<>
|
||||
<Button onClick={() => setPendingLifecycleAction(null)} disabled={lifecycleBusy}>Cancel</Button>
|
||||
<Button variant="primary" onClick={() => void applyLifecycleAction()} disabled={lifecycleBusy || !copyOptions.name?.trim()}>
|
||||
{lifecycleBusy ? "Creating copy..." : "Create copy"}
|
||||
</Button>
|
||||
</>}>
|
||||
<div className="campaign-copy-form">
|
||||
<p className="muted small-note">
|
||||
Create a new draft from version #{pendingLifecycleAction?.version?.version_number ?? "?"}. Delivery jobs, outcomes, locks, reports, and audit evidence are never copied.
|
||||
</p>
|
||||
<div className="campaign-copy-identity">
|
||||
<FormField label="Campaign name" help="The new campaign receives an independent identity and version history.">
|
||||
<input value={copyOptions.name ?? ""} onChange={(event) => setCopyOptions((current) => ({ ...current, name: event.target.value }))} />
|
||||
</FormField>
|
||||
<FormField label="Campaign ID" help="Leave blank to generate a unique ID from the source campaign.">
|
||||
<input value={copyOptions.external_id ?? ""} onChange={(event) => setCopyOptions((current) => ({ ...current, external_id: event.target.value }))} placeholder="Generated automatically" />
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="campaign-copy-options">
|
||||
<CopyOption
|
||||
label="Recipients"
|
||||
detail="Global address headers, recipient rows, imported audience provenance, and per-recipient values."
|
||||
checked={copyOptions.include_recipients}
|
||||
disabled={!hasScope(auth, "campaigns:recipient:read")}
|
||||
onChange={(checked) => setCopyOptions((current) => ({ ...current, include_recipients: checked }))} />
|
||||
<CopyOption
|
||||
label="Files"
|
||||
detail="Campaign and recipient attachment rules. Managed files remain owned by Files and are referenced, not duplicated."
|
||||
checked={copyOptions.include_files}
|
||||
onChange={(checked) => setCopyOptions((current) => ({ ...current, include_files: checked }))} />
|
||||
<CopyOption
|
||||
label="Shares"
|
||||
detail="Current active user and group shares. Ownership always starts with the account creating the copy."
|
||||
checked={copyOptions.include_shares}
|
||||
disabled={!hasScope(auth, "campaigns:campaign:share")}
|
||||
onChange={(checked) => setCopyOptions((current) => ({ ...current, include_shares: checked }))} />
|
||||
<CopyOption
|
||||
label="Policies"
|
||||
detail="Validation behavior and campaign settings."
|
||||
checked={copyOptions.include_policies}
|
||||
onChange={(checked) => setCopyOptions((current) => ({ ...current, include_policies: checked }))} />
|
||||
<CopyOption
|
||||
label="Mail profile"
|
||||
detail="References to the reusable Mail profile, its campaign policy, server selections, and credential selections. Secrets are never copied into Campaign."
|
||||
checked={copyOptions.include_mail_profile}
|
||||
onChange={(checked) => setCopyOptions((current) => ({ ...current, include_mail_profile: checked }))} />
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={scheduleDialogOpen}
|
||||
title="Schedule campaign drafts"
|
||||
className="campaign-schedule-dialog"
|
||||
helpContextId="campaigns.action.schedule-drafts"
|
||||
closeDisabled={scheduleBusy}
|
||||
onClose={() => setScheduleDialogOpen(false)}
|
||||
footer={<>
|
||||
<Button onClick={() => setScheduleDialogOpen(false)} disabled={scheduleBusy}>Cancel</Button>
|
||||
<Button variant="primary" onClick={() => void submitSchedule()} disabled={scheduleBusy || !scheduleDraft.name.trim() || !scheduleDraft.starts_at}>
|
||||
{scheduleBusy ? "Creating schedule..." : "Create schedule"}
|
||||
</Button>
|
||||
</>}>
|
||||
<DismissibleAlert tone="info" resetKey="campaign-schedule-safety">
|
||||
A schedule creates a fresh draft at each due time. It never validates, approves, queues, or sends messages automatically.
|
||||
</DismissibleAlert>
|
||||
<div className="campaign-schedule-form">
|
||||
<FormField label="Draft name">
|
||||
<input value={scheduleDraft.name} onChange={(event) => setScheduleDraft((current) => ({ ...current, name: event.target.value }))} />
|
||||
</FormField>
|
||||
<FormField label="First occurrence">
|
||||
<input type="datetime-local" value={scheduleDraft.starts_at} onChange={(event) => setScheduleDraft((current) => ({ ...current, starts_at: event.target.value }))} />
|
||||
</FormField>
|
||||
<FormField label="Recurrence">
|
||||
<select value={scheduleDraft.recurrence_kind} onChange={(event) => {
|
||||
const recurrence_kind = event.target.value as CampaignSchedule["recurrence_kind"];
|
||||
setScheduleDraft((current) => ({ ...current, recurrence_kind, max_occurrences: recurrence_kind === "once" ? 1 : Math.max(2, current.max_occurrences) }));
|
||||
}}>
|
||||
<option value="once">Once</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
</select>
|
||||
</FormField>
|
||||
{scheduleDraft.recurrence_kind !== "once" && <FormField label="Every">
|
||||
<div className="campaign-schedule-interval">
|
||||
<input type="number" min={1} max={365} value={scheduleDraft.interval_count} onChange={(event) => setScheduleDraft((current) => ({ ...current, interval_count: Math.max(1, Number(event.target.value) || 1) }))} />
|
||||
<span>{scheduleDraft.recurrence_kind.replace("ly", "")} interval(s)</span>
|
||||
</div>
|
||||
</FormField>}
|
||||
{scheduleDraft.recurrence_kind !== "once" && <FormField label="Maximum occurrences" help="Recurring schedules are bounded. Create a new schedule if the approved plan changes.">
|
||||
<input type="number" min={2} max={1000} value={scheduleDraft.max_occurrences} onChange={(event) => setScheduleDraft((current) => ({ ...current, max_occurrences: Math.max(2, Number(event.target.value) || 2) }))} />
|
||||
</FormField>}
|
||||
<FormField label="Timezone">
|
||||
<input value={scheduleDraft.timezone} onChange={(event) => setScheduleDraft((current) => ({ ...current, timezone: event.target.value }))} />
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="campaign-copy-options">
|
||||
<CopyOption label="Recipients" detail="Recipient rows and values used to prepare each draft." checked={scheduleDraft.include_recipients} disabled={!hasScope(auth, "campaigns:recipient:read")} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_recipients: checked }))} />
|
||||
<CopyOption label="Files" detail="Attachment rules and Files references; generated evidence is never copied." checked={scheduleDraft.include_files} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_files: checked }))} />
|
||||
<CopyOption label="Shares" detail="Current active shares are sealed when the schedule is created." checked={scheduleDraft.include_shares} disabled={!hasScope(auth, "campaigns:campaign:share")} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_shares: checked }))} />
|
||||
<CopyOption label="Policies" detail="Campaign and validation policy configuration." checked={scheduleDraft.include_policies} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_policies: checked }))} />
|
||||
<CopyOption label="Mail profile" detail="Reusable Mail profile references; credentials remain Mail-owned." checked={scheduleDraft.include_mail_profile} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_mail_profile: checked }))} />
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(pendingLockAction)}
|
||||
title={lockDialogTitle(pendingLockAction)}
|
||||
@@ -358,6 +622,42 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
||||
|
||||
}
|
||||
|
||||
function localDateTimeValue(value: Date): string {
|
||||
const local = new Date(value.getTime() - value.getTimezoneOffset() * 60_000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function scheduleCadence(schedule: CampaignSchedule): string {
|
||||
if (schedule.recurrence_kind === "once") return "One time";
|
||||
const unit = { daily: "day", weekly: "week", monthly: "month" }[schedule.recurrence_kind];
|
||||
return schedule.interval_count === 1 ? schedule.recurrence_kind : `Every ${schedule.interval_count} ${unit}s`;
|
||||
}
|
||||
|
||||
function CopyOption({
|
||||
label,
|
||||
detail,
|
||||
checked,
|
||||
disabled = false,
|
||||
onChange
|
||||
}: {
|
||||
label: string;
|
||||
detail: string;
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
return <div className={`campaign-copy-option ${disabled ? "is-disabled" : ""}`}>
|
||||
<div><strong>{label}</strong><small>{detail}</small></div>
|
||||
<ToggleSwitch
|
||||
label={`Copy ${label.toLowerCase()}`}
|
||||
inactiveLabel="Exclude"
|
||||
activeLabel="Include"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={onChange} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
type TemplateHealthTone = "neutral" | "good" | "warning" | "danger" | "info";
|
||||
|
||||
type CampaignVersionMetrics = {
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import {
|
||||
listCampaignContentLibrary,
|
||||
listCampaignPrintTemplates,
|
||||
previewCampaignAttachments,
|
||||
saveCampaignContentLibraryItem,
|
||||
type CampaignAttachmentPreviewRule,
|
||||
type CampaignContentLibraryItem,
|
||||
type CampaignContentLibraryResponse,
|
||||
type CampaignContentLibraryTarget,
|
||||
type CampaignPrintTemplate
|
||||
} from "../../api/campaigns";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { Dialog } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { FieldLabel } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
@@ -29,6 +35,7 @@ import { buildTemplatePreviewContext, buildUndefinedPlaceholders, extractTemplat
|
||||
type TemplateBodyMode = "text" | "html" | "both";
|
||||
type BodyEditorMode = "text" | "html";
|
||||
type EditorTarget = "subject" | "text" | "html";
|
||||
type ContentLibrarySaveKind = "fragment" | "campaign_part";
|
||||
|
||||
export default function TemplateDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||
@@ -44,6 +51,19 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
||||
const [printTemplates, setPrintTemplates] = useState<CampaignPrintTemplate[]>([]);
|
||||
const [printTemplatesLoading, setPrintTemplatesLoading] = useState(true);
|
||||
const [printTemplatesError, setPrintTemplatesError] = useState("");
|
||||
const [contentLibraryOpen, setContentLibraryOpen] = useState(false);
|
||||
const [contentLibraryQuery, setContentLibraryQuery] = useState("");
|
||||
const [contentLibrary, setContentLibrary] = useState<CampaignContentLibraryResponse | null>(null);
|
||||
const [contentLibraryLoading, setContentLibraryLoading] = useState(false);
|
||||
const [contentLibraryError, setContentLibraryError] = useState("");
|
||||
const [contentSaveOpen, setContentSaveOpen] = useState(false);
|
||||
const [contentSaveBusy, setContentSaveBusy] = useState(false);
|
||||
const [contentSaveName, setContentSaveName] = useState("");
|
||||
const [contentSaveDescription, setContentSaveDescription] = useState("");
|
||||
const [contentSaveKind, setContentSaveKind] = useState<ContentLibrarySaveKind>("fragment");
|
||||
const [contentSaveTarget, setContentSaveTarget] = useState<CampaignContentLibraryTarget>("text");
|
||||
const [contentSaveVisibility, setContentSaveVisibility] = useState<"personal" | "tenant">("personal");
|
||||
const [contentLibraryNotice, setContentLibraryNotice] = useState("");
|
||||
const subjectRef = useRef<HTMLInputElement | null>(null);
|
||||
const textRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const htmlRef = useRef<WysiwygEditorHandle | null>(null);
|
||||
@@ -68,6 +88,8 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
||||
const printConfig = asRecord(delivery.print);
|
||||
const selectedPrintTemplate = printTemplates.find((item) => item.id === getText(printConfig, "template_id")) ?? null;
|
||||
const templateBodyMode = normalizeTemplateBodyMode(getText(template, "body_mode", "both"));
|
||||
const contentSaveSelectedValue = getText(template, contentSaveTarget);
|
||||
const contentSaveHasBody = Boolean(getText(template, "text").trim() || getText(template, "html").trim());
|
||||
const visibleBodyEditor: BodyEditorMode = templateBodyMode === "html" ? "html" : templateBodyMode === "text" ? "text" : activeBodyEditor;
|
||||
const fields = useMemo(() => asArray(displayDraft.fields).map(asRecord), [displayDraft.fields]);
|
||||
const localFieldNames = useMemo(() => fields.map((field) => String(field.name || field.id || "")).filter(Boolean), [fields]);
|
||||
@@ -179,6 +201,32 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
||||
return () => { cancelled = true; };
|
||||
}, [campaignId, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contentLibraryOpen) return;
|
||||
let cancelled = false;
|
||||
setContentLibraryLoading(true);
|
||||
setContentLibraryError("");
|
||||
const handle = window.setTimeout(() => {
|
||||
void listCampaignContentLibrary(settings, campaignId, contentLibraryQuery)
|
||||
.then((response) => {
|
||||
if (!cancelled) setContentLibrary(response);
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (!cancelled) {
|
||||
setContentLibrary(null);
|
||||
setContentLibraryError(reason instanceof Error ? reason.message : String(reason));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setContentLibraryLoading(false);
|
||||
});
|
||||
}, 180);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(handle);
|
||||
};
|
||||
}, [campaignId, contentLibraryOpen, contentLibraryQuery, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||
|
||||
|
||||
function patchTemplateText(target: EditorTarget, value: string) {
|
||||
patch(["template", target], value);
|
||||
@@ -280,6 +328,87 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
||||
setUndefinedDialog(null);
|
||||
}
|
||||
|
||||
function openContentSaveDialog() {
|
||||
setContentSaveName("");
|
||||
setContentSaveDescription("");
|
||||
setContentSaveKind("fragment");
|
||||
setContentSaveTarget(activeEditor === "subject" ? "subject" : visibleBodyEditor);
|
||||
setContentSaveVisibility("personal");
|
||||
setContentLibraryError("");
|
||||
setContentSaveOpen(true);
|
||||
}
|
||||
|
||||
function insertContentFragment(target: CampaignContentLibraryTarget, value: string) {
|
||||
if (locked || !value) return;
|
||||
if (target === "html") {
|
||||
const inserted = htmlRef.current?.insertText(value) ?? false;
|
||||
if (!inserted) patchTemplateText("html", `${getText(template, "html")}${value}`);
|
||||
setActiveBodyEditor("html");
|
||||
setActiveEditor("html");
|
||||
setContentLibraryOpen(false);
|
||||
return;
|
||||
}
|
||||
const element = target === "subject" ? subjectRef.current : textRef.current;
|
||||
const currentText = getText(template, target);
|
||||
const start = element?.selectionStart ?? currentText.length;
|
||||
const end = element?.selectionEnd ?? currentText.length;
|
||||
patchTemplateText(target, `${currentText.slice(0, start)}${value}${currentText.slice(end)}`);
|
||||
window.requestAnimationFrame(() => {
|
||||
element?.focus();
|
||||
const cursor = start + value.length;
|
||||
element?.setSelectionRange(cursor, cursor);
|
||||
});
|
||||
if (target === "text") setActiveBodyEditor("text");
|
||||
setActiveEditor(target);
|
||||
setContentLibraryOpen(false);
|
||||
}
|
||||
|
||||
function applyCampaignPart(item: CampaignContentLibraryItem) {
|
||||
if (locked) return;
|
||||
setDraft((current) => {
|
||||
const next = cloneJson(current ?? {});
|
||||
next.template = {
|
||||
...asRecord(next.template),
|
||||
subject: item.subject ?? "",
|
||||
text: item.text ?? "",
|
||||
html: item.html ?? "",
|
||||
body_mode: item.body_mode
|
||||
};
|
||||
return next;
|
||||
});
|
||||
markDirty();
|
||||
setActiveBodyEditor(item.body_mode === "html" ? "html" : "text");
|
||||
setActiveEditor(item.body_mode === "html" ? "html" : "text");
|
||||
setContentLibraryOpen(false);
|
||||
}
|
||||
|
||||
async function saveReusableContent() {
|
||||
if (contentSaveBusy || !contentSaveName.trim()) return;
|
||||
setContentSaveBusy(true);
|
||||
setContentLibraryError("");
|
||||
try {
|
||||
const result = await saveCampaignContentLibraryItem(settings, campaignId, {
|
||||
name: contentSaveName.trim(),
|
||||
description: contentSaveDescription.trim() || null,
|
||||
kind: contentSaveKind,
|
||||
target: contentSaveKind === "fragment" ? contentSaveTarget : null,
|
||||
subject: getText(template, "subject"),
|
||||
text: getText(template, "text"),
|
||||
html: getText(template, "html"),
|
||||
body_mode: templateBodyMode,
|
||||
locale: "de",
|
||||
visibility: contentSaveVisibility
|
||||
});
|
||||
setContentSaveOpen(false);
|
||||
setContentLibraryNotice(`${result.template.name} was saved as an unpublished Templates draft.`);
|
||||
setContentLibrary(null);
|
||||
} catch (reason) {
|
||||
setContentLibraryError(reason instanceof Error ? reason.message : String(reason));
|
||||
} finally {
|
||||
setContentSaveBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="content-pad workspace-data-page">
|
||||
@@ -289,7 +418,7 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button disabled>i18n:govoplan-campaign.manage_templates.23688071</Button>
|
||||
<Button onClick={() => window.location.assign("/templates")}>i18n:govoplan-campaign.manage_templates.23688071</Button>
|
||||
<Button onClick={() => void discardDraft()} disabled={loading}>Discard</Button>
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>i18n:govoplan-campaign.save.efc007a3</Button>
|
||||
</div>
|
||||
@@ -297,6 +426,7 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
||||
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
||||
{contentLibraryNotice && <DismissibleAlert tone="success" resetKey={contentLibraryNotice} floating>{contentLibraryNotice}</DismissibleAlert>}
|
||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
|
||||
|
||||
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
||||
@@ -371,8 +501,8 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
||||
</div>
|
||||
}
|
||||
<div className="button-row template-editor-actions">
|
||||
<Button disabled>i18n:govoplan-campaign.load_from_library.327ada7c</Button>
|
||||
<Button disabled>i18n:govoplan-campaign.save_to_library.396649bf</Button>
|
||||
<Button onClick={() => setContentLibraryOpen(true)} disabled={locked}>i18n:govoplan-campaign.load_from_library.327ada7c</Button>
|
||||
<Button onClick={openContentSaveDialog} disabled={locked}>i18n:govoplan-campaign.save_to_library.396649bf</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -494,6 +624,154 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
||||
|
||||
}
|
||||
|
||||
<Dialog
|
||||
open={contentLibraryOpen}
|
||||
title="Reusable content"
|
||||
className="campaign-content-library-dialog"
|
||||
helpContextId="campaign.template.content-library"
|
||||
onClose={() => setContentLibraryOpen(false)}
|
||||
footer={<>
|
||||
<Button onClick={() => window.location.assign("/templates")}>Manage Templates</Button>
|
||||
<Button variant="primary" onClick={() => setContentLibraryOpen(false)}>Close</Button>
|
||||
</>}
|
||||
>
|
||||
<div className="campaign-content-library">
|
||||
<FormField label="Search library" help="Searches content published or visible to your Templates scope.">
|
||||
<input
|
||||
value={contentLibraryQuery}
|
||||
onChange={(event) => setContentLibraryQuery(event.target.value)}
|
||||
placeholder="Name or description"
|
||||
autoFocus
|
||||
/>
|
||||
</FormField>
|
||||
{contentLibraryError && <DismissibleAlert tone="danger" compact resetKey={contentLibraryError}>{contentLibraryError}</DismissibleAlert>}
|
||||
<LoadingFrame loading={contentLibraryLoading} label="Loading reusable content">
|
||||
<div className="campaign-content-library-list">
|
||||
{!contentLibraryError && contentLibrary && !contentLibrary.available && (
|
||||
<DismissibleAlert tone="info" dismissible={false}>{contentLibrary.reason || "Enable Templates to use reusable content."}</DismissibleAlert>
|
||||
)}
|
||||
{!contentLibraryError && contentLibrary?.available && contentLibrary.items.length === 0 && (
|
||||
<p className="muted">No reusable Campaign content matches this search.</p>
|
||||
)}
|
||||
{contentLibrary?.items.map((item) => (
|
||||
<div className="campaign-content-library-item" key={`${item.id}:${item.revision_id}`}>
|
||||
<div className="campaign-content-library-item-copy">
|
||||
<div className="campaign-content-library-item-title">
|
||||
<strong>{item.name}</strong>
|
||||
<span>{item.published ? `Published r${item.revision}` : `Draft r${item.revision}`}</span>
|
||||
</div>
|
||||
{item.description && <p>{item.description}</p>}
|
||||
<small>{item.kind === "fragment" ? "Content fragment" : "Complete campaign part"} · {item.scope_type} · {item.locale || "unspecified locale"}</small>
|
||||
</div>
|
||||
<div className="button-row campaign-content-library-item-actions">
|
||||
{item.kind === "campaign_part" ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={locked}
|
||||
onClick={() => applyCampaignPart(item)}
|
||||
title="Replaces the current subject and body fields in this draft"
|
||||
>Apply part</Button>
|
||||
) : item.targets.map((target) => {
|
||||
const value = target === "html" ? item.html : item.text;
|
||||
return (
|
||||
<Button
|
||||
key={target}
|
||||
disabled={locked || !value}
|
||||
onClick={() => value && insertContentFragment(target, value)}
|
||||
>Insert in {target}</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</LoadingFrame>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={contentSaveOpen}
|
||||
title="Save reusable content"
|
||||
className="campaign-content-save-dialog"
|
||||
helpContextId="campaign.template.content-library"
|
||||
closeDisabled={contentSaveBusy}
|
||||
onClose={() => setContentSaveOpen(false)}
|
||||
footer={<>
|
||||
<Button onClick={() => setContentSaveOpen(false)} disabled={contentSaveBusy}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void saveReusableContent()}
|
||||
disabled={
|
||||
contentSaveBusy ||
|
||||
!contentSaveName.trim() ||
|
||||
(contentSaveKind === "fragment" ? !contentSaveSelectedValue.trim() : !contentSaveHasBody)
|
||||
}
|
||||
>{contentSaveBusy ? "Saving..." : "Save draft"}</Button>
|
||||
</>}
|
||||
>
|
||||
<div className="campaign-content-save-form">
|
||||
<DismissibleAlert tone="info" compact dismissible={false}>
|
||||
Saving creates an unpublished Templates draft. Publication and later revisions remain governed in Templates.
|
||||
</DismissibleAlert>
|
||||
{contentLibraryError && <DismissibleAlert tone="danger" compact resetKey={contentLibraryError}>{contentLibraryError}</DismissibleAlert>}
|
||||
<div className="campaign-content-save-identity">
|
||||
<FormField label="Name">
|
||||
<input value={contentSaveName} onChange={(event) => setContentSaveName(event.target.value)} autoFocus />
|
||||
</FormField>
|
||||
<FormField label="Visibility" help="Personal drafts are visible to you; tenant drafts are available to authorized template users.">
|
||||
<SegmentedControl
|
||||
ariaLabel="Template visibility"
|
||||
value={contentSaveVisibility}
|
||||
onChange={setContentSaveVisibility}
|
||||
size="content"
|
||||
width="inline"
|
||||
options={[
|
||||
{ id: "personal", label: "Personal" },
|
||||
{ id: "tenant", label: "Tenant" }
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField label="Description">
|
||||
<textarea rows={3} value={contentSaveDescription} onChange={(event) => setContentSaveDescription(event.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="Content kind">
|
||||
<SegmentedControl
|
||||
ariaLabel="Reusable content kind"
|
||||
value={contentSaveKind}
|
||||
onChange={setContentSaveKind}
|
||||
size="content"
|
||||
width="inline"
|
||||
options={[
|
||||
{ id: "fragment", label: "Fragment" },
|
||||
{ id: "campaign_part", label: "Complete part" }
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
{contentSaveKind === "fragment" && (
|
||||
<FormField label="Source field" help="The selected current field becomes the reusable fragment.">
|
||||
<SegmentedControl
|
||||
ariaLabel="Fragment source field"
|
||||
value={contentSaveTarget}
|
||||
onChange={setContentSaveTarget}
|
||||
size="content"
|
||||
width="inline"
|
||||
options={[
|
||||
{ id: "subject", label: "Subject" },
|
||||
{ id: "text", label: "Text" },
|
||||
{ id: "html", label: "HTML" }
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
<p className="muted small-note">
|
||||
{contentSaveKind === "fragment"
|
||||
? `${contentSaveSelectedValue.length.toLocaleString()} characters from ${contentSaveTarget}.`
|
||||
: "Subject, text, HTML, and body mode are stored as one reusable Campaign part."}
|
||||
</p>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<UndefinedPlaceholderDecisionDialog
|
||||
field={undefinedDialog}
|
||||
contextLabel="template"
|
||||
|
||||
@@ -29,6 +29,12 @@ export function ensureCampaignDraft(version: CampaignVersionDetail | null): Reco
|
||||
send_without_attachments: true,
|
||||
send_without_attachments_behavior: "continue",
|
||||
global: [],
|
||||
residual_files: {
|
||||
mode: "none",
|
||||
recipient: null,
|
||||
subject: "Unassigned files in campaign {{local:campaign_name}}",
|
||||
text: "The campaign build found {{local:residual_file_count}} file(s) that were not assigned to a recipient.\n\n{{local:residual_file_list}}"
|
||||
},
|
||||
missing_behavior: "warn",
|
||||
ambiguous_behavior: "ask",
|
||||
...sourceAttachments
|
||||
|
||||
@@ -2781,3 +2781,68 @@
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
.campaign-copy-dialog { width: min(760px, calc(100vw - 32px)); }
|
||||
.campaign-copy-form { display: grid; gap: 16px; }
|
||||
.campaign-copy-form > p { margin: 0; }
|
||||
.campaign-copy-identity { display: grid; grid-template-columns: minmax(0, 1fr) minmax(220px, .65fr); gap: 12px; }
|
||||
.campaign-copy-identity input { width: 100%; }
|
||||
.campaign-copy-options { border: var(--border-line); }
|
||||
.campaign-copy-option { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 18px; min-height: 66px; padding: 10px 12px; border-bottom: var(--border-line); }
|
||||
.campaign-copy-option:last-child { border-bottom: 0; }
|
||||
.campaign-copy-option strong, .campaign-copy-option small { display: block; }
|
||||
.campaign-copy-option small { margin-top: 3px; color: var(--muted); line-height: 1.35; }
|
||||
.campaign-copy-option.is-disabled { opacity: .62; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.campaign-copy-identity { grid-template-columns: 1fr; }
|
||||
.campaign-copy-option { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.campaign-content-library-dialog { width: min(880px, calc(100vw - 32px)); }
|
||||
.campaign-content-save-dialog { width: min(680px, calc(100vw - 32px)); }
|
||||
.campaign-content-library, .campaign-content-save-form { display: grid; gap: 14px; }
|
||||
.campaign-content-library input, .campaign-content-save-form input, .campaign-content-save-form textarea { width: 100%; }
|
||||
.campaign-content-library-list { display: grid; max-height: min(56vh, 560px); overflow: auto; border: var(--border-line); }
|
||||
.campaign-content-library-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 18px; align-items: center; padding: 12px; border-bottom: var(--border-line); }
|
||||
.campaign-content-library-item:last-child { border-bottom: 0; }
|
||||
.campaign-content-library-item:hover { background: var(--hover-bg); }
|
||||
.campaign-content-library-item-copy { min-width: 0; }
|
||||
.campaign-content-library-item-copy p { margin: 4px 0; }
|
||||
.campaign-content-library-item-copy small { color: var(--muted); }
|
||||
.campaign-content-library-item-title { display: flex; align-items: baseline; gap: 8px; }
|
||||
.campaign-content-library-item-title span { color: var(--muted); font-size: var(--font-size-sm); }
|
||||
.campaign-content-library-item-actions { justify-content: flex-end; }
|
||||
.campaign-content-save-identity { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: end; }
|
||||
.campaign-content-save-form > p { margin: 0; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.campaign-content-library-item, .campaign-content-save-identity { grid-template-columns: 1fr; }
|
||||
.campaign-content-library-item-actions { justify-content: flex-start; }
|
||||
}
|
||||
|
||||
.campaign-schedule-dialog { width: min(760px, calc(100vw - 32px)); }
|
||||
.campaign-schedule-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-block: 12px; }
|
||||
.campaign-schedule-interval { display: grid; grid-template-columns: 88px minmax(0, 1fr); align-items: center; gap: 8px; }
|
||||
.campaign-schedule-list { display: grid; gap: 4px; }
|
||||
.campaign-schedule-row { display: flex; align-items: center; justify-content: space-between; min-height: 52px; gap: 12px; padding: 8px; border-radius: 4px; }
|
||||
.campaign-schedule-row:hover { background: var(--hover-bg); }
|
||||
.campaign-schedule-main { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; min-width: 0; }
|
||||
.campaign-schedule-main strong { flex-basis: 100%; }
|
||||
.campaign-schedule-main span { color: var(--muted); font-size: var(--font-size-sm); }
|
||||
.campaign-schedule-main .danger-text { color: var(--danger); }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.campaign-schedule-form { grid-template-columns: minmax(0, 1fr); }
|
||||
.campaign-schedule-row { align-items: flex-start; flex-direction: column; }
|
||||
}
|
||||
|
||||
.campaign-residual-file-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-bottom: 12px; }
|
||||
.campaign-residual-file-wide { grid-column: 1 / -1; }
|
||||
.campaign-residual-file-form input,
|
||||
.campaign-residual-file-form select,
|
||||
.campaign-residual-file-form textarea { width: 100%; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.campaign-residual-file-form { grid-template-columns: minmax(0, 1fr); }
|
||||
.campaign-residual-file-wide { grid-column: auto; }
|
||||
}
|
||||
|
||||
@@ -15,7 +15,13 @@ assert.match(overview, /pendingLifecycleAction/);
|
||||
assert.match(overview, /archive_campaign_confirmation/);
|
||||
assert.match(overview, /await archiveCampaign\(settings, campaign\.id, pending\.policy\.state_token\)/);
|
||||
assert.match(overview, /await deleteCampaign\(settings, campaign\.id, pending\.policy\.state_token\)/);
|
||||
assert.match(overview, /await copyCampaign\(settings, campaign\.id, pending\.version\.id, pending\.policy\.state_token\)/);
|
||||
assert.match(overview, /await copyCampaign\(settings, campaign\.id, pending\.version\.id, pending\.policy\.state_token, copyOptions\)/);
|
||||
assert.match(overview, /include_recipients/);
|
||||
assert.match(overview, /include_files/);
|
||||
assert.match(overview, /include_shares/);
|
||||
assert.match(overview, /include_policies/);
|
||||
assert.match(overview, /include_mail_profile/);
|
||||
assert.match(overview, /Delivery jobs, outcomes, locks, reports, and audit evidence are never copied/);
|
||||
assert.match(overview, /await archiveCampaignVersion\(settings, campaign\.id, pending\.version\.id, pending\.policy\.state_token\)/);
|
||||
assert.match(api, /\/api\/v1\/campaigns\/\$\{campaignId\}\/archive/);
|
||||
assert.match(api, /\/api\/v1\/campaigns\/\$\{campaignId\}\/copies/);
|
||||
|
||||
Reference in New Issue
Block a user