From c2efd6b7bdb8d5bbe743b6e101fd9dd7efa62529 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 7 Aug 2026 14:54:04 +0200 Subject: [PATCH] feat: add campaign copying scheduling and residual handling --- .../backend/campaign/copying.py | 44 +++ .../backend/campaign/lifecycle.py | 52 ++- .../backend/campaign/models.py | 27 ++ .../backend/campaign/scheduling.py | 363 ++++++++++++++++++ src/govoplan_campaign/backend/capabilities.py | 32 ++ src/govoplan_campaign/backend/db/models.py | 85 ++++ .../backend/documentation.py | 133 ++++++- src/govoplan_campaign/backend/integrations.py | 56 +++ src/govoplan_campaign/backend/manifest.py | 24 ++ .../backend/messages/builder.py | 207 +++++++++- ...5b6c7d8e9f0_campaign_schedule_revisions.py | 27 ++ .../f4a5b6c7d8e9_campaign_schedules.py | 27 ++ ...8e9f0_v0120_campaign_schedule_revisions.py | 40 ++ .../f4a5b6c7d8e9_v0119_campaign_schedules.py | 106 +++++ src/govoplan_campaign/backend/router.py | 2 + .../backend/routes/campaigns.py | 242 +++++++++++- .../backend/routes/schedules.py | 290 ++++++++++++++ .../backend/schema/campaign.schema.json | 31 ++ src/govoplan_campaign/backend/schemas.py | 120 ++++++ tests/test_artifact_reconciliation.py | 3 +- tests/test_attachment_building.py | 86 +++++ tests/test_campaign_content_library.py | 125 ++++++ tests/test_campaign_lifecycle.py | 129 ++++++- tests/test_campaign_scheduling.py | 240 ++++++++++++ tests/test_documentation.py | 3 + tests/test_manifest_navigation.py | 4 +- tests/test_route_registration.py | 5 +- tests/test_search_source.py | 2 + webui/src/api/campaigns.ts | 164 +++++++- .../campaigns/AttachmentsDataPage.tsx | 58 +++ .../campaigns/CampaignOverviewPage.tsx | 308 ++++++++++++++- .../features/campaigns/TemplateDataPage.tsx | 284 +++++++++++++- .../features/campaigns/utils/draftEditor.ts | 6 + webui/src/styles/campaign-workspace.css | 65 ++++ .../campaign-lifecycle-ui-structure.test.mjs | 8 +- 35 files changed, 3359 insertions(+), 39 deletions(-) create mode 100644 src/govoplan_campaign/backend/campaign/copying.py create mode 100644 src/govoplan_campaign/backend/campaign/scheduling.py create mode 100644 src/govoplan_campaign/backend/migrations/dev_versions/a5b6c7d8e9f0_campaign_schedule_revisions.py create mode 100644 src/govoplan_campaign/backend/migrations/dev_versions/f4a5b6c7d8e9_campaign_schedules.py create mode 100644 src/govoplan_campaign/backend/migrations/versions/a5b6c7d8e9f0_v0120_campaign_schedule_revisions.py create mode 100644 src/govoplan_campaign/backend/migrations/versions/f4a5b6c7d8e9_v0119_campaign_schedules.py create mode 100644 src/govoplan_campaign/backend/routes/schedules.py create mode 100644 tests/test_campaign_content_library.py create mode 100644 tests/test_campaign_scheduling.py diff --git a/src/govoplan_campaign/backend/campaign/copying.py b/src/govoplan_campaign/backend/campaign/copying.py new file mode 100644 index 0000000..0ae674e --- /dev/null +++ b/src/govoplan_campaign/backend/campaign/copying.py @@ -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"] diff --git a/src/govoplan_campaign/backend/campaign/lifecycle.py b/src/govoplan_campaign/backend/campaign/lifecycle.py index 4864ce5..adf036c 100644 --- a/src/govoplan_campaign/backend/campaign/lifecycle.py +++ b/src/govoplan_campaign/backend/campaign/lifecycle.py @@ -12,6 +12,7 @@ from sqlalchemy.orm import Session from govoplan_campaign.backend.db.models import ( Campaign, CampaignJob, + CampaignSchedule, CampaignShare, CampaignVersion, ) @@ -19,7 +20,7 @@ from govoplan_core.auth import ApiPrincipal, has_scope POLICY_ID = "campaign.lifecycle" -POLICY_VERSION = "1" +POLICY_VERSION = "2" _ACTIVE_QUEUE_STATES = {"queued", "sending"} _ACTIVE_SEND_STATES = {"queued", "claimed", "sending", "outcome_unknown"} @@ -104,6 +105,12 @@ def campaign_lifecycle_policy( .order_by(CampaignShare.id.asc()) .all() ) + schedules = ( + session.query(CampaignSchedule) + .filter(CampaignSchedule.campaign_id == campaign.id) + .order_by(CampaignSchedule.id.asc()) + .all() + ) selected_version = next( (version for version in versions if version.id == version_id), None, @@ -116,6 +123,10 @@ def campaign_lifecycle_policy( "id": campaign.id, "status": campaign.status, "current_version_id": campaign.current_version_id, + "settings_sha256": _canonical_hash(campaign.settings or {}), + "mail_profile_policy_sha256": _canonical_hash( + campaign.mail_profile_policy or {} + ), "updated_at": _timestamp(campaign.updated_at), }, "versions": [ @@ -129,6 +140,7 @@ def campaign_lifecycle_policy( "published_at": _timestamp(version.published_at), "execution_snapshot_at": _timestamp(version.execution_snapshot_at), "archived_at": _timestamp(version.archived_at), + "configuration_sha256": _canonical_hash(version.raw_json or {}), "updated_at": _timestamp(version.updated_at), } for version in versions @@ -145,13 +157,34 @@ def campaign_lifecycle_policy( } for job in jobs ], - "active_share_ids": [share.id for share in shares], + "active_shares": [ + { + "id": share.id, + "target_type": share.target_type, + "target_id": share.target_id, + "permission": share.permission, + "updated_at": _timestamp(share.updated_at), + } + for share in shares + ], + "schedules": [ + { + "id": schedule.id, + "active": schedule.active, + "resource_revision": schedule.resource_revision, + "next_fire_at": _timestamp(schedule.next_fire_at), + "occurrence_count": schedule.occurrence_count, + "updated_at": _timestamp(schedule.updated_at), + } + for schedule in schedules + ], "selected_version_id": version_id, } token = _canonical_hash(snapshot) active_delivery = any(_active_delivery(job) for job in jobs) protected_versions = any(_protected_version(version) for version in versions) + active_schedules = any(schedule.active for schedule in schedules) archive = LifecycleDecision(True) if not has_scope(principal, "campaigns:campaign:archive"): @@ -163,6 +196,11 @@ def campaign_lifecycle_policy( False, "Active or uncertain delivery must be resolved before archiving.", ) + elif active_schedules: + archive = LifecycleDecision( + False, + "Pause active Campaign schedules before archiving.", + ) delete = LifecycleDecision(True) if not has_scope(principal, "campaigns:campaign:delete"): @@ -184,12 +222,15 @@ def campaign_lifecycle_policy( False, "Revoke active campaign shares before deleting the untouched draft.", ) + elif schedules: + delete = LifecycleDecision( + False, + "Campaigns with schedule evidence must be archived instead of deleted.", + ) copy = LifecycleDecision(True) if not has_scope(principal, "campaigns:campaign:copy"): copy = LifecycleDecision(False, "Missing campaign copy permission.") - elif not has_scope(principal, "campaigns:recipient:read"): - copy = LifecycleDecision(False, "Recipient read permission is required to copy a campaign.") elif version_id is not None and selected_version is None: copy = LifecycleDecision(False, "The selected source version does not exist.") @@ -220,9 +261,10 @@ def campaign_lifecycle_policy( "campaign_state", "retained_evidence", "active_delivery", + "scheduled_automation", "optimistic_concurrency", ), - "evidence_retention": "Versions, delivery outcomes, reports, and audit records are never deleted by archival.", + "evidence_retention": "Versions, schedule occurrences, delivery outcomes, reports, and audit records are never deleted by archival.", }, } diff --git a/src/govoplan_campaign/backend/campaign/models.py b/src/govoplan_campaign/backend/campaign/models.py index 3383397..564e131 100644 --- a/src/govoplan_campaign/backend/campaign/models.py +++ b/src/govoplan_campaign/backend/campaign/models.py @@ -468,6 +468,30 @@ class AttachmentBasePathConfig(StrictModel): source: str | None = None +class ResidualFileMode(StrEnum): + NONE = "none" + REPORT = "report" + ATTACH = "attach" + + +class ResidualFileDispositionConfig(StrictModel): + mode: ResidualFileMode = ResidualFileMode.NONE + recipient: RecipientConfig | None = None + subject: str = "Unassigned files in campaign {{local:campaign_name}}" + text: str = ( + "The campaign build found {{local:residual_file_count}} file(s) that " + "were not assigned to a recipient.\n\n{{local:residual_file_list}}" + ) + + @model_validator(mode="after") + def require_recipient_for_routing(self) -> "ResidualFileDispositionConfig": + if self.mode != ResidualFileMode.NONE and self.recipient is None: + raise ValueError( + "Residual-file report or attachment routing requires a recipient." + ) + return self + + class AttachmentConfig(StrictModel): id: str | None = None label: str | None = None @@ -509,6 +533,9 @@ class AttachmentsConfig(StrictModel): global_: list[AttachmentConfig] = Field(default_factory=list, alias="global") missing_behavior: Behavior = Behavior.WARN ambiguous_behavior: Behavior = Behavior.ASK + residual_files: ResidualFileDispositionConfig = Field( + default_factory=ResidualFileDispositionConfig + ) @model_validator(mode="after") def normalize_send_without_attachments_behavior(self) -> "AttachmentsConfig": diff --git a/src/govoplan_campaign/backend/campaign/scheduling.py b/src/govoplan_campaign/backend/campaign/scheduling.py new file mode 100644 index 0000000..6105e9c --- /dev/null +++ b/src/govoplan_campaign/backend/campaign/scheduling.py @@ -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", +] diff --git a/src/govoplan_campaign/backend/capabilities.py b/src/govoplan_campaign/backend/capabilities.py index ba36aee..74ddb4e 100644 --- a/src/govoplan_campaign/backend/capabilities.py +++ b/src/govoplan_campaign/backend/capabilities.py @@ -14,6 +14,7 @@ from govoplan_core.core.campaigns import ( CampaignPolicyContext, CampaignPolicyContextProvider, CampaignRetentionProvider, + CampaignScheduleProvider, ) from govoplan_core.core.ownership import ( OwnershipActionDecision, @@ -715,6 +716,37 @@ def delivery_tasks_capability(context: object) -> CampaignDeliveryTaskService: return CampaignDeliveryTaskService() +class CampaignScheduleService(CampaignScheduleProvider): + def dispatch_due( + self, + session: object, + *, + tenant_id: str | None = None, + now=None, + limit: int = 50, + ) -> Mapping[str, object]: + from govoplan_campaign.backend.campaign.scheduling import ( + dispatch_due_campaign_schedules, + ) + + return dispatch_due_campaign_schedules( + session, # type: ignore[arg-type] + tenant_id=tenant_id, + now=now, + limit=limit, + ) + + +def schedules_capability(context: object) -> CampaignScheduleService: + from govoplan_campaign.backend.runtime import configure_runtime + + configure_runtime( + registry=getattr(context, "registry", None), + settings=getattr(context, "settings", None), + ) + return CampaignScheduleService() + + class CampaignRetentionService(CampaignRetentionProvider): def apply_retention(self, session: object, *, dry_run, now, policy_for_campaign_id): from govoplan_campaign.backend.retention import apply_campaign_retention diff --git a/src/govoplan_campaign/backend/db/models.py b/src/govoplan_campaign/backend/db/models.py index 4922be4..c27d5f7 100644 --- a/src/govoplan_campaign/backend/db/models.py +++ b/src/govoplan_campaign/backend/db/models.py @@ -168,6 +168,91 @@ class CampaignShare(Base, TimestampMixin): revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) +class CampaignSchedule(Base, TimestampMixin): + __tablename__ = "campaign_schedules" + __table_args__ = ( + Index("ix_campaign_schedules_due", "tenant_id", "active", "next_fire_at"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + campaign_id: Mapped[str] = mapped_column( + ForeignKey("campaigns.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + source_version_id: Mapped[str] = mapped_column( + ForeignKey("campaign_versions.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + created_by_user_id: Mapped[str | None] = mapped_column( + ForeignKey("access_users.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + name: Mapped[str] = mapped_column(String(255), nullable=False) + recurrence_kind: Mapped[str] = mapped_column( + String(20), + default="once", + nullable=False, + index=True, + ) + interval_count: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + timezone: Mapped[str] = mapped_column(String(100), default="UTC", nullable=False) + starts_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + next_fire_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + ends_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + max_occurrences: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + occurrence_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True) + resource_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + copy_options: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + source_snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + source_snapshot_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + source_base_path: Mapped[str | None] = mapped_column(String(1000), nullable=True) + last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + last_campaign_id: Mapped[str | None] = mapped_column( + ForeignKey("campaigns.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + last_error: Mapped[str | None] = mapped_column(Text, nullable=True) + + +class CampaignScheduleOccurrence(Base, TimestampMixin): + __tablename__ = "campaign_schedule_occurrences" + __table_args__ = ( + UniqueConstraint( + "schedule_id", + "scheduled_for", + name="uq_campaign_schedule_occurrence", + ), + Index("ix_campaign_schedule_occurrences_schedule", "schedule_id", "scheduled_for"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + schedule_id: Mapped[str] = mapped_column( + ForeignKey("campaign_schedules.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + scheduled_for: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + status: Mapped[str] = mapped_column(String(30), default="preparing", nullable=False, index=True) + generated_campaign_id: Mapped[str | None] = mapped_column( + ForeignKey("campaigns.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + generated_version_id: Mapped[str | None] = mapped_column( + ForeignKey("campaign_versions.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + error: Mapped[str | None] = mapped_column(Text, nullable=True) + + class RecipientImportMappingProfile(Base, TimestampMixin): __tablename__ = "campaign_recipient_import_mapping_profiles" __table_args__ = ( diff --git a/src/govoplan_campaign/backend/documentation.py b/src/govoplan_campaign/backend/documentation.py index 9db4fbd..c5c2607 100644 --- a/src/govoplan_campaign/backend/documentation.py +++ b/src/govoplan_campaign/backend/documentation.py @@ -46,6 +46,7 @@ _ADDRESSES_SOURCE_INTEGRATION = "addresses.recipient_source" _DISTRIBUTION_LIST_SOURCE_INTEGRATION = "dist_lists.source" _DISTRIBUTION_LIST_EXPAND_INTEGRATION = "dist_lists.expand" _TEMPLATE_CATALOG_INTEGRATION = "templates.catalog" +_TEMPLATE_CONTENT_LIBRARY_INTEGRATION = "templates.content_library" _TEMPLATE_RENDERER_INTEGRATION = "templates.renderer" _CALENDAR_INVITATION_INTEGRATION = "calendar.invitations" _NOTIFICATIONS_INTEGRATION = "notifications.dispatch" @@ -73,6 +74,7 @@ def _workflow_topic( links: tuple[DocumentationLink, ...] = (DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),), related_modules: tuple[str, ...] = (), limitations: tuple[str, ...] = (), + translations: dict[str, dict[str, str]] | None = None, ) -> DocumentationTopic: metadata: dict[str, object] = { "kind": "workflow", @@ -106,6 +108,7 @@ def _workflow_topic( links=links, related_modules=related_modules, unlocks=(outcome,), + translations=translations or {}, source_module_id="campaigns", metadata=metadata, ) @@ -163,26 +166,109 @@ CAMPAIGN_USER_DOCUMENTATION = ( topic_id="campaigns.workflow.copy-campaign", title="Copy a campaign into a new draft", summary="Reuse a selected campaign version as configuration for a new campaign without copying operational or audit evidence.", - body="Copy campaign is different from creating an editable successor. It creates a separately owned campaign with a generated identifier and one editable version. Delivery jobs, outcomes, explicit shares, locks, and audit evidence stay exclusively with the source campaign.", + body="Copy campaign is different from creating an editable successor. It creates a separately owned campaign with a chosen or generated identifier and one editable version. Recipients, attachment rules, active shares, campaign policies, and the Mail profile reference are explicit independent choices. Delivery jobs, outcomes, locks, reports, and audit evidence always stay exclusively with the source campaign.", order=32, audience=("campaign_manager", "campaign_author"), - required_scopes=("campaigns:campaign:read", "campaigns:campaign:copy", "campaigns:recipient:read"), + required_scopes=("campaigns:campaign:read", "campaigns:campaign:copy"), route="/campaigns/{campaign_id}", screen="Campaign overview", help_contexts=("campaign.overview",), prerequisites=( - "You may read the selected campaign and its recipient configuration.", + "You may read the selected campaign. Copying recipient data additionally requires recipient-read authority.", "You may create campaign copies in the active tenant.", ), steps=( "Open the campaign overview and choose the current or a historical source version.", - "Choose Copy campaign or Copy as new campaign and review the evidence-isolation consequence.", + "Choose Copy campaign or Copy as new campaign, enter the new identity, and select recipients, files, shares, policies, and Mail profile independently.", + "If a choice is unavailable, obtain the corresponding recipient-read or campaign-share authority or leave that content excluded.", "Confirm while the lifecycle state token is current; reload if another actor changed the source state.", "Open the newly created campaign, review its generated identifier and ownership, and validate all inherited configuration before use.", ), - outcome="A new editable campaign draft containing configuration from the selected version and no copied operational evidence.", - verification="The destination has a distinct campaign ID and owner, one editable version, and no source jobs, outcomes, shares, or locks.", + outcome="A new editable campaign draft containing only the explicitly selected configuration and no copied operational evidence.", + verification="The destination has a distinct campaign ID and owner, one editable version, the selected configuration domains, and no source jobs, outcomes, locks, reports, or audit evidence.", related_topic_ids=("campaigns.workflow.create-editable-successor", "campaigns.workflow.prepare-validate-and-build"), + translations={ + "de": { + "title": "Kampagne als neuen Entwurf kopieren", + "summary": "Eine ausgewählte Kampagnenversion als Konfiguration wiederverwenden, ohne Betriebs- oder Auditnachweise zu kopieren.", + "body": "Kampagne kopieren erzeugt eine eigenständige Kampagne mit eigener Kennung und einer bearbeitbaren Version. Empfänger, Dateiregeln, aktive Freigaben, Kampagnenrichtlinien und die Mailprofil-Referenz werden unabhängig ausgewählt. Sendeaufträge, Ergebnisse, Sperren, Berichte und Auditnachweise verbleiben immer ausschließlich bei der Quellkampagne.", + } + }, + ), + _workflow_topic( + topic_id="campaigns.workflow.reuse-content-library", + title="Reuse Campaign content through Templates", + summary="Insert scoped, versioned fragments or complete message parts and save new content as an unpublished Templates draft.", + body="The reusable library is owned by Templates. Loading a fragment inserts it at the selected Campaign field and cursor; applying a complete part replaces the current subject and body in the editable Campaign draft. Saving from Campaign creates a personal or tenant Templates draft and never publishes it automatically. Neither operation changes an existing Template revision or a historical Campaign version.", + order=33, + audience=("campaign_manager", "campaign_author"), + required_scopes=("campaigns:campaign:read", "campaigns:campaign:update"), + required_modules=("campaigns", "templates"), + required_capabilities=( + _TEMPLATE_CATALOG_INTEGRATION, + _TEMPLATE_CONTENT_LIBRARY_INTEGRATION, + ), + route="/campaigns/{campaign_id}/template", + screen="Campaign template", + help_contexts=("campaign.template", "campaign.template.content-library"), + prerequisites=( + "The Campaign version is editable.", + "Templates is active and you may read its library; saving additionally requires Template write authority.", + ), + steps=( + "Open Template and choose Load from library to search content visible in the active Templates scope.", + "Insert a fragment into its declared subject, text, or HTML target, or explicitly apply a complete Campaign part.", + "Review placeholders and save the Campaign draft normally.", + "To retain new content, choose Save to library, select fragment or complete part plus personal or tenant visibility, and create the unpublished draft.", + "Open Templates to review, revise, and publish shared content.", + ), + outcome="Reusable content remains centrally versioned while each Campaign records its own deliberate draft changes.", + verification="The Campaign draft shows the inserted content, and saved library content appears in Templates as an unpublished revision with Campaign provenance.", + related_topic_ids=("campaigns.workflow.prepare-validate-and-build",), + related_modules=("templates",), + translations={ + "de": { + "title": "Kampagneninhalte über Templates wiederverwenden", + "summary": "Bereichsbezogene, versionierte Bausteine oder vollständige Nachrichtenteile einfügen und neue Inhalte als unveröffentlichten Templates-Entwurf speichern.", + "body": "Die wiederverwendbare Bibliothek gehört Templates. Ein Baustein wird in das ausgewählte Kampagnenfeld an der Cursorposition eingefügt; ein vollständiger Teil ersetzt Betreff und Nachrichtentext im bearbeitbaren Kampagnenentwurf. Das Speichern aus Campaign legt einen persönlichen oder mandantenweiten Templates-Entwurf an und veröffentlicht ihn niemals automatisch. Bestehende Template-Revisionen und historische Kampagnenversionen bleiben unverändert.", + } + }, + ), + _workflow_topic( + topic_id="campaigns.workflow.schedule-drafts", + title="Schedule bounded recurring campaign drafts", + summary="Prepare fresh campaign drafts at a future time without bypassing validation, review, approval, or delivery controls.", + body="A Campaign schedule stores an integrity-sealed snapshot of the selected version, campaign policy, Mail profile policy, and optional shares, plus a bounded one-time, daily, weekly, or monthly recurrence. Each due occurrence creates a separately owned draft and an occurrence record. Missed intervals are coalesced instead of producing a catch-up storm. A schedule never validates, approves, queues, retries, or sends a campaign, and a preparation failure pauses it for operator review. Pause and resume reject stale browser state.", + order=34, + audience=("campaign_manager", "campaign_author", "operator"), + required_scopes=("campaigns:campaign:read", "campaigns:campaign:copy", "campaigns:campaign:schedule"), + route="/campaigns/{campaign_id}", + screen="Campaign overview", + help_contexts=("campaign.overview", "campaigns.action.schedule-drafts"), + prerequisites=( + "Choose the exact campaign version whose configuration should seed future drafts.", + "Recipient data and active shares require their corresponding read or share authority.", + "A worker and scheduler process must be running for automatic due-time preparation.", + ), + steps=( + "Open the campaign overview and choose Schedule.", + "Set the first occurrence, timezone, recurrence, and bounded maximum occurrence count.", + "Select which configuration domains may be copied and create the schedule.", + "Review each generated draft independently before validating, building, approving, and sending it.", + "Pause the schedule when the approved plan changes; a failed occurrence is paused automatically and remains visible as evidence.", + ), + outcome="A bounded sequence of independent campaign drafts with durable schedule and occurrence evidence.", + verification="The Schedules section shows the next occurrence and generated count; each prepared occurrence links to a distinct draft with no delivery jobs or outcomes.", + related_topic_ids=("campaigns.workflow.copy-campaign", "campaigns.workflow.prepare-validate-and-build"), + translations={ + "de": { + "title": "Begrenzte wiederkehrende Kampagnenentwürfe planen", + "summary": "Künftige Kampagnenentwürfe vorbereiten, ohne Validierung, Prüfung, Freigabe oder Versandkontrollen zu umgehen.", + "body": "Ein Kampagnenzeitplan speichert einen integritätsgesicherten Stand der ausgewählten Version, Kampagnenrichtlinie, Mail-Profilrichtlinie und optionalen Freigaben sowie eine begrenzte einmalige, tägliche, wöchentliche oder monatliche Wiederholung. Jede fällige Ausführung erzeugt einen eigenständigen Entwurf und einen Ausführungsnachweis. Verpasste Intervalle werden zusammengefasst, statt unkontrolliert nachgeholt zu werden. Der Zeitplan validiert, genehmigt, startet, wiederholt oder versendet niemals eine Kampagne; ein Fehler pausiert ihn zur betrieblichen Prüfung. Pausieren und Fortsetzen weisen veraltete Browserstände zurück.", + "outcome": "Eine begrenzte Folge eigenständiger Kampagnenentwürfe mit dauerhaftem Zeitplan- und Ausführungsnachweis.", + "verification": "Der Abschnitt Zeitpläne zeigt die nächste Ausführung und die Zahl erzeugter Entwürfe; jede Ausführung verweist auf einen eigenen Entwurf ohne Versandaufträge oder Ergebnisse.", + } + }, ), _workflow_topic( topic_id="campaigns.workflow.import-recipients", @@ -366,6 +452,41 @@ CAMPAIGN_USER_DOCUMENTATION = ( ), related_modules=("files",), ), + _workflow_topic( + topic_id="campaigns.workflow.route-unassigned-files", + title="Review and route unassigned campaign files", + summary="Turn files left in a watched source into an explicit report or reviewed attachment message instead of silently overlooking them.", + body="Campaign already compares watched attachment sources with the exact files assigned to built recipient messages. An optional residual-file disposition turns the remaining set into one additional Campaign row addressed to a configured mailbox. Report mode lists the files; attach mode also includes them. The row always needs review and follows the normal build, approval, delivery, reporting, and audit lifecycle. Saving or building never sends it directly.", + order=35, + audience=("campaign_manager", "campaign_author", "campaign_reviewer"), + required_scopes=("campaigns:campaign:read", "campaigns:campaign:update", "campaigns:campaign:build"), + route="/campaigns/{campaign_id}/files", + screen="Attachments", + help_contexts=("campaign.attachments", "campaign.attachments.residual-files"), + prerequisites=( + "Enable Unsent on every attachment source that must be checked.", + "Configure the ordinary warning or blocking policy even if no routed message is wanted.", + "For routing, provide a reviewed recipient, subject, and report body.", + ), + steps=( + "Open Attachments and set the unassigned-file action to warning only, report, or report with attachments.", + "Save and build the campaign; Campaign compares resolved recipient files with every file in the watched sources.", + "Open the residual-file row in Review and send, inspect its exact list and any attached files, and record the review decision.", + "Queue or send it through the same controlled Campaign lifecycle, or correct the attachment rules and rebuild instead.", + ), + outcome="Every watched file is either assigned, deliberately reported, or represented by a visible policy finding.", + verification="The build contains no hidden residual set: it shows either the configured warning/blocking issue or one needs-review row with residual-file provenance and the configured recipient.", + related_topic_ids=("campaigns.workflow.use-managed-attachments", "campaigns.workflow.prepare-validate-and-build"), + translations={ + "de": { + "title": "Nicht zugeordnete Kampagnendateien prüfen und weiterleiten", + "summary": "Übrig gebliebene Dateien aus überwachten Quellen ausdrücklich melden oder als geprüfte Nachricht vorbereiten, statt sie unbemerkt zu übergehen.", + "body": "Campaign vergleicht überwachte Anhangsquellen bereits mit den Dateien, die den erzeugten Empfängernachrichten tatsächlich zugeordnet sind. Eine optionale Restdatei-Behandlung erzeugt aus der verbleibenden Menge eine zusätzliche Kampagnenzeile an ein konfiguriertes Postfach. Der Berichtsmodus listet die Dateien auf; der Anhangsmodus fügt sie zusätzlich bei. Die Zeile muss immer geprüft werden und durchläuft den normalen Erzeugungs-, Freigabe-, Versand-, Berichts- und Auditablauf. Speichern oder Erzeugen versendet sie niemals unmittelbar.", + "outcome": "Jede überwachte Datei ist zugeordnet, bewusst gemeldet oder durch einen sichtbaren Richtlinienbefund erfasst.", + "verification": "Der Build enthält keine verborgene Restmenge: Er zeigt entweder den konfigurierten Warn- oder Sperrbefund oder eine zu prüfende Zeile mit Restdatei-Provenienz und dem konfigurierten Empfänger.", + } + }, + ), _workflow_topic( topic_id="campaigns.workflow.send-calendar-invitations", title="Send individualized calendar invitations", diff --git a/src/govoplan_campaign/backend/integrations.py b/src/govoplan_campaign/backend/integrations.py index 1b8a902..f41e2eb 100644 --- a/src/govoplan_campaign/backend/integrations.py +++ b/src/govoplan_campaign/backend/integrations.py @@ -38,9 +38,12 @@ from govoplan_core.core.postbox import ( ) from govoplan_core.core.templates import ( CAPABILITY_TEMPLATE_CATALOG, + CAPABILITY_TEMPLATE_CONTENT_LIBRARY, CAPABILITY_TEMPLATE_RENDERER, TemplateCatalogProvider, TemplateCompatibility, + TemplateContentDraftRequest, + TemplateContentLibraryProvider, TemplateRef, TemplateRenderRequest, TemplateRenderResult, @@ -56,6 +59,7 @@ POSTBOX_DIRECTORY_CAPABILITY = CAPABILITY_POSTBOX_DIRECTORY POSTBOX_EVIDENCE_CAPABILITY = CAPABILITY_POSTBOX_EVIDENCE APPROVALS_CAPABILITY = CAPABILITY_APPROVAL_REQUESTS TEMPLATE_CATALOG_CAPABILITY = CAPABILITY_TEMPLATE_CATALOG +TEMPLATE_CONTENT_LIBRARY_CAPABILITY = CAPABILITY_TEMPLATE_CONTENT_LIBRARY TEMPLATE_RENDERER_CAPABILITY = CAPABILITY_TEMPLATE_RENDERER CALENDAR_INVITATIONS_CAPABILITY = CAPABILITY_CALENDAR_INVITATIONS @@ -527,6 +531,7 @@ class TemplatesCampaignIntegration: self, catalog_delegate: object | None = None, renderer_delegate: object | None = None, + content_library_delegate: object | None = None, ) -> None: self._catalog = ( catalog_delegate @@ -538,11 +543,24 @@ class TemplatesCampaignIntegration: if isinstance(renderer_delegate, TemplateRendererProvider) else None ) + self._content_library = ( + content_library_delegate + if isinstance(content_library_delegate, TemplateContentLibraryProvider) + else None + ) @property def available(self) -> bool: return self._catalog is not None and self._renderer is not None + @property + def content_available(self) -> bool: + return self._catalog is not None + + @property + def content_writable(self) -> bool: + return self._content_library is not None + def list_templates( self, session: object, @@ -563,6 +581,43 @@ class TemplatesCampaignIntegration: ) ) + def list_content_templates( + self, + session: object, + principal: object, + *, + query: str = "", + limit: int = 100, + ) -> tuple[TemplateRef, ...]: + if self._catalog is None: + return () + return tuple( + self._catalog.list_templates( + session, + principal, + query=query, + usage="campaign.content", + limit=limit, + ) + ) + + def create_content_draft( + self, + session: object, + principal: object, + *, + request: TemplateContentDraftRequest, + ) -> TemplateRef: + if self._content_library is None: + raise TemplateOutputUnavailable( + "Saving reusable Campaign content requires the Templates content-library capability." + ) + return self._content_library.create_content_draft( + session, + principal, + request=request, + ) + def check_compatibility( self, session: object, @@ -799,6 +854,7 @@ def templates_integration() -> TemplatesCampaignIntegration: return TemplatesCampaignIntegration( capability(TEMPLATE_CATALOG_CAPABILITY), capability(TEMPLATE_RENDERER_CAPABILITY), + capability(TEMPLATE_CONTENT_LIBRARY_CAPABILITY), ) diff --git a/src/govoplan_campaign/backend/manifest.py b/src/govoplan_campaign/backend/manifest.py index 49edde5..d3236d1 100644 --- a/src/govoplan_campaign/backend/manifest.py +++ b/src/govoplan_campaign/backend/manifest.py @@ -13,6 +13,7 @@ from govoplan_core.core.campaigns import ( CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT, CAPABILITY_CAMPAIGNS_POLICY_CONTEXT, CAPABILITY_CAMPAIGNS_RETENTION, + CAPABILITY_CAMPAIGNS_SCHEDULES, ) from govoplan_core.core.calendar import CAPABILITY_CALENDAR_INVITATIONS from govoplan_core.core.module_guards import ( @@ -43,6 +44,7 @@ from govoplan_core.core.distribution_lists import ( ) from govoplan_core.core.templates import ( CAPABILITY_TEMPLATE_CATALOG, + CAPABILITY_TEMPLATE_CONTENT_LIBRARY, CAPABILITY_TEMPLATE_RENDERER, ) from govoplan_core.core.operations import OperationalCheckProviderRegistration @@ -108,6 +110,12 @@ PERMISSIONS = ( "Create campaigns or working versions from existing campaigns.", "Campaigns", ), + _permission( + "campaigns:campaign:schedule", + "Schedule campaign drafts", + "Prepare fresh campaign drafts at a governed time or bounded recurrence.", + "Campaigns", + ), _permission( "campaigns:campaign:archive", "Archive campaigns", @@ -274,6 +282,7 @@ ROLE_TEMPLATES = ( "campaigns:campaign:create", "campaigns:campaign:update", "campaigns:campaign:copy", + "campaigns:campaign:schedule", "campaigns:campaign:validate", "campaigns:campaign:build", "campaigns:ownership:accept_group", @@ -385,6 +394,7 @@ manifest = ModuleManifest( provides_interfaces=( ModuleInterfaceProvider(name="campaigns.access", version="0.1.6"), ModuleInterfaceProvider(name="campaigns.delivery_tasks", version="0.1.6"), + ModuleInterfaceProvider(name="campaigns.schedules", version="0.1.0"), ModuleInterfaceProvider(name="campaigns.mail_policy_context", version="0.1.6"), ModuleInterfaceProvider(name="campaigns.policy_context", version="0.1.6"), ModuleInterfaceProvider(name="campaigns.retention", version="0.1.6"), @@ -454,6 +464,12 @@ manifest = ModuleManifest( version_max_exclusive="0.2.0", optional=True, ), + ModuleInterfaceRequirement( + name=CAPABILITY_TEMPLATE_CONTENT_LIBRARY, + version_min="0.1.18", + version_max_exclusive="0.2.0", + optional=True, + ), ModuleInterfaceRequirement( name=CAPABILITY_CALENDAR_INVITATIONS, version_min="0.2.0", @@ -577,6 +593,8 @@ manifest = ModuleManifest( retirement_supported=True, retirement_provider=drop_table_retirement_provider( campaign_models.Campaign, + campaign_models.CampaignSchedule, + campaign_models.CampaignScheduleOccurrence, campaign_models.CampaignShare, campaign_models.RecipientImportMappingProfile, campaign_models.CampaignVersion, @@ -597,6 +615,8 @@ manifest = ModuleManifest( uninstall_guard_providers=( persistent_table_uninstall_guard( campaign_models.Campaign, + campaign_models.CampaignSchedule, + campaign_models.CampaignScheduleOccurrence, campaign_models.CampaignShare, campaign_models.RecipientImportMappingProfile, campaign_models.CampaignVersion, @@ -1149,6 +1169,10 @@ manifest = ModuleManifest( "govoplan_campaign.backend.capabilities", fromlist=["delivery_tasks_capability"], ).delivery_tasks_capability(context), + CAPABILITY_CAMPAIGNS_SCHEDULES: lambda context: __import__( + "govoplan_campaign.backend.capabilities", + fromlist=["schedules_capability"], + ).schedules_capability(context), CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT: lambda context: __import__( "govoplan_campaign.backend.capabilities", fromlist=["mail_policy_context_capability"], diff --git a/src/govoplan_campaign/backend/messages/builder.py b/src/govoplan_campaign/backend/messages/builder.py index abcb700..8467336 100644 --- a/src/govoplan_campaign/backend/messages/builder.py +++ b/src/govoplan_campaign/backend/messages/builder.py @@ -24,11 +24,13 @@ from govoplan_campaign.backend.campaign.entries import load_campaign_entries from govoplan_campaign.backend.campaign.field_values import ignored_entry_field_overrides from govoplan_campaign.backend.campaign.models import ( Behavior, + AttachmentConfig, BuildStatus, CampaignConfig, EntryConfig, MissingAddressBehavior, RecipientConfig, + ResidualFileMode, SendStatus, TemplateBodyMode, ZipArchiveConfig, @@ -92,6 +94,13 @@ class _MimeBuildResult: attachment_count: int +@dataclass(slots=True) +class _ResidualFileGroup: + source_name: str + directory: Path + files: list[Path] + + def _resolve(campaign_file: str | Path, raw_path: str) -> Path: campaign_path = Path(campaign_file).resolve() path = Path(raw_path).expanduser() @@ -890,17 +899,13 @@ def build_entry_message( -def _unsent_attachment_issues( +def _residual_attachment_files( *, config: CampaignConfig, campaign_file: str | Path, built_messages: list[BuiltMessage], attachment_match_index: AttachmentMatchIndex | None = None, -) -> list[MessageIssue]: - behavior = config.validation_policy.unsent_attachment_files.value - if behavior == Behavior.CONTINUE.value: - return [] - +) -> list[_ResidualFileGroup]: matched_files = { Path(match).resolve() for built in built_messages @@ -908,7 +913,7 @@ def _unsent_attachment_issues( for match in attachment.matches } - issues: list[MessageIssue] = [] + groups: list[_ResidualFileGroup] = [] for base_path in config.attachments.base_paths: if not base_path.unsent_warning: continue @@ -922,20 +927,182 @@ def _unsent_attachment_issues( unsent = [path for path in all_files if path not in matched_files] if not unsent: continue + groups.append( + _ResidualFileGroup( + source_name=base_path.name, + directory=directory, + files=unsent, + ) + ) + return groups + + +def _unsent_attachment_issues( + *, + config: CampaignConfig, + residual_groups: list[_ResidualFileGroup], +) -> list[MessageIssue]: + behavior = config.validation_policy.unsent_attachment_files.value + if ( + behavior == Behavior.CONTINUE.value + or config.attachments.residual_files.mode != ResidualFileMode.NONE + ): + return [] + issues: list[MessageIssue] = [] + for group in residual_groups: + unsent = group.files + directory = group.directory shown = ", ".join(str(path.relative_to(directory)) for path in unsent[:10]) if len(unsent) > 10: shown += f", … (+{len(unsent) - 10} more)" issues.append( _issue_from_behavior( code="unsent_attachment_files", - message=f"{len(unsent)} file(s) in attachment source {base_path.name!r} are not used by any message: {shown}", + message=f"{len(unsent)} file(s) in attachment source {group.source_name!r} are not used by any message: {shown}", behavior=behavior, - source=f"attachments:{base_path.name}", + source=f"attachments:{group.source_name}", ) ) return issues +def _build_residual_file_message( + *, + config: CampaignConfig, + campaign_file: Path, + residual_groups: list[_ResidualFileGroup], + entry_index: int, + output_dir: Path | None, + write_eml: bool, + work_dir: Path, + attachment_match_index: AttachmentMatchIndex, +) -> BuiltMessage | None: + disposition = config.attachments.residual_files + if disposition.mode == ResidualFileMode.NONE or disposition.recipient is None: + return None + files = sorted({path.resolve() for group in residual_groups for path in group.files}) + if not files: + return None + file_lines = [ + f"{group.source_name}: {path.relative_to(group.directory)}" + for group in residual_groups + for path in group.files + ] + entry = EntryConfig( + id="__residual_files__", + to=[disposition.recipient], + merge_to=False, + combine_attachments=True, + fields={ + "campaign_name": config.campaign.name, + "residual_file_count": len(files), + "residual_file_list": "\n".join(file_lines), + }, + ) + residual_config = config.model_copy(deep=True) + residual_config.attachments.global_ = [] + residual_config.attachments.zip.enabled = False + if disposition.mode == ResidualFileMode.ATTACH: + residual_config.attachments.global_ = [ + AttachmentConfig( + id=f"residual-file-{index}", + label=path.name, + base_dir=str(path.parent), + file_filter=path.name, + required=True, + allow_multiple=False, + ) + for index, path in enumerate(files, start=1) + ] + context = _entry_message_context( + config=residual_config, + campaign_file=campaign_file, + entry=entry, + entry_index=entry_index, + attachment_match_index=attachment_match_index, + ) + context.validation_status = _validate_required_sender( + context.senders, + context.issues, + context.validation_status, + ) + context.validation_status = _validate_required_recipients( + residual_config, + context.recipients, + context.issues, + context.validation_status, + ) + values = build_template_values(residual_config, entry) + rendered = _RenderedMessageTemplate( + subject=_render_template(disposition.subject, values, keep_missing=True), + text_body=_render_template(disposition.text, values, keep_missing=True), + html_body=None, + body_mode=TemplateBodyMode.TEXT.value, + values=values, + ) + context.validation_status = _validate_rendered_template( + residual_config, + rendered, + context.issues, + context.validation_status, + ) + context.issues.append( + MessageIssue( + severity="warning", + code="residual_attachment_disposition", + message=( + f"{len(files)} unassigned file(s) are routed as a reviewed " + f"{disposition.mode.value} message." + ), + behavior="ask", + source="attachments:residual_files", + details={ + "mode": disposition.mode.value, + "file_count": len(files), + "source_count": len(residual_groups), + }, + ) + ) + if context.validation_status not in { + MessageValidationStatus.BLOCKED, + MessageValidationStatus.EXCLUDED, + }: + context.validation_status = MessageValidationStatus.NEEDS_REVIEW + mime_result = _build_mime_message( + config=residual_config, + entry=entry, + entry_index=entry_index, + output_dir=output_dir, + work_dir=work_dir, + context=context, + rendered=rendered, + ) + eml_path: str | None = None + eml_size: int | None = None + if write_eml and output_dir is not None and mime_result.message is not None: + eml_path, eml_size = _write_eml( + mime_result.message, + output_dir, + entry, + entry_index, + ) + return BuiltMessage( + draft=_message_draft( + config=residual_config, + entry=entry, + entry_index=entry_index, + context=context, + build_status=mime_result.build_status, + validation_status=mime_result.validation_status, + subject=rendered.subject, + attachment_count=mime_result.attachment_count, + eml_path=eml_path, + eml_size=eml_size, + ), + mime=mime_result.message, + ) + + def _apply_campaign_level_issues(built_messages: list[BuiltMessage], issues: list[MessageIssue]) -> None: if not issues: return @@ -978,15 +1145,31 @@ def build_campaign_messages( for index, entry in enumerate(entries, start=1) if entry.active ] + residual_groups = _residual_attachment_files( + config=config, + campaign_file=campaign_path, + built_messages=built_messages, + attachment_match_index=attachment_match_index, + ) _apply_campaign_level_issues( built_messages, _unsent_attachment_issues( config=config, - campaign_file=campaign_path, - built_messages=built_messages, - attachment_match_index=attachment_match_index, + residual_groups=residual_groups, ), ) + residual_message = _build_residual_file_message( + config=config, + campaign_file=campaign_path, + residual_groups=residual_groups, + entry_index=len(entries) + 1, + output_dir=output_path, + write_eml=write_eml, + work_dir=work_dir, + attachment_match_index=attachment_match_index, + ) + if residual_message is not None: + built_messages.append(residual_message) rules_resolved = sum(len(built.draft.attachments) for built in built_messages) report = CampaignBuildReport( diff --git a/src/govoplan_campaign/backend/migrations/dev_versions/a5b6c7d8e9f0_campaign_schedule_revisions.py b/src/govoplan_campaign/backend/migrations/dev_versions/a5b6c7d8e9f0_campaign_schedule_revisions.py new file mode 100644 index 0000000..7bdd523 --- /dev/null +++ b/src/govoplan_campaign/backend/migrations/dev_versions/a5b6c7d8e9f0_campaign_schedule_revisions.py @@ -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() diff --git a/src/govoplan_campaign/backend/migrations/dev_versions/f4a5b6c7d8e9_campaign_schedules.py b/src/govoplan_campaign/backend/migrations/dev_versions/f4a5b6c7d8e9_campaign_schedules.py new file mode 100644 index 0000000..17ede98 --- /dev/null +++ b/src/govoplan_campaign/backend/migrations/dev_versions/f4a5b6c7d8e9_campaign_schedules.py @@ -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() diff --git a/src/govoplan_campaign/backend/migrations/versions/a5b6c7d8e9f0_v0120_campaign_schedule_revisions.py b/src/govoplan_campaign/backend/migrations/versions/a5b6c7d8e9f0_v0120_campaign_schedule_revisions.py new file mode 100644 index 0000000..5cb8615 --- /dev/null +++ b/src/govoplan_campaign/backend/migrations/versions/a5b6c7d8e9f0_v0120_campaign_schedule_revisions.py @@ -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") diff --git a/src/govoplan_campaign/backend/migrations/versions/f4a5b6c7d8e9_v0119_campaign_schedules.py b/src/govoplan_campaign/backend/migrations/versions/f4a5b6c7d8e9_v0119_campaign_schedules.py new file mode 100644 index 0000000..d0a431f --- /dev/null +++ b/src/govoplan_campaign/backend/migrations/versions/f4a5b6c7d8e9_v0119_campaign_schedules.py @@ -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") diff --git a/src/govoplan_campaign/backend/router.py b/src/govoplan_campaign/backend/router.py index 4e265d8..ccb72c0 100644 --- a/src/govoplan_campaign/backend/router.py +++ b/src/govoplan_campaign/backend/router.py @@ -8,6 +8,7 @@ from govoplan_campaign.backend.routes.delivery import router as delivery_router from govoplan_campaign.backend.routes.jobs import router as jobs_router from govoplan_campaign.backend.routes.operations import router as operations_router from govoplan_campaign.backend.routes.reports import router as reports_router +from govoplan_campaign.backend.routes.schedules import router as schedules_router from govoplan_campaign.backend.routes.sharing import router as sharing_router from govoplan_campaign.backend.routes.versions import router as versions_router @@ -19,6 +20,7 @@ for workflow_router in ( versions_router, jobs_router, reports_router, + schedules_router, sharing_router, delivery_router, attachments_router, diff --git a/src/govoplan_campaign/backend/routes/campaigns.py b/src/govoplan_campaign/backend/routes/campaigns.py index ca2553b..b0a6d2a 100644 --- a/src/govoplan_campaign/backend/routes/campaigns.py +++ b/src/govoplan_campaign/backend/routes/campaigns.py @@ -14,6 +14,7 @@ from govoplan_campaign.backend.schemas import ( CampaignCreateResponse, CampaignCreateMinimalRequest, CampaignCopyRequest, + CampaignContentLibrarySaveRequest, CampaignLifecycleMutationRequest, CampaignLifecyclePolicyResponse, CampaignAddressLookupCandidate, @@ -42,6 +43,7 @@ from govoplan_campaign.backend.schemas import ( ) from govoplan_core.auth import ApiPrincipal, has_scope, require_scope from govoplan_core.audit.logging import audit_from_principal +from govoplan_core.core.templates import TemplateContentDraftRequest, TemplateRef from govoplan_core.core.change_sequence import ( decode_sequence_watermark, encode_sequence_watermark, @@ -60,6 +62,7 @@ from govoplan_campaign.backend.change_tracking import ( ) from govoplan_campaign.backend.db.models import ( Campaign, + CampaignShare, CampaignVersion, RecipientImportMappingProfile, ) @@ -70,6 +73,7 @@ from govoplan_campaign.backend.campaign.lifecycle import ( assert_lifecycle_state_token, campaign_lifecycle_policy, ) +from govoplan_campaign.backend.campaign.copying import campaign_copy_configuration from govoplan_campaign.backend.integrations import ( calendar_integration, PostboxDeliveryUnavailable, @@ -214,6 +218,13 @@ def _campaign_copy_external_id( ) +def _campaign_copy_configuration( + source: dict[str, object], + payload: CampaignCopyRequest, +) -> dict[str, object]: + return campaign_copy_configuration(source, payload.model_dump()) + + @router.post("", response_model=CampaignCreateResponse) def create_campaign( payload: CampaignCreateRequest, @@ -924,6 +935,192 @@ def campaign_print_templates( } +def _campaign_content_library_item(template: TemplateRef) -> dict[str, object]: + revision = template.revision + revision_metadata = dict(revision.metadata) if revision else {} + raw_targets = revision_metadata.get("campaign_targets") + targets = ( + [str(value) for value in raw_targets if str(value) in {"subject", "text", "html"}] + if isinstance(raw_targets, (list, tuple)) + else [] + ) + if not targets and revision is not None: + if template.template_type == "email": + targets = ["subject", "text", "html"] + else: + if revision.content_text: + targets.append("text") + if revision.content_html: + targets.append("html") + kind = str(revision_metadata.get("campaign_kind") or "").strip() + if kind not in {"fragment", "campaign_part"}: + kind = "fragment" if template.template_type == "content_fragment" else "campaign_part" + return { + "id": template.id, + "name": template.name, + "description": template.description, + "template_type": template.template_type, + "kind": kind, + "status": template.status, + "scope_type": template.scope_type, + "scope_id": template.scope_id, + "read_only": template.read_only, + "current_revision": template.current_revision, + "revision": revision.revision if revision else template.current_revision, + "revision_id": revision.id if revision else template.current_revision_id, + "locale": revision.locale if revision else None, + "published": bool(template.published_revision_id), + "targets": list(dict.fromkeys(targets)), + "subject": revision_metadata.get("campaign_subject"), + "text": revision.content_text if revision else None, + "html": revision.content_html if revision else None, + "body_mode": revision_metadata.get("campaign_body_mode") or "both", + } + + +@router.get("/{campaign_id}/content-library") +def campaign_content_library( + campaign_id: str, + query: str = Query(default="", min_length=0, max_length=200), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")), +): + _get_campaign_for_principal(session, campaign_id, principal) + integration = templates_integration() + if not integration.content_available: + return { + "available": False, + "writable": False, + "reason": "The Templates content library is not active.", + "items": [], + } + try: + templates = integration.list_content_templates( + session, + principal, + query=query, + limit=250, + ) + except PermissionError as exc: + return { + "available": True, + "writable": False, + "reason": str(exc), + "items": [], + } + return { + "available": True, + "writable": integration.content_writable, + "items": [_campaign_content_library_item(template) for template in templates], + } + + +@router.post("/{campaign_id}/content-library", status_code=status.HTTP_201_CREATED) +def save_campaign_content_library_item( + campaign_id: str, + payload: CampaignContentLibrarySaveRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")), +): + campaign = _get_campaign_for_principal( + session, + campaign_id, + principal, + write=True, + ) + integration = templates_integration() + if not integration.content_writable: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="The Templates content-library capability is not active.", + ) + target = payload.target if payload.kind == "fragment" else None + content_text = ( + payload.subject + if target == "subject" + else payload.text + if target == "text" + else None + ) + content_html = payload.html if target == "html" else None + if payload.kind == "campaign_part": + content_text = payload.text + content_html = payload.html + metadata: dict[str, object] = { + "campaign_kind": payload.kind, + "campaign_targets": ( + [target] + if target + else [ + field + for field, value in ( + ("subject", payload.subject), + ("text", payload.text), + ("html", payload.html), + ) + if value and value.strip() + ] + ), + "campaign_body_mode": payload.body_mode, + "source_module": "campaigns", + "source_campaign_id": campaign.id, + } + if payload.kind == "campaign_part" and payload.subject: + metadata["campaign_subject"] = payload.subject + try: + template = integration.create_content_draft( + session, + principal, + request=TemplateContentDraftRequest( + name=payload.name, + description=payload.description, + template_type=( + "content_fragment" if payload.kind == "fragment" else "email" + ), + usages=("campaign.content",), + content_text=content_text, + content_html=content_html, + locale=payload.locale, + scope_type=("user" if payload.visibility == "personal" else "tenant"), + scope_id=( + principal.account_id if payload.visibility == "personal" else None + ), + metadata=metadata, + ), + ) + audit_from_principal( + session, + principal, + action="campaign.content_library_saved", + object_type="campaign", + object_id=campaign.id, + details={ + "template_id": template.id, + "template_revision_id": ( + template.revision.id if template.revision else None + ), + "kind": payload.kind, + "target": payload.target, + "visibility": payload.visibility, + }, + commit=False, + ) + session.commit() + except PermissionError as exc: + session.rollback() + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=str(exc), + ) from exc + except ValueError as exc: + session.rollback() + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=str(exc), + ) from exc + return {"template": _campaign_content_library_item(template)} + + @router.post( "/{campaign_id}/recipient-address-sources/snapshot", response_model=CampaignRecipientAddressSourceSnapshotResponse, @@ -1652,7 +1849,10 @@ def copy_campaign( action="copy_campaign", version_id=payload.source_version_id, ) - _require_permission(principal, "campaigns:recipient:read") + if payload.include_recipients: + _require_permission(principal, "campaigns:recipient:read") + if payload.include_shares: + _require_permission(principal, "campaigns:campaign:share") source_version = ( session.query(CampaignVersion) .filter( @@ -1674,7 +1874,7 @@ def copy_campaign( requested=payload.external_id, ) name = (payload.name or f"{source_campaign.name} (copy)").strip() - raw_json = copy.deepcopy(source_version.raw_json) + raw_json = _campaign_copy_configuration(source_version.raw_json, payload) campaign_metadata = raw_json.get("campaign") if not isinstance(campaign_metadata, dict): raise HTTPException( @@ -1696,6 +1896,36 @@ def copy_campaign( source_base_path=source_version.source_base_path, commit=False, ) + if payload.include_policies: + campaign.settings = copy.deepcopy(source_campaign.settings or {}) + if payload.include_mail_profile: + campaign.mail_profile_policy = copy.deepcopy( + source_campaign.mail_profile_policy or {} + ) + copied_share_count = 0 + if payload.include_shares: + source_shares = ( + session.query(CampaignShare) + .filter( + CampaignShare.tenant_id == principal.tenant_id, + CampaignShare.campaign_id == source_campaign.id, + CampaignShare.revoked_at.is_(None), + ) + .order_by(CampaignShare.id.asc()) + .all() + ) + for source_share in source_shares: + session.add( + CampaignShare( + tenant_id=principal.tenant_id, + campaign_id=campaign.id, + target_type=source_share.target_type, + target_id=source_share.target_id, + permission=source_share.permission, + created_by_user_id=principal.user.id, + ) + ) + copied_share_count = len(source_shares) audit_from_principal( session, principal, @@ -1707,6 +1937,14 @@ def copy_campaign( "source_version_id": source_version.id, "destination_version_id": version.id, "copied_evidence": False, + "copy_options": { + "recipients": payload.include_recipients, + "files": payload.include_files, + "shares": payload.include_shares, + "policies": payload.include_policies, + "mail_profile": payload.include_mail_profile, + }, + "copied_share_count": copied_share_count, }, commit=True, ) diff --git a/src/govoplan_campaign/backend/routes/schedules.py b/src/govoplan_campaign/backend/routes/schedules.py new file mode 100644 index 0000000..d51262f --- /dev/null +++ b/src/govoplan_campaign/backend/routes/schedules.py @@ -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 + ] + } + ) diff --git a/src/govoplan_campaign/backend/schema/campaign.schema.json b/src/govoplan_campaign/backend/schema/campaign.schema.json index 802dff1..f7e1b08 100644 --- a/src/govoplan_campaign/backend/schema/campaign.schema.json +++ b/src/govoplan_campaign/backend/schema/campaign.schema.json @@ -371,6 +371,9 @@ "warn" ], "default": "ask" + }, + "residual_files": { + "$ref": "#/$defs/residual_file_disposition" } }, "additionalProperties": false, @@ -1455,6 +1458,34 @@ }, "additionalProperties": false }, + "residual_file_disposition": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": ["none", "report", "attach"], + "default": "none", + "description": "Keep normal warning policy, prepare a reviewed report message, or prepare a reviewed message with the residual files attached." + }, + "recipient": { + "oneOf": [ + { "$ref": "#/$defs/recipient" }, + { "type": "null" } + ], + "default": null + }, + "subject": { + "type": "string", + "default": "Unassigned files in campaign {{local:campaign_name}}" + }, + "text": { + "type": "string", + "default": "The campaign build found {{local:residual_file_count}} file(s) that were not assigned to a recipient.\n\n{{local:residual_file_list}}" + } + }, + "additionalProperties": false, + "default": { "mode": "none", "recipient": null } + }, "zip_config": { "type": "object", "properties": { diff --git a/src/govoplan_campaign/backend/schemas.py b/src/govoplan_campaign/backend/schemas.py index bee2efb..1baa60c 100644 --- a/src/govoplan_campaign/backend/schemas.py +++ b/src/govoplan_campaign/backend/schemas.py @@ -52,6 +52,126 @@ class CampaignCopyRequest(CampaignLifecycleMutationRequest): source_version_id: str = Field(min_length=1, max_length=36) external_id: str | None = Field(default=None, min_length=1, max_length=255) name: str | None = Field(default=None, min_length=1, max_length=255) + include_recipients: bool = True + include_files: bool = True + include_shares: bool = False + include_policies: bool = True + include_mail_profile: bool = True + + +class CampaignScheduleCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + source_version_id: str = Field(min_length=1, max_length=36) + name: str = Field(min_length=1, max_length=255) + recurrence_kind: Literal["once", "daily", "weekly", "monthly"] = "once" + interval_count: int = Field(default=1, ge=1, le=365) + timezone: str = Field(default="UTC", min_length=1, max_length=100) + starts_at: datetime + ends_at: datetime | None = None + max_occurrences: int = Field(default=1, ge=1, le=1000) + include_recipients: bool = True + include_files: bool = True + include_shares: bool = False + include_policies: bool = True + include_mail_profile: bool = True + + @model_validator(mode="after") + def validate_schedule(self) -> "CampaignScheduleCreateRequest": + if self.starts_at.tzinfo is None: + raise ValueError("Campaign schedule start must include a timezone.") + if self.ends_at is not None: + if self.ends_at.tzinfo is None: + raise ValueError("Campaign schedule end must include a timezone.") + if self.ends_at <= self.starts_at: + raise ValueError("Campaign schedule end must be after its start.") + if self.recurrence_kind == "once": + self.max_occurrences = 1 + self.interval_count = 1 + elif self.max_occurrences < 2: + raise ValueError("A recurring campaign schedule needs at least two occurrences.") + return self + + +class CampaignScheduleStateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + active: bool + base_revision: int = Field(ge=1) + + +class CampaignScheduleOccurrenceResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + schedule_id: str + scheduled_for: datetime + status: str + generated_campaign_id: str | None = None + generated_version_id: str | None = None + error: str | None = None + created_at: datetime + + +class CampaignScheduleResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + campaign_id: str + source_version_id: str + name: str + recurrence_kind: str + interval_count: int + timezone: str + starts_at: datetime + next_fire_at: datetime | None = None + ends_at: datetime | None = None + max_occurrences: int + occurrence_count: int + active: bool + resource_revision: int + last_fired_at: datetime | None = None + last_campaign_id: str | None = None + last_error: str | None = None + created_at: datetime + updated_at: datetime + occurrences: list[CampaignScheduleOccurrenceResponse] = Field(default_factory=list) + + +class CampaignScheduleListResponse(BaseModel): + items: list[CampaignScheduleResponse] + + +class CampaignContentLibrarySaveRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=300) + description: str | None = Field(default=None, max_length=4000) + kind: Literal["fragment", "campaign_part"] + target: Literal["subject", "text", "html"] | None = None + subject: str | None = Field(default=None, max_length=1000) + text: str | None = Field(default=None, max_length=1_000_000) + html: str | None = Field(default=None, max_length=2_000_000) + body_mode: Literal["text", "html", "both"] = "both" + locale: str = Field(default="de", min_length=2, max_length=35) + visibility: Literal["personal", "tenant"] = "personal" + + @model_validator(mode="after") + def validate_content(self) -> "CampaignContentLibrarySaveRequest": + if self.kind == "fragment" and self.target is None: + raise ValueError("A content fragment requires a target field.") + values = { + "subject": self.subject, + "text": self.text, + "html": self.html, + } + if self.kind == "fragment": + selected = values[self.target or "text"] + if not selected or not selected.strip(): + raise ValueError("The selected fragment field is empty.") + elif not any(value and value.strip() for value in (self.text, self.html)): + raise ValueError("A campaign part requires text or HTML body content.") + return self class CampaignCreateMinimalRequest(BaseModel): diff --git a/tests/test_artifact_reconciliation.py b/tests/test_artifact_reconciliation.py index 60fe9bb..726e8e2 100644 --- a/tests/test_artifact_reconciliation.py +++ b/tests/test_artifact_reconciliation.py @@ -240,6 +240,7 @@ def test_process_loss_orphan_is_deleted_once_and_same_request_replays( def test_competing_node_cannot_acquire_cleanup_authority( recovery_session_factory, ) -> None: + lease_observed_at = datetime.now(timezone.utc) with recovery_session_factory() as session: claim = acquire_lease( session, @@ -248,7 +249,7 @@ def test_competing_node_cannot_acquire_cleanup_authority( holder_node_id="node-other", holder_incarnation="run-other", ttl_seconds=900, - now=NOW, + now=lease_observed_at, ) assert claim is not None session.commit() diff --git a/tests/test_attachment_building.py b/tests/test_attachment_building.py index 4a20e38..543651e 100644 --- a/tests/test_attachment_building.py +++ b/tests/test_attachment_building.py @@ -275,6 +275,92 @@ class CampaignAttachmentBuildTests(unittest.TestCase): self.assertEqual(archive.namelist(), ["matched.xlsx"]) self.assertEqual(archive.read("matched.xlsx"), b"matched workbook") + def test_residual_files_become_a_separate_reviewed_report_or_attachment_message(self) -> None: + for mode, expected_attachment_count in (("report", 0), ("attach", 1)): + with self.subTest(mode=mode), tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + watched = root / "watched" + watched.mkdir() + (watched / "assigned.txt").write_text("assigned", encoding="utf-8") + (watched / "residual.txt").write_text("residual", encoding="utf-8") + campaign_file = root / "campaign.json" + campaign_file.write_text("{}", encoding="utf-8") + config = CampaignConfig.model_validate({ + "version": "1.0", + "campaign": {"id": f"residual-{mode}", "name": "Monthly import", "mode": "test"}, + "fields": [], + "global_values": {}, + "server": { + "mail_profile_id": "profile-1", + "profile_capabilities": {"smtp_available": True}, + }, + "recipients": { + "from": {"email": "sender@example.org", "type": "to"}, + "allow_individual_to": True, + }, + "template": {"subject": "Normal message", "text": "Normal body"}, + "attachments": { + "base_paths": [{ + "id": "watched", + "name": "Watched folder", + "path": "watched", + "unsent_warning": True, + }], + "global": [{ + "id": "assigned", + "base_path_id": "watched", + "base_dir": "watched", + "file_filter": "assigned.txt", + "required": True, + }], + "residual_files": { + "mode": mode, + "recipient": {"email": "operator@example.org", "name": "Operator"}, + "subject": "Residual files for {{local:campaign_name}}", + "text": "{{local:residual_file_count}} file(s):\n{{local:residual_file_list}}", + }, + }, + "entries": {"inline": [{ + "id": "recipient-1", + "to": [{"email": "recipient@example.org", "type": "to"}], + }]}, + "validation_policy": { + "missing_email": "block", + "template_error": "block", + "unsent_attachment_files": "block", + }, + "delivery": {"imap_append_sent": {"enabled": False}}, + }) + + result = build_campaign_messages( + config, + campaign_file=campaign_file, + output_dir=root / "out", + write_eml=True, + ) + + self.assertEqual(len(result.report.messages), 2) + normal, residual = result.report.messages + self.assertEqual(normal.validation_status.value, "ready") + self.assertEqual(residual.entry_id, "__residual_files__") + self.assertEqual(residual.validation_status.value, "needs_review") + self.assertEqual(residual.to[0].email, "operator@example.org") + self.assertEqual(residual.subject, "Residual files for Monthly import") + self.assertEqual(residual.attachment_count, expected_attachment_count) + self.assertIn( + "residual_attachment_disposition", + {issue.code for issue in residual.issues}, + ) + self.assertNotIn( + "unsent_attachment_files", + {issue.code for message in result.report.messages for issue in message.issues}, + ) + mime = result.built_messages[1].mime + self.assertIsNotNone(mime) + self.assertIn("residual.txt", mime.get_body(preferencelist=("plain",)).get_content()) + filenames = [part.get_filename() for part in mime.iter_attachments()] + self.assertEqual(filenames, ["residual.txt"] if mode == "attach" else []) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_campaign_content_library.py b/tests/test_campaign_content_library.py new file mode 100644 index 0000000..2f9eef6 --- /dev/null +++ b/tests/test_campaign_content_library.py @@ -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") diff --git a/tests/test_campaign_lifecycle.py b/tests/test_campaign_lifecycle.py index eeab854..cabdb0c 100644 --- a/tests/test_campaign_lifecycle.py +++ b/tests/test_campaign_lifecycle.py @@ -13,10 +13,12 @@ from govoplan_campaign.backend.campaign.lifecycle import campaign_lifecycle_poli from govoplan_campaign.backend.db.models import ( Campaign, CampaignJob, + CampaignSchedule, CampaignShare, CampaignVersion, ) from govoplan_campaign.backend.routes.campaigns import ( + _campaign_copy_configuration, archive_campaign_version, copy_campaign, delete_draft_campaign, @@ -26,6 +28,7 @@ from govoplan_campaign.backend.schemas import ( CampaignLifecycleMutationRequest, ) from govoplan_core.db.base import Base +from govoplan_core.core.change_sequence import ChangeSequenceEntry class _Principal: @@ -65,6 +68,8 @@ class CampaignLifecycleTests(unittest.TestCase): CampaignVersion.__table__, CampaignShare.__table__, CampaignJob.__table__, + CampaignSchedule.__table__, + ChangeSequenceEntry.__table__, ], ) self.SessionLocal = sessionmaker( @@ -73,7 +78,14 @@ class CampaignLifecycleTests(unittest.TestCase): expire_on_commit=False, ) with self.SessionLocal() as session: - session.execute(access_users.insert().values(id="user-1")) + user_values = {"id": "user-1"} + if "tenant_id" in access_users.c: + user_values.update( + tenant_id="tenant-1", + account_id="account-1", + email="user-1@example.test", + ) + session.execute(access_users.insert().values(**user_values)) campaign = Campaign( id="campaign-1", tenant_id="tenant-1", @@ -153,6 +165,50 @@ class CampaignLifecycleTests(unittest.TestCase): self.assertFalse(policy["actions"]["archive_campaign"]["allowed"]) self.assertIn("Active or uncertain", policy["actions"]["archive_campaign"]["reason"]) + def test_schedule_evidence_blocks_destructive_lifecycle_actions(self) -> None: + with self.SessionLocal() as session: + session.add( + CampaignSchedule( + id="schedule-1", + tenant_id="tenant-1", + campaign_id="campaign-1", + source_version_id="version-2", + created_by_user_id="user-1", + name="Recurring draft", + recurrence_kind="daily", + interval_count=1, + timezone="UTC", + starts_at=datetime(2026, 8, 8, tzinfo=UTC), + next_fire_at=datetime(2026, 8, 8, tzinfo=UTC), + max_occurrences=2, + copy_options={}, + source_snapshot={"schema": "test"}, + source_snapshot_hash="a" * 64, + ) + ) + session.commit() + + active_policy = self._policy(session) + self.assertFalse(active_policy["actions"]["archive_campaign"]["allowed"]) + self.assertIn( + "Pause active", + active_policy["actions"]["archive_campaign"]["reason"], + ) + self.assertFalse(active_policy["actions"]["delete_campaign"]["allowed"]) + self.assertIn( + "schedule evidence", + active_policy["actions"]["delete_campaign"]["reason"], + ) + + schedule = session.get(CampaignSchedule, "schedule-1") + assert schedule is not None + schedule.active = False + schedule.resource_revision += 1 + session.commit() + paused_policy = self._policy(session) + self.assertTrue(paused_policy["actions"]["archive_campaign"]["allowed"]) + self.assertFalse(paused_policy["actions"]["delete_campaign"]["allowed"]) + def test_stale_delete_token_is_rejected(self) -> None: with self.SessionLocal() as session: policy = self._policy(session) @@ -203,6 +259,10 @@ class CampaignLifecycleTests(unittest.TestCase): def test_whole_campaign_copy_starts_without_operational_evidence(self) -> None: with self.SessionLocal() as session: + source_campaign = session.get(Campaign, "campaign-1") + assert source_campaign is not None + source_campaign.settings = {"retention": "source-policy"} + source_campaign.mail_profile_policy = {"profile_id": "mail-profile-1"} session.add_all( ( CampaignShare( @@ -269,6 +329,8 @@ class CampaignLifecycleTests(unittest.TestCase): CampaignCopyRequest( source_version_id="version-2", expected_state_token=policy["state_token"], + include_policies=False, + include_mail_profile=True, ), session=session, principal=self.principal, @@ -277,6 +339,13 @@ class CampaignLifecycleTests(unittest.TestCase): self.assertEqual(response.campaign.external_id, "campaign-1-copy") self.assertEqual(response.campaign.owner_user_id, "user-1") self.assertEqual(captured["raw_json"]["campaign"]["mode"], "draft") + copied_campaign = session.get(Campaign, "campaign-copy") + assert copied_campaign is not None + self.assertEqual({}, copied_campaign.settings) + self.assertEqual( + {"profile_id": "mail-profile-1"}, + copied_campaign.mail_profile_policy, + ) self.assertEqual( session.query(CampaignJob) .filter(CampaignJob.campaign_id == "campaign-copy") @@ -290,6 +359,64 @@ class CampaignLifecycleTests(unittest.TestCase): 0, ) + def test_copy_choices_reset_only_selected_configuration_domains(self) -> None: + source = { + "version": "1.0", + "campaign": {"id": "campaign-1", "name": "Campaign"}, + "fields": [{"name": "case_id"}], + "global_values": {"sender": "Office"}, + "recipients": {"to": [{"email": "team@example.test"}]}, + "entries": { + "inline": [ + { + "id": "one", + "to": [{"email": "person@example.test"}], + "attachments": [{"base_dir": ".", "file_filter": "one.pdf"}], + } + ] + }, + "template": {"subject": "Hello", "text": "Body"}, + "attachments": { + "global": [{"base_dir": ".", "file_filter": "global.pdf"}] + }, + "validation_policy": {"missing_required_attachment": "warn"}, + "server": {"mail_profile_id": "profile-1"}, + "delivery": {"rate_limit": {"messages_per_minute": 20}}, + } + + copied = _campaign_copy_configuration( + source, + CampaignCopyRequest( + source_version_id="version-2", + expected_state_token="a" * 64, + include_recipients=True, + include_files=False, + include_policies=False, + include_mail_profile=False, + ), + ) + + self.assertEqual(source["attachments"]["global"][0]["file_filter"], "global.pdf") + self.assertEqual({}, copied["attachments"]) + self.assertEqual([], copied["entries"]["inline"][0]["attachments"]) + self.assertEqual("person@example.test", copied["entries"]["inline"][0]["to"][0]["email"]) + self.assertEqual({}, copied["validation_policy"]) + self.assertEqual({}, copied["server"]) + self.assertEqual(20, copied["delivery"]["rate_limit"]["messages_per_minute"]) + + def test_copy_without_recipient_permission_is_allowed_when_recipient_data_is_excluded(self) -> None: + principal = _Principal("campaigns:campaign:read", "campaigns:campaign:copy") + with self.SessionLocal() as session: + campaign = session.get(Campaign, "campaign-1") + assert campaign is not None + policy = campaign_lifecycle_policy( + session, + campaign=campaign, + principal=principal, + version_id="version-2", + ) + self.assertTrue(policy["actions"]["copy_campaign"]["allowed"]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_campaign_scheduling.py b/tests/test_campaign_scheduling.py new file mode 100644 index 0000000..d4c9421 --- /dev/null +++ b/tests/test_campaign_scheduling.py @@ -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 diff --git a/tests/test_documentation.py b/tests/test_documentation.py index 16a35be..4035b3b 100644 --- a/tests/test_documentation.py +++ b/tests/test_documentation.py @@ -416,7 +416,10 @@ def test_static_campaign_handbook_has_unique_ids_help_contexts_and_no_planned_re "campaign.settings", "campaign.fields", "campaign.template", + "campaign.template.content-library", + "campaigns.action.schedule-drafts", "campaign.attachments", + "campaign.attachments.residual-files", "campaign.recipients", "campaign.recipient-data", "campaign.server-settings", diff --git a/tests/test_manifest_navigation.py b/tests/test_manifest_navigation.py index 636fa7b..f046152 100644 --- a/tests/test_manifest_navigation.py +++ b/tests/test_manifest_navigation.py @@ -19,7 +19,9 @@ def test_operator_queue_is_an_integrated_campaign_view() -> None: assert "/operator" not in {item.path for item in manifest.frontend.nav_items} routes = {route.path: route for route in manifest.frontend.routes} - assert "/operator" not in routes + legacy_redirect = routes["/operator"] + assert legacy_redirect.component == "OperatorQueueRedirect" + assert legacy_redirect.surface_id == "campaigns.route.operator-redirect" queue = routes["/campaigns/queue"] assert queue.component == "OperatorQueuePage" assert queue.required_all == ("campaigns:campaign:read",) diff --git a/tests/test_route_registration.py b/tests/test_route_registration.py index 02fac54..2deb500 100644 --- a/tests/test_route_registration.py +++ b/tests/test_route_registration.py @@ -9,6 +9,7 @@ from govoplan_campaign.backend.routes.delivery import router as delivery_router from govoplan_campaign.backend.routes.jobs import router as jobs_router from govoplan_campaign.backend.routes.operations import router as operations_router from govoplan_campaign.backend.routes.reports import router as reports_router +from govoplan_campaign.backend.routes.schedules import router as schedules_router from govoplan_campaign.backend.routes.sharing import router as sharing_router from govoplan_campaign.backend.routes.versions import router as versions_router @@ -28,6 +29,7 @@ def test_campaign_router_composes_every_workflow_operation_once() -> None: versions_router, jobs_router, reports_router, + schedules_router, sharing_router, delivery_router, attachments_router, @@ -40,7 +42,7 @@ def test_campaign_router_composes_every_workflow_operation_once() -> None: actual = _operation_keys(router) assert actual == expected - assert len(actual) == 72 + assert len(actual) == 80 assert not [operation for operation, count in Counter(actual).items() if count > 1] @@ -54,6 +56,7 @@ def test_key_routes_are_owned_by_their_focused_router() -> None: (versions_router, ("POST", "/campaigns/versions/{version_id}/build")), (jobs_router, ("GET", "/campaigns/{campaign_id}/jobs")), (reports_router, ("GET", "/campaigns/{campaign_id}/report")), + (schedules_router, ("GET", "/campaigns/{campaign_id}/schedules")), (sharing_router, ("POST", "/campaigns/{campaign_id}/shares")), (delivery_router, ("POST", "/campaigns/{campaign_id}/send-now")), ( diff --git a/tests/test_search_source.py b/tests/test_search_source.py index 04b0e21..50b4be6 100644 --- a/tests/test_search_source.py +++ b/tests/test_search_source.py @@ -20,6 +20,7 @@ from govoplan_core.core.search import ( SearchBackfillRequest, SearchResourceReference, ) +from govoplan_core.core.change_sequence import ChangeSequenceEntry from govoplan_core.db.base import Base @@ -34,6 +35,7 @@ class CampaignSearchSourceTests(unittest.TestCase): Group.__table__, Campaign.__table__, CampaignShare.__table__, + ChangeSequenceEntry.__table__, ), ) self.session = Session(self.engine) diff --git a/webui/src/api/campaigns.ts b/webui/src/api/campaigns.ts index 5a46774..08b1705 100644 --- a/webui/src/api/campaigns.ts +++ b/webui/src/api/campaigns.ts @@ -102,6 +102,66 @@ export type CampaignLifecyclePolicy = { provenance: Record; }; +export type CampaignCopyOptions = { + name?: string; + external_id?: string; + include_recipients: boolean; + include_files: boolean; + include_shares: boolean; + include_policies: boolean; + include_mail_profile: boolean; +}; + +export type CampaignScheduleOccurrence = { + id: string; + schedule_id: string; + scheduled_for: string; + status: "prepared" | "failed" | string; + generated_campaign_id?: string | null; + generated_version_id?: string | null; + error?: string | null; + created_at: string; +}; + +export type CampaignSchedule = { + id: string; + campaign_id: string; + source_version_id: string; + name: string; + recurrence_kind: "once" | "daily" | "weekly" | "monthly"; + interval_count: number; + timezone: string; + starts_at: string; + next_fire_at?: string | null; + ends_at?: string | null; + max_occurrences: number; + occurrence_count: number; + active: boolean; + resource_revision: number; + last_fired_at?: string | null; + last_campaign_id?: string | null; + last_error?: string | null; + created_at: string; + updated_at: string; + occurrences: CampaignScheduleOccurrence[]; +}; + +export type CampaignScheduleCreate = { + source_version_id: string; + name: string; + recurrence_kind: CampaignSchedule["recurrence_kind"]; + interval_count: number; + timezone: string; + starts_at: string; + ends_at?: string | null; + max_occurrences: number; + include_recipients: boolean; + include_files: boolean; + include_shares: boolean; + include_policies: boolean; + include_mail_profile: boolean; +}; + export type CampaignVersionDetail = CampaignVersionListItem & { raw_json: Record; campaign_json?: Record; @@ -431,6 +491,50 @@ export type CampaignPrintTemplatesResponse = { templates: CampaignPrintTemplate[]; }; +export type CampaignContentLibraryTarget = "subject" | "text" | "html"; + +export type CampaignContentLibraryItem = { + id: string; + name: string; + description?: string | null; + template_type: string; + kind: "fragment" | "campaign_part"; + status: string; + scope_type: "tenant" | "group" | "user"; + scope_id?: string | null; + read_only: boolean; + current_revision: number; + revision: number; + revision_id: string; + locale?: string | null; + published: boolean; + targets: CampaignContentLibraryTarget[]; + subject?: string | null; + text?: string | null; + html?: string | null; + body_mode: "text" | "html" | "both"; +}; + +export type CampaignContentLibraryResponse = { + available: boolean; + writable: boolean; + reason?: string | null; + items: CampaignContentLibraryItem[]; +}; + +export type CampaignContentLibrarySaveInput = { + name: string; + description?: string | null; + kind: "fragment" | "campaign_part"; + target?: CampaignContentLibraryTarget | null; + subject?: string | null; + text?: string | null; + html?: string | null; + body_mode: "text" | "html" | "both"; + locale: string; + visibility: "personal" | "tenant"; +}; + export type CampaignRecipientSnapshotItem = { contact_id: string; display_name: string; @@ -991,6 +1095,26 @@ query = "") return apiFetch(settings, `/api/v1/campaigns/${campaignId}/print-templates${suffix}`); } +export async function listCampaignContentLibrary( +settings: ApiSettings, +campaignId: string, +query = "") +: Promise { + const suffix = query.trim() ? `?query=${encodeURIComponent(query.trim())}` : ""; + return apiFetch(settings, `/api/v1/campaigns/${campaignId}/content-library${suffix}`); +} + +export async function saveCampaignContentLibraryItem( +settings: ApiSettings, +campaignId: string, +payload: CampaignContentLibrarySaveInput) +: Promise<{template: CampaignContentLibraryItem;}> { + return apiFetch<{template: CampaignContentLibraryItem;}>(settings, `/api/v1/campaigns/${campaignId}/content-library`, { + method: "POST", + body: JSON.stringify(payload) + }); +} + export async function snapshotCampaignRecipientAddressSource( settings: ApiSettings, campaignId: string, @@ -1052,17 +1176,53 @@ export async function copyCampaign( settings: ApiSettings, campaignId: string, sourceVersionId: string, -expectedStateToken: string) +expectedStateToken: string, +options: CampaignCopyOptions) : Promise { return apiFetch(settings, `/api/v1/campaigns/${campaignId}/copies`, { method: "POST", body: JSON.stringify({ source_version_id: sourceVersionId, - expected_state_token: expectedStateToken + expected_state_token: expectedStateToken, + ...options, + name: options.name?.trim() || undefined, + external_id: options.external_id?.trim() || undefined }) }); } +export async function listCampaignSchedules( +settings: ApiSettings, +campaignId: string) +: Promise { + 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 { + return apiFetch(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 { + return apiFetch(settings, `/api/v1/campaigns/${campaignId}/schedules/${scheduleId}`, { + method: "PATCH", + body: JSON.stringify({ active, base_revision: baseRevision }) + }); +} + export async function archiveCampaignVersion( settings: ApiSettings, campaignId: string, diff --git a/webui/src/features/campaigns/AttachmentsDataPage.tsx b/webui/src/features/campaigns/AttachmentsDataPage.tsx index 0962857..e905f1a 100644 --- a/webui/src/features/campaigns/AttachmentsDataPage.tsx +++ b/webui/src/features/campaigns/AttachmentsDataPage.tsx @@ -7,6 +7,7 @@ import { Card } from "@govoplan/core-webui"; import { PageTitle } from "@govoplan/core-webui"; import { LoadingFrame } from "@govoplan/core-webui"; import { MetricCard } from "@govoplan/core-webui"; +import { FormField } from "@govoplan/core-webui"; import LockedVersionNotice from "./components/LockedVersionNotice"; import VersionLine from "./components/VersionLine"; import { ToggleSwitch } from "@govoplan/core-webui"; @@ -56,6 +57,9 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings: const basePaths = useMemo(() => normalizeAttachmentBasePaths(attachments.base_paths, attachments), [attachments]); const globalRules = useMemo(() => normalizeAttachmentRules(attachments.global), [attachments.global]); const zipConfig = useMemo(() => normalizeAttachmentZipCollection(attachments.zip), [attachments.zip]); + const residualFiles = asRecord(attachments.residual_files); + const residualMode = ["report", "attach"].includes(String(residualFiles.mode)) ? String(residualFiles.mode) : "none"; + const residualRecipient = asRecord(residualFiles.recipient); const filenameFieldOptions = useMemo(() => buildZipFilenameFieldOptions(displayDraft), [displayDraft]); const passwordFields = useMemo(() => getDraftFields(displayDraft).filter((field) => field.type === "password"), [displayDraft]); const zipArchiveNameValidation = useMemo( @@ -96,6 +100,18 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings: markDirty(); } + function patchResidualFiles(next: Record) { + 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) { patchBasePaths(basePaths.map((basePath, currentIndex) => currentIndex === index ? { ...basePath, ...patch } : basePath)); } @@ -271,6 +287,48 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings: + +
+ + + + {residualMode !== "none" && <> + + patchResidualFiles({ recipient: { ...residualRecipient, email: event.target.value } })} /> + + + patchResidualFiles({ recipient: { ...residualRecipient, name: event.target.value || null } })} /> + +
+ + patchResidualFiles({ subject: event.target.value })} /> + +
+
+ +