feat: add campaign copying scheduling and residual handling

This commit is contained in:
2026-08-07 14:54:04 +02:00
parent 696f8f6385
commit c2efd6b7bd
35 changed files with 3359 additions and 39 deletions
@@ -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 ( from govoplan_campaign.backend.db.models import (
Campaign, Campaign,
CampaignJob, CampaignJob,
CampaignSchedule,
CampaignShare, CampaignShare,
CampaignVersion, CampaignVersion,
) )
@@ -19,7 +20,7 @@ from govoplan_core.auth import ApiPrincipal, has_scope
POLICY_ID = "campaign.lifecycle" POLICY_ID = "campaign.lifecycle"
POLICY_VERSION = "1" POLICY_VERSION = "2"
_ACTIVE_QUEUE_STATES = {"queued", "sending"} _ACTIVE_QUEUE_STATES = {"queued", "sending"}
_ACTIVE_SEND_STATES = {"queued", "claimed", "sending", "outcome_unknown"} _ACTIVE_SEND_STATES = {"queued", "claimed", "sending", "outcome_unknown"}
@@ -104,6 +105,12 @@ def campaign_lifecycle_policy(
.order_by(CampaignShare.id.asc()) .order_by(CampaignShare.id.asc())
.all() .all()
) )
schedules = (
session.query(CampaignSchedule)
.filter(CampaignSchedule.campaign_id == campaign.id)
.order_by(CampaignSchedule.id.asc())
.all()
)
selected_version = next( selected_version = next(
(version for version in versions if version.id == version_id), (version for version in versions if version.id == version_id),
None, None,
@@ -116,6 +123,10 @@ def campaign_lifecycle_policy(
"id": campaign.id, "id": campaign.id,
"status": campaign.status, "status": campaign.status,
"current_version_id": campaign.current_version_id, "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), "updated_at": _timestamp(campaign.updated_at),
}, },
"versions": [ "versions": [
@@ -129,6 +140,7 @@ def campaign_lifecycle_policy(
"published_at": _timestamp(version.published_at), "published_at": _timestamp(version.published_at),
"execution_snapshot_at": _timestamp(version.execution_snapshot_at), "execution_snapshot_at": _timestamp(version.execution_snapshot_at),
"archived_at": _timestamp(version.archived_at), "archived_at": _timestamp(version.archived_at),
"configuration_sha256": _canonical_hash(version.raw_json or {}),
"updated_at": _timestamp(version.updated_at), "updated_at": _timestamp(version.updated_at),
} }
for version in versions for version in versions
@@ -145,13 +157,34 @@ def campaign_lifecycle_policy(
} }
for job in jobs 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, "selected_version_id": version_id,
} }
token = _canonical_hash(snapshot) token = _canonical_hash(snapshot)
active_delivery = any(_active_delivery(job) for job in jobs) active_delivery = any(_active_delivery(job) for job in jobs)
protected_versions = any(_protected_version(version) for version in versions) protected_versions = any(_protected_version(version) for version in versions)
active_schedules = any(schedule.active for schedule in schedules)
archive = LifecycleDecision(True) archive = LifecycleDecision(True)
if not has_scope(principal, "campaigns:campaign:archive"): if not has_scope(principal, "campaigns:campaign:archive"):
@@ -163,6 +196,11 @@ def campaign_lifecycle_policy(
False, False,
"Active or uncertain delivery must be resolved before archiving.", "Active or uncertain delivery must be resolved before archiving.",
) )
elif active_schedules:
archive = LifecycleDecision(
False,
"Pause active Campaign schedules before archiving.",
)
delete = LifecycleDecision(True) delete = LifecycleDecision(True)
if not has_scope(principal, "campaigns:campaign:delete"): if not has_scope(principal, "campaigns:campaign:delete"):
@@ -184,12 +222,15 @@ def campaign_lifecycle_policy(
False, False,
"Revoke active campaign shares before deleting the untouched draft.", "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) copy = LifecycleDecision(True)
if not has_scope(principal, "campaigns:campaign:copy"): if not has_scope(principal, "campaigns:campaign:copy"):
copy = LifecycleDecision(False, "Missing campaign copy permission.") 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: elif version_id is not None and selected_version is None:
copy = LifecycleDecision(False, "The selected source version does not exist.") copy = LifecycleDecision(False, "The selected source version does not exist.")
@@ -220,9 +261,10 @@ def campaign_lifecycle_policy(
"campaign_state", "campaign_state",
"retained_evidence", "retained_evidence",
"active_delivery", "active_delivery",
"scheduled_automation",
"optimistic_concurrency", "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 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): class AttachmentConfig(StrictModel):
id: str | None = None id: str | None = None
label: str | None = None label: str | None = None
@@ -509,6 +533,9 @@ class AttachmentsConfig(StrictModel):
global_: list[AttachmentConfig] = Field(default_factory=list, alias="global") global_: list[AttachmentConfig] = Field(default_factory=list, alias="global")
missing_behavior: Behavior = Behavior.WARN missing_behavior: Behavior = Behavior.WARN
ambiguous_behavior: Behavior = Behavior.ASK ambiguous_behavior: Behavior = Behavior.ASK
residual_files: ResidualFileDispositionConfig = Field(
default_factory=ResidualFileDispositionConfig
)
@model_validator(mode="after") @model_validator(mode="after")
def normalize_send_without_attachments_behavior(self) -> "AttachmentsConfig": 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, CampaignPolicyContext,
CampaignPolicyContextProvider, CampaignPolicyContextProvider,
CampaignRetentionProvider, CampaignRetentionProvider,
CampaignScheduleProvider,
) )
from govoplan_core.core.ownership import ( from govoplan_core.core.ownership import (
OwnershipActionDecision, OwnershipActionDecision,
@@ -715,6 +716,37 @@ def delivery_tasks_capability(context: object) -> CampaignDeliveryTaskService:
return 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): class CampaignRetentionService(CampaignRetentionProvider):
def apply_retention(self, session: object, *, dry_run, now, policy_for_campaign_id): def apply_retention(self, session: object, *, dry_run, now, policy_for_campaign_id):
from govoplan_campaign.backend.retention import apply_campaign_retention 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) 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): class RecipientImportMappingProfile(Base, TimestampMixin):
__tablename__ = "campaign_recipient_import_mapping_profiles" __tablename__ = "campaign_recipient_import_mapping_profiles"
__table_args__ = ( __table_args__ = (
+127 -6
View File
@@ -46,6 +46,7 @@ _ADDRESSES_SOURCE_INTEGRATION = "addresses.recipient_source"
_DISTRIBUTION_LIST_SOURCE_INTEGRATION = "dist_lists.source" _DISTRIBUTION_LIST_SOURCE_INTEGRATION = "dist_lists.source"
_DISTRIBUTION_LIST_EXPAND_INTEGRATION = "dist_lists.expand" _DISTRIBUTION_LIST_EXPAND_INTEGRATION = "dist_lists.expand"
_TEMPLATE_CATALOG_INTEGRATION = "templates.catalog" _TEMPLATE_CATALOG_INTEGRATION = "templates.catalog"
_TEMPLATE_CONTENT_LIBRARY_INTEGRATION = "templates.content_library"
_TEMPLATE_RENDERER_INTEGRATION = "templates.renderer" _TEMPLATE_RENDERER_INTEGRATION = "templates.renderer"
_CALENDAR_INVITATION_INTEGRATION = "calendar.invitations" _CALENDAR_INVITATION_INTEGRATION = "calendar.invitations"
_NOTIFICATIONS_INTEGRATION = "notifications.dispatch" _NOTIFICATIONS_INTEGRATION = "notifications.dispatch"
@@ -73,6 +74,7 @@ def _workflow_topic(
links: tuple[DocumentationLink, ...] = (DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),), links: tuple[DocumentationLink, ...] = (DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),),
related_modules: tuple[str, ...] = (), related_modules: tuple[str, ...] = (),
limitations: tuple[str, ...] = (), limitations: tuple[str, ...] = (),
translations: dict[str, dict[str, str]] | None = None,
) -> DocumentationTopic: ) -> DocumentationTopic:
metadata: dict[str, object] = { metadata: dict[str, object] = {
"kind": "workflow", "kind": "workflow",
@@ -106,6 +108,7 @@ def _workflow_topic(
links=links, links=links,
related_modules=related_modules, related_modules=related_modules,
unlocks=(outcome,), unlocks=(outcome,),
translations=translations or {},
source_module_id="campaigns", source_module_id="campaigns",
metadata=metadata, metadata=metadata,
) )
@@ -163,26 +166,109 @@ CAMPAIGN_USER_DOCUMENTATION = (
topic_id="campaigns.workflow.copy-campaign", topic_id="campaigns.workflow.copy-campaign",
title="Copy a campaign into a new draft", 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.", 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, order=32,
audience=("campaign_manager", "campaign_author"), 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}", route="/campaigns/{campaign_id}",
screen="Campaign overview", screen="Campaign overview",
help_contexts=("campaign.overview",), help_contexts=("campaign.overview",),
prerequisites=( 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.", "You may create campaign copies in the active tenant.",
), ),
steps=( steps=(
"Open the campaign overview and choose the current or a historical source version.", "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.", "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.", "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.", 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, and no source jobs, outcomes, shares, or locks.", 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"), 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( _workflow_topic(
topic_id="campaigns.workflow.import-recipients", topic_id="campaigns.workflow.import-recipients",
@@ -366,6 +452,41 @@ CAMPAIGN_USER_DOCUMENTATION = (
), ),
related_modules=("files",), 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( _workflow_topic(
topic_id="campaigns.workflow.send-calendar-invitations", topic_id="campaigns.workflow.send-calendar-invitations",
title="Send individualized calendar invitations", title="Send individualized calendar invitations",
@@ -38,9 +38,12 @@ from govoplan_core.core.postbox import (
) )
from govoplan_core.core.templates import ( from govoplan_core.core.templates import (
CAPABILITY_TEMPLATE_CATALOG, CAPABILITY_TEMPLATE_CATALOG,
CAPABILITY_TEMPLATE_CONTENT_LIBRARY,
CAPABILITY_TEMPLATE_RENDERER, CAPABILITY_TEMPLATE_RENDERER,
TemplateCatalogProvider, TemplateCatalogProvider,
TemplateCompatibility, TemplateCompatibility,
TemplateContentDraftRequest,
TemplateContentLibraryProvider,
TemplateRef, TemplateRef,
TemplateRenderRequest, TemplateRenderRequest,
TemplateRenderResult, TemplateRenderResult,
@@ -56,6 +59,7 @@ POSTBOX_DIRECTORY_CAPABILITY = CAPABILITY_POSTBOX_DIRECTORY
POSTBOX_EVIDENCE_CAPABILITY = CAPABILITY_POSTBOX_EVIDENCE POSTBOX_EVIDENCE_CAPABILITY = CAPABILITY_POSTBOX_EVIDENCE
APPROVALS_CAPABILITY = CAPABILITY_APPROVAL_REQUESTS APPROVALS_CAPABILITY = CAPABILITY_APPROVAL_REQUESTS
TEMPLATE_CATALOG_CAPABILITY = CAPABILITY_TEMPLATE_CATALOG TEMPLATE_CATALOG_CAPABILITY = CAPABILITY_TEMPLATE_CATALOG
TEMPLATE_CONTENT_LIBRARY_CAPABILITY = CAPABILITY_TEMPLATE_CONTENT_LIBRARY
TEMPLATE_RENDERER_CAPABILITY = CAPABILITY_TEMPLATE_RENDERER TEMPLATE_RENDERER_CAPABILITY = CAPABILITY_TEMPLATE_RENDERER
CALENDAR_INVITATIONS_CAPABILITY = CAPABILITY_CALENDAR_INVITATIONS CALENDAR_INVITATIONS_CAPABILITY = CAPABILITY_CALENDAR_INVITATIONS
@@ -527,6 +531,7 @@ class TemplatesCampaignIntegration:
self, self,
catalog_delegate: object | None = None, catalog_delegate: object | None = None,
renderer_delegate: object | None = None, renderer_delegate: object | None = None,
content_library_delegate: object | None = None,
) -> None: ) -> None:
self._catalog = ( self._catalog = (
catalog_delegate catalog_delegate
@@ -538,11 +543,24 @@ class TemplatesCampaignIntegration:
if isinstance(renderer_delegate, TemplateRendererProvider) if isinstance(renderer_delegate, TemplateRendererProvider)
else None else None
) )
self._content_library = (
content_library_delegate
if isinstance(content_library_delegate, TemplateContentLibraryProvider)
else None
)
@property @property
def available(self) -> bool: def available(self) -> bool:
return self._catalog is not None and self._renderer is not None 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( def list_templates(
self, self,
session: object, 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( def check_compatibility(
self, self,
session: object, session: object,
@@ -799,6 +854,7 @@ def templates_integration() -> TemplatesCampaignIntegration:
return TemplatesCampaignIntegration( return TemplatesCampaignIntegration(
capability(TEMPLATE_CATALOG_CAPABILITY), capability(TEMPLATE_CATALOG_CAPABILITY),
capability(TEMPLATE_RENDERER_CAPABILITY), capability(TEMPLATE_RENDERER_CAPABILITY),
capability(TEMPLATE_CONTENT_LIBRARY_CAPABILITY),
) )
+24
View File
@@ -13,6 +13,7 @@ from govoplan_core.core.campaigns import (
CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT, CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT,
CAPABILITY_CAMPAIGNS_POLICY_CONTEXT, CAPABILITY_CAMPAIGNS_POLICY_CONTEXT,
CAPABILITY_CAMPAIGNS_RETENTION, CAPABILITY_CAMPAIGNS_RETENTION,
CAPABILITY_CAMPAIGNS_SCHEDULES,
) )
from govoplan_core.core.calendar import CAPABILITY_CALENDAR_INVITATIONS from govoplan_core.core.calendar import CAPABILITY_CALENDAR_INVITATIONS
from govoplan_core.core.module_guards import ( from govoplan_core.core.module_guards import (
@@ -43,6 +44,7 @@ from govoplan_core.core.distribution_lists import (
) )
from govoplan_core.core.templates import ( from govoplan_core.core.templates import (
CAPABILITY_TEMPLATE_CATALOG, CAPABILITY_TEMPLATE_CATALOG,
CAPABILITY_TEMPLATE_CONTENT_LIBRARY,
CAPABILITY_TEMPLATE_RENDERER, CAPABILITY_TEMPLATE_RENDERER,
) )
from govoplan_core.core.operations import OperationalCheckProviderRegistration from govoplan_core.core.operations import OperationalCheckProviderRegistration
@@ -108,6 +110,12 @@ PERMISSIONS = (
"Create campaigns or working versions from existing campaigns.", "Create campaigns or working versions from existing campaigns.",
"Campaigns", "Campaigns",
), ),
_permission(
"campaigns:campaign:schedule",
"Schedule campaign drafts",
"Prepare fresh campaign drafts at a governed time or bounded recurrence.",
"Campaigns",
),
_permission( _permission(
"campaigns:campaign:archive", "campaigns:campaign:archive",
"Archive campaigns", "Archive campaigns",
@@ -274,6 +282,7 @@ ROLE_TEMPLATES = (
"campaigns:campaign:create", "campaigns:campaign:create",
"campaigns:campaign:update", "campaigns:campaign:update",
"campaigns:campaign:copy", "campaigns:campaign:copy",
"campaigns:campaign:schedule",
"campaigns:campaign:validate", "campaigns:campaign:validate",
"campaigns:campaign:build", "campaigns:campaign:build",
"campaigns:ownership:accept_group", "campaigns:ownership:accept_group",
@@ -385,6 +394,7 @@ manifest = ModuleManifest(
provides_interfaces=( provides_interfaces=(
ModuleInterfaceProvider(name="campaigns.access", version="0.1.6"), ModuleInterfaceProvider(name="campaigns.access", version="0.1.6"),
ModuleInterfaceProvider(name="campaigns.delivery_tasks", 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.mail_policy_context", version="0.1.6"),
ModuleInterfaceProvider(name="campaigns.policy_context", version="0.1.6"), ModuleInterfaceProvider(name="campaigns.policy_context", version="0.1.6"),
ModuleInterfaceProvider(name="campaigns.retention", version="0.1.6"), ModuleInterfaceProvider(name="campaigns.retention", version="0.1.6"),
@@ -454,6 +464,12 @@ manifest = ModuleManifest(
version_max_exclusive="0.2.0", version_max_exclusive="0.2.0",
optional=True, optional=True,
), ),
ModuleInterfaceRequirement(
name=CAPABILITY_TEMPLATE_CONTENT_LIBRARY,
version_min="0.1.18",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement( ModuleInterfaceRequirement(
name=CAPABILITY_CALENDAR_INVITATIONS, name=CAPABILITY_CALENDAR_INVITATIONS,
version_min="0.2.0", version_min="0.2.0",
@@ -577,6 +593,8 @@ manifest = ModuleManifest(
retirement_supported=True, retirement_supported=True,
retirement_provider=drop_table_retirement_provider( retirement_provider=drop_table_retirement_provider(
campaign_models.Campaign, campaign_models.Campaign,
campaign_models.CampaignSchedule,
campaign_models.CampaignScheduleOccurrence,
campaign_models.CampaignShare, campaign_models.CampaignShare,
campaign_models.RecipientImportMappingProfile, campaign_models.RecipientImportMappingProfile,
campaign_models.CampaignVersion, campaign_models.CampaignVersion,
@@ -597,6 +615,8 @@ manifest = ModuleManifest(
uninstall_guard_providers=( uninstall_guard_providers=(
persistent_table_uninstall_guard( persistent_table_uninstall_guard(
campaign_models.Campaign, campaign_models.Campaign,
campaign_models.CampaignSchedule,
campaign_models.CampaignScheduleOccurrence,
campaign_models.CampaignShare, campaign_models.CampaignShare,
campaign_models.RecipientImportMappingProfile, campaign_models.RecipientImportMappingProfile,
campaign_models.CampaignVersion, campaign_models.CampaignVersion,
@@ -1149,6 +1169,10 @@ manifest = ModuleManifest(
"govoplan_campaign.backend.capabilities", "govoplan_campaign.backend.capabilities",
fromlist=["delivery_tasks_capability"], fromlist=["delivery_tasks_capability"],
).delivery_tasks_capability(context), ).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__( CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT: lambda context: __import__(
"govoplan_campaign.backend.capabilities", "govoplan_campaign.backend.capabilities",
fromlist=["mail_policy_context_capability"], fromlist=["mail_policy_context_capability"],
+195 -12
View File
@@ -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.field_values import ignored_entry_field_overrides
from govoplan_campaign.backend.campaign.models import ( from govoplan_campaign.backend.campaign.models import (
Behavior, Behavior,
AttachmentConfig,
BuildStatus, BuildStatus,
CampaignConfig, CampaignConfig,
EntryConfig, EntryConfig,
MissingAddressBehavior, MissingAddressBehavior,
RecipientConfig, RecipientConfig,
ResidualFileMode,
SendStatus, SendStatus,
TemplateBodyMode, TemplateBodyMode,
ZipArchiveConfig, ZipArchiveConfig,
@@ -92,6 +94,13 @@ class _MimeBuildResult:
attachment_count: int 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: def _resolve(campaign_file: str | Path, raw_path: str) -> Path:
campaign_path = Path(campaign_file).resolve() campaign_path = Path(campaign_file).resolve()
path = Path(raw_path).expanduser() path = Path(raw_path).expanduser()
@@ -890,17 +899,13 @@ def build_entry_message(
def _unsent_attachment_issues( def _residual_attachment_files(
*, *,
config: CampaignConfig, config: CampaignConfig,
campaign_file: str | Path, campaign_file: str | Path,
built_messages: list[BuiltMessage], built_messages: list[BuiltMessage],
attachment_match_index: AttachmentMatchIndex | None = None, attachment_match_index: AttachmentMatchIndex | None = None,
) -> list[MessageIssue]: ) -> list[_ResidualFileGroup]:
behavior = config.validation_policy.unsent_attachment_files.value
if behavior == Behavior.CONTINUE.value:
return []
matched_files = { matched_files = {
Path(match).resolve() Path(match).resolve()
for built in built_messages for built in built_messages
@@ -908,7 +913,7 @@ def _unsent_attachment_issues(
for match in attachment.matches for match in attachment.matches
} }
issues: list[MessageIssue] = [] groups: list[_ResidualFileGroup] = []
for base_path in config.attachments.base_paths: for base_path in config.attachments.base_paths:
if not base_path.unsent_warning: if not base_path.unsent_warning:
continue continue
@@ -922,20 +927,182 @@ def _unsent_attachment_issues(
unsent = [path for path in all_files if path not in matched_files] unsent = [path for path in all_files if path not in matched_files]
if not unsent: if not unsent:
continue 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]) shown = ", ".join(str(path.relative_to(directory)) for path in unsent[:10])
if len(unsent) > 10: if len(unsent) > 10:
shown += f", … (+{len(unsent) - 10} more)" shown += f", … (+{len(unsent) - 10} more)"
issues.append( issues.append(
_issue_from_behavior( _issue_from_behavior(
code="unsent_attachment_files", 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, behavior=behavior,
source=f"attachments:{base_path.name}", source=f"attachments:{group.source_name}",
) )
) )
return issues 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: def _apply_campaign_level_issues(built_messages: list[BuiltMessage], issues: list[MessageIssue]) -> None:
if not issues: if not issues:
return return
@@ -978,15 +1145,31 @@ def build_campaign_messages(
for index, entry in enumerate(entries, start=1) for index, entry in enumerate(entries, start=1)
if entry.active if entry.active
] ]
_apply_campaign_level_issues( residual_groups = _residual_attachment_files(
built_messages,
_unsent_attachment_issues(
config=config, config=config,
campaign_file=campaign_path, campaign_file=campaign_path,
built_messages=built_messages, built_messages=built_messages,
attachment_match_index=attachment_match_index, attachment_match_index=attachment_match_index,
)
_apply_campaign_level_issues(
built_messages,
_unsent_attachment_issues(
config=config,
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) rules_resolved = sum(len(built.draft.attachments) for built in built_messages)
report = CampaignBuildReport( report = CampaignBuildReport(
@@ -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()
@@ -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()
@@ -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")
@@ -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")
+2
View File
@@ -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.jobs import router as jobs_router
from govoplan_campaign.backend.routes.operations import router as operations_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.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.sharing import router as sharing_router
from govoplan_campaign.backend.routes.versions import router as versions_router from govoplan_campaign.backend.routes.versions import router as versions_router
@@ -19,6 +20,7 @@ for workflow_router in (
versions_router, versions_router,
jobs_router, jobs_router,
reports_router, reports_router,
schedules_router,
sharing_router, sharing_router,
delivery_router, delivery_router,
attachments_router, attachments_router,
@@ -14,6 +14,7 @@ from govoplan_campaign.backend.schemas import (
CampaignCreateResponse, CampaignCreateResponse,
CampaignCreateMinimalRequest, CampaignCreateMinimalRequest,
CampaignCopyRequest, CampaignCopyRequest,
CampaignContentLibrarySaveRequest,
CampaignLifecycleMutationRequest, CampaignLifecycleMutationRequest,
CampaignLifecyclePolicyResponse, CampaignLifecyclePolicyResponse,
CampaignAddressLookupCandidate, CampaignAddressLookupCandidate,
@@ -42,6 +43,7 @@ from govoplan_campaign.backend.schemas import (
) )
from govoplan_core.auth import ApiPrincipal, has_scope, require_scope from govoplan_core.auth import ApiPrincipal, has_scope, require_scope
from govoplan_core.audit.logging import audit_from_principal from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.core.templates import TemplateContentDraftRequest, TemplateRef
from govoplan_core.core.change_sequence import ( from govoplan_core.core.change_sequence import (
decode_sequence_watermark, decode_sequence_watermark,
encode_sequence_watermark, encode_sequence_watermark,
@@ -60,6 +62,7 @@ from govoplan_campaign.backend.change_tracking import (
) )
from govoplan_campaign.backend.db.models import ( from govoplan_campaign.backend.db.models import (
Campaign, Campaign,
CampaignShare,
CampaignVersion, CampaignVersion,
RecipientImportMappingProfile, RecipientImportMappingProfile,
) )
@@ -70,6 +73,7 @@ from govoplan_campaign.backend.campaign.lifecycle import (
assert_lifecycle_state_token, assert_lifecycle_state_token,
campaign_lifecycle_policy, campaign_lifecycle_policy,
) )
from govoplan_campaign.backend.campaign.copying import campaign_copy_configuration
from govoplan_campaign.backend.integrations import ( from govoplan_campaign.backend.integrations import (
calendar_integration, calendar_integration,
PostboxDeliveryUnavailable, 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) @router.post("", response_model=CampaignCreateResponse)
def create_campaign( def create_campaign(
payload: CampaignCreateRequest, 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( @router.post(
"/{campaign_id}/recipient-address-sources/snapshot", "/{campaign_id}/recipient-address-sources/snapshot",
response_model=CampaignRecipientAddressSourceSnapshotResponse, response_model=CampaignRecipientAddressSourceSnapshotResponse,
@@ -1652,7 +1849,10 @@ def copy_campaign(
action="copy_campaign", action="copy_campaign",
version_id=payload.source_version_id, version_id=payload.source_version_id,
) )
if payload.include_recipients:
_require_permission(principal, "campaigns:recipient:read") _require_permission(principal, "campaigns:recipient:read")
if payload.include_shares:
_require_permission(principal, "campaigns:campaign:share")
source_version = ( source_version = (
session.query(CampaignVersion) session.query(CampaignVersion)
.filter( .filter(
@@ -1674,7 +1874,7 @@ def copy_campaign(
requested=payload.external_id, requested=payload.external_id,
) )
name = (payload.name or f"{source_campaign.name} (copy)").strip() 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") campaign_metadata = raw_json.get("campaign")
if not isinstance(campaign_metadata, dict): if not isinstance(campaign_metadata, dict):
raise HTTPException( raise HTTPException(
@@ -1696,6 +1896,36 @@ def copy_campaign(
source_base_path=source_version.source_base_path, source_base_path=source_version.source_base_path,
commit=False, 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( audit_from_principal(
session, session,
principal, principal,
@@ -1707,6 +1937,14 @@ def copy_campaign(
"source_version_id": source_version.id, "source_version_id": source_version.id,
"destination_version_id": version.id, "destination_version_id": version.id,
"copied_evidence": False, "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, 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" "warn"
], ],
"default": "ask" "default": "ask"
},
"residual_files": {
"$ref": "#/$defs/residual_file_disposition"
} }
}, },
"additionalProperties": false, "additionalProperties": false,
@@ -1455,6 +1458,34 @@
}, },
"additionalProperties": false "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": { "zip_config": {
"type": "object", "type": "object",
"properties": { "properties": {
+120
View File
@@ -52,6 +52,126 @@ class CampaignCopyRequest(CampaignLifecycleMutationRequest):
source_version_id: str = Field(min_length=1, max_length=36) source_version_id: str = Field(min_length=1, max_length=36)
external_id: str | None = Field(default=None, min_length=1, max_length=255) 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) 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): class CampaignCreateMinimalRequest(BaseModel):
+2 -1
View File
@@ -240,6 +240,7 @@ def test_process_loss_orphan_is_deleted_once_and_same_request_replays(
def test_competing_node_cannot_acquire_cleanup_authority( def test_competing_node_cannot_acquire_cleanup_authority(
recovery_session_factory, recovery_session_factory,
) -> None: ) -> None:
lease_observed_at = datetime.now(timezone.utc)
with recovery_session_factory() as session: with recovery_session_factory() as session:
claim = acquire_lease( claim = acquire_lease(
session, session,
@@ -248,7 +249,7 @@ def test_competing_node_cannot_acquire_cleanup_authority(
holder_node_id="node-other", holder_node_id="node-other",
holder_incarnation="run-other", holder_incarnation="run-other",
ttl_seconds=900, ttl_seconds=900,
now=NOW, now=lease_observed_at,
) )
assert claim is not None assert claim is not None
session.commit() session.commit()
+86
View File
@@ -275,6 +275,92 @@ class CampaignAttachmentBuildTests(unittest.TestCase):
self.assertEqual(archive.namelist(), ["matched.xlsx"]) self.assertEqual(archive.namelist(), ["matched.xlsx"])
self.assertEqual(archive.read("matched.xlsx"), b"matched workbook") 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+125
View File
@@ -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")
+128 -1
View File
@@ -13,10 +13,12 @@ from govoplan_campaign.backend.campaign.lifecycle import campaign_lifecycle_poli
from govoplan_campaign.backend.db.models import ( from govoplan_campaign.backend.db.models import (
Campaign, Campaign,
CampaignJob, CampaignJob,
CampaignSchedule,
CampaignShare, CampaignShare,
CampaignVersion, CampaignVersion,
) )
from govoplan_campaign.backend.routes.campaigns import ( from govoplan_campaign.backend.routes.campaigns import (
_campaign_copy_configuration,
archive_campaign_version, archive_campaign_version,
copy_campaign, copy_campaign,
delete_draft_campaign, delete_draft_campaign,
@@ -26,6 +28,7 @@ from govoplan_campaign.backend.schemas import (
CampaignLifecycleMutationRequest, CampaignLifecycleMutationRequest,
) )
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_core.core.change_sequence import ChangeSequenceEntry
class _Principal: class _Principal:
@@ -65,6 +68,8 @@ class CampaignLifecycleTests(unittest.TestCase):
CampaignVersion.__table__, CampaignVersion.__table__,
CampaignShare.__table__, CampaignShare.__table__,
CampaignJob.__table__, CampaignJob.__table__,
CampaignSchedule.__table__,
ChangeSequenceEntry.__table__,
], ],
) )
self.SessionLocal = sessionmaker( self.SessionLocal = sessionmaker(
@@ -73,7 +78,14 @@ class CampaignLifecycleTests(unittest.TestCase):
expire_on_commit=False, expire_on_commit=False,
) )
with self.SessionLocal() as session: 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( campaign = Campaign(
id="campaign-1", id="campaign-1",
tenant_id="tenant-1", tenant_id="tenant-1",
@@ -153,6 +165,50 @@ class CampaignLifecycleTests(unittest.TestCase):
self.assertFalse(policy["actions"]["archive_campaign"]["allowed"]) self.assertFalse(policy["actions"]["archive_campaign"]["allowed"])
self.assertIn("Active or uncertain", policy["actions"]["archive_campaign"]["reason"]) 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: def test_stale_delete_token_is_rejected(self) -> None:
with self.SessionLocal() as session: with self.SessionLocal() as session:
policy = self._policy(session) policy = self._policy(session)
@@ -203,6 +259,10 @@ class CampaignLifecycleTests(unittest.TestCase):
def test_whole_campaign_copy_starts_without_operational_evidence(self) -> None: def test_whole_campaign_copy_starts_without_operational_evidence(self) -> None:
with self.SessionLocal() as session: 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( session.add_all(
( (
CampaignShare( CampaignShare(
@@ -269,6 +329,8 @@ class CampaignLifecycleTests(unittest.TestCase):
CampaignCopyRequest( CampaignCopyRequest(
source_version_id="version-2", source_version_id="version-2",
expected_state_token=policy["state_token"], expected_state_token=policy["state_token"],
include_policies=False,
include_mail_profile=True,
), ),
session=session, session=session,
principal=self.principal, principal=self.principal,
@@ -277,6 +339,13 @@ class CampaignLifecycleTests(unittest.TestCase):
self.assertEqual(response.campaign.external_id, "campaign-1-copy") self.assertEqual(response.campaign.external_id, "campaign-1-copy")
self.assertEqual(response.campaign.owner_user_id, "user-1") self.assertEqual(response.campaign.owner_user_id, "user-1")
self.assertEqual(captured["raw_json"]["campaign"]["mode"], "draft") 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( self.assertEqual(
session.query(CampaignJob) session.query(CampaignJob)
.filter(CampaignJob.campaign_id == "campaign-copy") .filter(CampaignJob.campaign_id == "campaign-copy")
@@ -290,6 +359,64 @@ class CampaignLifecycleTests(unittest.TestCase):
0, 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+240
View File
@@ -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
+3
View File
@@ -416,7 +416,10 @@ def test_static_campaign_handbook_has_unique_ids_help_contexts_and_no_planned_re
"campaign.settings", "campaign.settings",
"campaign.fields", "campaign.fields",
"campaign.template", "campaign.template",
"campaign.template.content-library",
"campaigns.action.schedule-drafts",
"campaign.attachments", "campaign.attachments",
"campaign.attachments.residual-files",
"campaign.recipients", "campaign.recipients",
"campaign.recipient-data", "campaign.recipient-data",
"campaign.server-settings", "campaign.server-settings",
+3 -1
View File
@@ -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} assert "/operator" not in {item.path for item in manifest.frontend.nav_items}
routes = {route.path: route for route in manifest.frontend.routes} 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"] queue = routes["/campaigns/queue"]
assert queue.component == "OperatorQueuePage" assert queue.component == "OperatorQueuePage"
assert queue.required_all == ("campaigns:campaign:read",) assert queue.required_all == ("campaigns:campaign:read",)
+4 -1
View File
@@ -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.jobs import router as jobs_router
from govoplan_campaign.backend.routes.operations import router as operations_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.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.sharing import router as sharing_router
from govoplan_campaign.backend.routes.versions import router as versions_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, versions_router,
jobs_router, jobs_router,
reports_router, reports_router,
schedules_router,
sharing_router, sharing_router,
delivery_router, delivery_router,
attachments_router, attachments_router,
@@ -40,7 +42,7 @@ def test_campaign_router_composes_every_workflow_operation_once() -> None:
actual = _operation_keys(router) actual = _operation_keys(router)
assert actual == expected assert actual == expected
assert len(actual) == 72 assert len(actual) == 80
assert not [operation for operation, count in Counter(actual).items() if count > 1] 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")), (versions_router, ("POST", "/campaigns/versions/{version_id}/build")),
(jobs_router, ("GET", "/campaigns/{campaign_id}/jobs")), (jobs_router, ("GET", "/campaigns/{campaign_id}/jobs")),
(reports_router, ("GET", "/campaigns/{campaign_id}/report")), (reports_router, ("GET", "/campaigns/{campaign_id}/report")),
(schedules_router, ("GET", "/campaigns/{campaign_id}/schedules")),
(sharing_router, ("POST", "/campaigns/{campaign_id}/shares")), (sharing_router, ("POST", "/campaigns/{campaign_id}/shares")),
(delivery_router, ("POST", "/campaigns/{campaign_id}/send-now")), (delivery_router, ("POST", "/campaigns/{campaign_id}/send-now")),
( (
+2
View File
@@ -20,6 +20,7 @@ from govoplan_core.core.search import (
SearchBackfillRequest, SearchBackfillRequest,
SearchResourceReference, SearchResourceReference,
) )
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
@@ -34,6 +35,7 @@ class CampaignSearchSourceTests(unittest.TestCase):
Group.__table__, Group.__table__,
Campaign.__table__, Campaign.__table__,
CampaignShare.__table__, CampaignShare.__table__,
ChangeSequenceEntry.__table__,
), ),
) )
self.session = Session(self.engine) self.session = Session(self.engine)
+162 -2
View File
@@ -102,6 +102,66 @@ export type CampaignLifecyclePolicy = {
provenance: Record<string, unknown>; 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 & { export type CampaignVersionDetail = CampaignVersionListItem & {
raw_json: Record<string, unknown>; raw_json: Record<string, unknown>;
campaign_json?: Record<string, unknown>; campaign_json?: Record<string, unknown>;
@@ -431,6 +491,50 @@ export type CampaignPrintTemplatesResponse = {
templates: CampaignPrintTemplate[]; 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 = { export type CampaignRecipientSnapshotItem = {
contact_id: string; contact_id: string;
display_name: string; display_name: string;
@@ -991,6 +1095,26 @@ query = "")
return apiFetch<CampaignPrintTemplatesResponse>(settings, `/api/v1/campaigns/${campaignId}/print-templates${suffix}`); 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( export async function snapshotCampaignRecipientAddressSource(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
@@ -1052,17 +1176,53 @@ export async function copyCampaign(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
sourceVersionId: string, sourceVersionId: string,
expectedStateToken: string) expectedStateToken: string,
options: CampaignCopyOptions)
: Promise<CampaignCreateResponse> { : Promise<CampaignCreateResponse> {
return apiFetch<CampaignCreateResponse>(settings, `/api/v1/campaigns/${campaignId}/copies`, { return apiFetch<CampaignCreateResponse>(settings, `/api/v1/campaigns/${campaignId}/copies`, {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
source_version_id: sourceVersionId, 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( export async function archiveCampaignVersion(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
@@ -7,6 +7,7 @@ import { Card } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui"; import { PageTitle } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui"; import { LoadingFrame } from "@govoplan/core-webui";
import { MetricCard } from "@govoplan/core-webui"; import { MetricCard } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import LockedVersionNotice from "./components/LockedVersionNotice"; import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine"; import VersionLine from "./components/VersionLine";
import { ToggleSwitch } from "@govoplan/core-webui"; 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 basePaths = useMemo(() => normalizeAttachmentBasePaths(attachments.base_paths, attachments), [attachments]);
const globalRules = useMemo(() => normalizeAttachmentRules(attachments.global), [attachments.global]); const globalRules = useMemo(() => normalizeAttachmentRules(attachments.global), [attachments.global]);
const zipConfig = useMemo(() => normalizeAttachmentZipCollection(attachments.zip), [attachments.zip]); 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 filenameFieldOptions = useMemo(() => buildZipFilenameFieldOptions(displayDraft), [displayDraft]);
const passwordFields = useMemo(() => getDraftFields(displayDraft).filter((field) => field.type === "password"), [displayDraft]); const passwordFields = useMemo(() => getDraftFields(displayDraft).filter((field) => field.type === "password"), [displayDraft]);
const zipArchiveNameValidation = useMemo( const zipArchiveNameValidation = useMemo(
@@ -96,6 +100,18 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
markDirty(); 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>) { function patchBasePath(index: number, patch: Partial<AttachmentBasePath>) {
patchBasePaths(basePaths.map((basePath, currentIndex) => currentIndex === index ? { ...basePath, ...patch } : basePath)); patchBasePaths(basePaths.map((basePath, currentIndex) => currentIndex === index ? { ...basePath, ...patch } : basePath));
} }
@@ -271,6 +287,48 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
</div> </div>
</Card> </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> <Card title="i18n:govoplan-campaign.zip_attachments.6b58ed68" collapsible>
<div className="attachment-zip-master-toggle"> <div className="attachment-zip-master-toggle">
<ToggleSwitch <ToggleSwitch
@@ -1,10 +1,11 @@
import { useEffect, useMemo, useState } from "react"; 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 { Link } from "react-router";
import type { ApiSettings, AuthInfo } from "../../types"; import type { ApiSettings, AuthInfo } from "../../types";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui"; import { FormField } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui"; import { LoadingFrame } from "@govoplan/core-webui";
import { MetricCard } from "@govoplan/core-webui"; import { MetricCard } from "@govoplan/core-webui";
@@ -17,13 +18,19 @@ import {
archiveCampaign, archiveCampaign,
archiveCampaignVersion, archiveCampaignVersion,
copyCampaign, copyCampaign,
createCampaignSchedule,
deleteCampaign, deleteCampaign,
getCampaignLifecyclePolicy, getCampaignLifecyclePolicy,
lockCampaignVersionPermanently, lockCampaignVersionPermanently,
lockCampaignVersionTemporarily, lockCampaignVersionTemporarily,
unlockCampaignVersionUserLock, unlockCampaignVersionUserLock,
listCampaignSchedules,
setCampaignScheduleState,
updateCampaignMetadata, updateCampaignMetadata,
type CampaignSchedule,
type CampaignScheduleCreate,
type CampaignLifecyclePolicy, type CampaignLifecyclePolicy,
type CampaignCopyOptions,
type CampaignVersionDetail, type CampaignVersionDetail,
type CampaignVersionListItem } from type CampaignVersionListItem } from
"../../api/campaigns"; "../../api/campaigns";
@@ -51,6 +58,34 @@ type PendingLifecycleAction = {
policy: CampaignLifecyclePolicy; policy: CampaignLifecyclePolicy;
version?: CampaignVersionListItem; version?: CampaignVersionListItem;
} | null; } | 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;}) { export default function CampaignOverviewPage({ settings, auth, campaignId }: {settings: ApiSettings;auth: AuthInfo;campaignId: string;}) {
const navigate = useGuardedNavigate(); const navigate = useGuardedNavigate();
@@ -65,12 +100,18 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
const [pendingLockAction, setPendingLockAction] = useState<PendingLockAction>(null); const [pendingLockAction, setPendingLockAction] = useState<PendingLockAction>(null);
const [lockBusy, setLockBusy] = useState(false); const [lockBusy, setLockBusy] = useState(false);
const [pendingLifecycleAction, setPendingLifecycleAction] = useState<PendingLifecycleAction>(null); const [pendingLifecycleAction, setPendingLifecycleAction] = useState<PendingLifecycleAction>(null);
const [copyOptions, setCopyOptions] = useState<CampaignCopyOptions>(defaultCopyOptions);
const [lifecycleBusy, setLifecycleBusy] = useState(false); const [lifecycleBusy, setLifecycleBusy] = useState(false);
const [message, setMessage] = useState(""); 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 versionMetrics = useMemo(() => campaignVersionMetrics(data.currentVersion), [data.currentVersion]);
const canArchive = Boolean(campaign) && campaign?.status !== "archived" && hasScope(auth, "campaigns:campaign:archive"); 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 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({ useUnsavedDraftGuard({
dirty: identityDirty, dirty: identityDirty,
@@ -97,6 +138,20 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
}); });
}, [campaign, identityDirty]); }, [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) { function patchIdentity(key: keyof typeof identity, value: string) {
setIdentity((current) => ({ ...current, [key]: value })); setIdentity((current) => ({ ...current, [key]: value }));
setIdentityDirty(true); 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."); setError(decision?.reason || "This lifecycle action is not available for the current campaign state.");
return; return;
} }
if (action === "copy_campaign") {
setCopyOptions({
...defaultCopyOptions,
name: `${campaign.name} (copy)`,
include_recipients: hasScope(auth, "campaigns:recipient:read")
});
}
setPendingLifecycleAction({ action, policy, version }); setPendingLifecycleAction({ action, policy, version });
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
@@ -201,7 +263,7 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
navigate("/campaigns"); navigate("/campaigns");
return; return;
} else if (pending.action === "copy_campaign" && pending.version) { } 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); setPendingLifecycleAction(null);
navigate(`/campaigns/${created.campaign.id}`); navigate(`/campaigns/${created.campaign.id}`);
return; 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 ( return (
<div className="content-pad workspace-data-page"> <div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading"> <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 size={16} aria-hidden="true" />
Copy campaign Copy campaign
</Button>} </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 {canDelete && <Button
variant="danger" variant="danger"
onClick={() => void prepareLifecycleAction("delete_campaign")} onClick={() => void prepareLifecycleAction("delete_campaign")}
@@ -287,6 +407,37 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
</div> </div>
</Card> </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"> <Card title="Versions" collapsible actions={<div className="button-row compact-actions">
{archivedVersionCount > 0 && <ToggleSwitch {archivedVersionCount > 0 && <ToggleSwitch
label={`Show archived (${archivedVersionCount})`} label={`Show archived (${archivedVersionCount})`}
@@ -335,7 +486,7 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
</LoadingFrame> </LoadingFrame>
<ConfirmDialog <ConfirmDialog
open={Boolean(pendingLifecycleAction)} open={Boolean(pendingLifecycleAction && pendingLifecycleAction.action !== "copy_campaign")}
title={lifecycleDialogTitle(pendingLifecycleAction)} title={lifecycleDialogTitle(pendingLifecycleAction)}
message={lifecycleDialogMessage(pendingLifecycleAction)} message={lifecycleDialogMessage(pendingLifecycleAction)}
confirmLabel={lifecycleDialogLabel(pendingLifecycleAction)} confirmLabel={lifecycleDialogLabel(pendingLifecycleAction)}
@@ -344,6 +495,119 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
onCancel={() => setPendingLifecycleAction(null)} onCancel={() => setPendingLifecycleAction(null)}
onConfirm={() => void applyLifecycleAction()} /> 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 <ConfirmDialog
open={Boolean(pendingLockAction)} open={Boolean(pendingLockAction)}
title={lockDialogTitle(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 TemplateHealthTone = "neutral" | "good" | "warning" | "danger" | "info";
type CampaignVersionMetrics = { type CampaignVersionMetrics = {
@@ -1,13 +1,19 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import type { ApiSettings } from "../../types"; import type { ApiSettings } from "../../types";
import { import {
listCampaignContentLibrary,
listCampaignPrintTemplates, listCampaignPrintTemplates,
previewCampaignAttachments, previewCampaignAttachments,
saveCampaignContentLibraryItem,
type CampaignAttachmentPreviewRule, type CampaignAttachmentPreviewRule,
type CampaignContentLibraryItem,
type CampaignContentLibraryResponse,
type CampaignContentLibraryTarget,
type CampaignPrintTemplate type CampaignPrintTemplate
} from "../../api/campaigns"; } from "../../api/campaigns";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui"; import { FormField } from "@govoplan/core-webui";
import { FieldLabel } from "@govoplan/core-webui"; import { FieldLabel } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui"; import { PageTitle } from "@govoplan/core-webui";
@@ -29,6 +35,7 @@ import { buildTemplatePreviewContext, buildUndefinedPlaceholders, extractTemplat
type TemplateBodyMode = "text" | "html" | "both"; type TemplateBodyMode = "text" | "html" | "both";
type BodyEditorMode = "text" | "html"; type BodyEditorMode = "text" | "html";
type EditorTarget = "subject" | "text" | "html"; type EditorTarget = "subject" | "text" | "html";
type ContentLibrarySaveKind = "fragment" | "campaign_part";
export default function TemplateDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) { export default function TemplateDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId); 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 [printTemplates, setPrintTemplates] = useState<CampaignPrintTemplate[]>([]);
const [printTemplatesLoading, setPrintTemplatesLoading] = useState(true); const [printTemplatesLoading, setPrintTemplatesLoading] = useState(true);
const [printTemplatesError, setPrintTemplatesError] = useState(""); 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 subjectRef = useRef<HTMLInputElement | null>(null);
const textRef = useRef<HTMLTextAreaElement | null>(null); const textRef = useRef<HTMLTextAreaElement | null>(null);
const htmlRef = useRef<WysiwygEditorHandle | 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 printConfig = asRecord(delivery.print);
const selectedPrintTemplate = printTemplates.find((item) => item.id === getText(printConfig, "template_id")) ?? null; const selectedPrintTemplate = printTemplates.find((item) => item.id === getText(printConfig, "template_id")) ?? null;
const templateBodyMode = normalizeTemplateBodyMode(getText(template, "body_mode", "both")); 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 visibleBodyEditor: BodyEditorMode = templateBodyMode === "html" ? "html" : templateBodyMode === "text" ? "text" : activeBodyEditor;
const fields = useMemo(() => asArray(displayDraft.fields).map(asRecord), [displayDraft.fields]); const fields = useMemo(() => asArray(displayDraft.fields).map(asRecord), [displayDraft.fields]);
const localFieldNames = useMemo(() => fields.map((field) => String(field.name || field.id || "")).filter(Boolean), [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; }; return () => { cancelled = true; };
}, [campaignId, settings.apiBaseUrl, settings.apiKey, settings.accessToken]); }, [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) { function patchTemplateText(target: EditorTarget, value: string) {
patch(["template", target], value); patch(["template", target], value);
@@ -280,6 +328,87 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
setUndefinedDialog(null); 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 ( return (
<div className="content-pad workspace-data-page"> <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} /> <VersionLine version={version} versions={data.versions} status={saveState} />
</div> </div>
<div className="button-row compact-actions"> <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 onClick={() => void discardDraft()} disabled={loading}>Discard</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>i18n:govoplan-campaign.save.efc007a3</Button> <Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>i18n:govoplan-campaign.save.efc007a3</Button>
</div> </div>
@@ -297,6 +426,7 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</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" />} {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"> <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>
} }
<div className="button-row template-editor-actions"> <div className="button-row template-editor-actions">
<Button disabled>i18n:govoplan-campaign.load_from_library.327ada7c</Button> <Button onClick={() => setContentLibraryOpen(true)} disabled={locked}>i18n:govoplan-campaign.load_from_library.327ada7c</Button>
<Button disabled>i18n:govoplan-campaign.save_to_library.396649bf</Button> <Button onClick={openContentSaveDialog} disabled={locked}>i18n:govoplan-campaign.save_to_library.396649bf</Button>
</div> </div>
</div> </div>
</Card> </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 <UndefinedPlaceholderDecisionDialog
field={undefinedDialog} field={undefinedDialog}
contextLabel="template" contextLabel="template"
@@ -29,6 +29,12 @@ export function ensureCampaignDraft(version: CampaignVersionDetail | null): Reco
send_without_attachments: true, send_without_attachments: true,
send_without_attachments_behavior: "continue", send_without_attachments_behavior: "continue",
global: [], 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", missing_behavior: "warn",
ambiguous_behavior: "ask", ambiguous_behavior: "ask",
...sourceAttachments ...sourceAttachments
+65
View File
@@ -2781,3 +2781,68 @@
animation: none; 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, /archive_campaign_confirmation/);
assert.match(overview, /await archiveCampaign\(settings, campaign\.id, pending\.policy\.state_token\)/); 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 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(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\}\/archive/);
assert.match(api, /\/api\/v1\/campaigns\/\$\{campaignId\}\/copies/); assert.match(api, /\/api\/v1\/campaigns\/\$\{campaignId\}\/copies/);