Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19437ce378 | ||
|
|
8bca4fc728 | ||
|
|
c51fc180fb | ||
|
|
1b32427813 | ||
|
|
c21fb4cf7c | ||
|
|
b41f23c901 | ||
|
|
3934e7fedb | ||
|
|
1bd24f9b5b | ||
|
|
4f52f010ee | ||
|
|
2630498026 | ||
|
|
c2f083e5f6 | ||
|
|
5a21067e44 | ||
|
|
73cfad209a | ||
|
|
2a00d910df | ||
|
|
c846c249b8 | ||
|
|
69e6588a89 | ||
|
|
75e8f864a7 | ||
|
|
d5b874c469 | ||
|
|
5c27527725 | ||
|
|
112ef9dc31 | ||
|
|
f4fe534ee1 | ||
|
|
b6af7665f4 | ||
|
|
14e94873a9 | ||
|
|
8e2f9d743d | ||
|
|
039ce35e78 | ||
|
|
7bc5e4a35c | ||
|
|
06f773e4eb | ||
|
|
9a2f13bc9a | ||
|
|
cf8d7fab11 | ||
|
|
68459fde15 | ||
|
|
c2efd6b7bd | ||
|
|
696f8f6385 | ||
|
|
6562484d32 | ||
|
|
2db99eaf6a |
@@ -59,6 +59,32 @@ Files, Mail, Distribution Lists, Templates, Postbox, and Calendar are optional m
|
||||
|
||||
Hybrid delivery never treats an opt-in as an implicit duplicate-send instruction. The Campaign author selects one primary route per recipient and may select a supported fallback. A fallback runs only after the first channel rejects before acceptance; accepted or outcome-unknown effects stop cross-channel retry. Printable output is generated once during build, optionally persisted through Files, reviewed with the exact Campaign version, and accepted idempotently per recipient job during delivery.
|
||||
|
||||
Recurring schedules have two immutable modes. Manual mode remains the default
|
||||
and prepares independent drafts without Mail. Autonomous mode is explicit and
|
||||
Mail-only: it seals an already built and explicitly approved execution snapshot,
|
||||
rechecks approval, policy, credential/transport revision, live SMTP health,
|
||||
recipient and attachment evidence before each occurrence, and submits one
|
||||
Mail-owned durable command per frozen message. Occurrence-scoped idempotency is
|
||||
allocated before delivery. Accepted and outcome-unknown effects are never
|
||||
retried automatically; uncertain or systemic failures pause the schedule,
|
||||
notify its accountable operator, and retain non-secret recovery evidence.
|
||||
Generated EML retention excludes source versions while an autonomous schedule
|
||||
has a remaining occurrence, including while it is paused; once the schedule
|
||||
finishes, already accepted Mail commands retain their own encrypted payload and
|
||||
evidence under Mail policy.
|
||||
|
||||
Campaign versions can also be exported as versioned portable JSON packages and
|
||||
imported as independently owned drafts. The privacy-safe export default is
|
||||
metadata plus template/configuration. Recipients, attachment rules, aggregate
|
||||
review state, and recipient-level delivery history are separate scopes with
|
||||
their existing fine-grained permissions. Packages include source provenance,
|
||||
scope/item/redaction manifests, and a SHA-256 integrity digest. They never
|
||||
contain attachment bytes, transport secrets, credential references,
|
||||
password-field values, local storage locators, shares, or ownership grants.
|
||||
Import previews schema and checksum compatibility plus every created/skipped
|
||||
domain. It clears deployment-bound Mail references and never replays locks,
|
||||
approvals, review decisions, jobs, attempts, or sent state.
|
||||
|
||||
Public campaign, version, job, and report responses expose business data and
|
||||
delivery evidence, but never process-local paths, storage-backend keys, or
|
||||
worker claim tokens. Operational troubleshooting uses the dedicated job
|
||||
@@ -78,6 +104,9 @@ services can cooperate without importing campaign internals:
|
||||
- `campaigns.policyContext` for retention/policy provenance
|
||||
- `campaigns.deliveryTasks` for queued send and append-to-Sent workers
|
||||
- `campaigns.retention` for campaign-owned retention cleanup
|
||||
- `privacy.dsar.campaigns` for tenant-scoped recipient, version, delivery,
|
||||
report-projection, and artifact-metadata discovery plus governed erasure
|
||||
planning
|
||||
|
||||
Keep these capability payloads narrow: stable ids, policy payloads, and task
|
||||
results only.
|
||||
@@ -120,6 +149,11 @@ Platform RBAC and governance rules are documented in `govoplan-core/docs/`.
|
||||
- [Campaign handbook](docs/CAMPAIGN_HANDBOOK.md) provides the adaptive user, process, governance, technical, and operations perspectives.
|
||||
- [Campaign delivery runbook](docs/CAMPAIGN_DELIVERY_RUNBOOK.md) covers queueing, local vs Celery operation, retries, reconciliation, reports, and the live SMTP/IMAP test checklist.
|
||||
- Immediate delivery is bounded to 25 exact eligible recipient jobs by default. Deployments may set `GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS` (0–500), and tenants may narrow that ceiling through `campaign_delivery_policy.synchronous_send_max_recipients` in tenant settings.
|
||||
- Immediate Mail delivery preflights the selected SMTP transport before the
|
||||
first effect and reuses a healthy bounded connection through Mail. Review and
|
||||
send reports the batch state, connection/reconnect counts, and paused count.
|
||||
A systemic authentication, sender, or connectivity failure pauses remaining
|
||||
jobs; correct and test the Mail profile before explicitly resuming them.
|
||||
- Report-email preview uses the selected version's stored v5 Mail-profile evidence. Live report email fails closed until [govoplan-mail#17](https://git.add-ideas.de/GovOPlaN/govoplan-mail/issues/17) provides a durable, idempotent Mail-owned outbox and transport-attempt ledger; per-job CSV is off by default and requires `campaigns:recipient:export` when requested.
|
||||
- [Campaign/Mail profile boundary](docs/MAIL_PROFILE_BOUNDARY.md) defines profile-only delivery, runtime resolution, execution evidence, and the fail-closed legacy migration path.
|
||||
- [Recipient import guide](docs/RECIPIENT_IMPORT_GUIDE.md) covers user/admin workflows, mapping profiles, validation, and import evidence.
|
||||
|
||||
@@ -29,7 +29,7 @@ import tomllib
|
||||
from collections import Counter
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Iterator, Mapping, Protocol
|
||||
from uuid import uuid4
|
||||
@@ -66,6 +66,7 @@ SEND_RESULT_STATUSES = frozenset(
|
||||
{
|
||||
"already_accepted",
|
||||
"already_claimed",
|
||||
"already_sending",
|
||||
"cancelled",
|
||||
"dry_run",
|
||||
"failed",
|
||||
@@ -135,11 +136,21 @@ SMTP_FAULT_MODES = frozenset(
|
||||
}
|
||||
)
|
||||
WORKER_TASK_CODE = """
|
||||
import os
|
||||
import sys
|
||||
from govoplan_core.celery_app import send_email
|
||||
from types import SimpleNamespace
|
||||
from govoplan_core.celery_app import send_email, _worker_runtime_identity
|
||||
from govoplan_core.core.runtime_coordination import register_runtime_node
|
||||
from govoplan_core.db.session import get_database
|
||||
|
||||
identity = _worker_runtime_identity(SimpleNamespace(hostname=f"campaign-acceptance-{os.getpid()}"))
|
||||
with get_database().SessionLocal() as session:
|
||||
register_runtime_node(session, identity, metadata={"acceptance_worker_pid": os.getpid()})
|
||||
session.commit()
|
||||
|
||||
result = send_email.run(sys.argv[1])
|
||||
if not isinstance(result, dict) or result.get("status") not in {
|
||||
"already_sending",
|
||||
"outcome_unknown",
|
||||
"smtp_accepted",
|
||||
}:
|
||||
@@ -1206,6 +1217,92 @@ def _wait_for_worker_process(process: subprocess.Popen[bytes], *, timeout_second
|
||||
raise AcceptanceError("Restarted Campaign worker task failed")
|
||||
|
||||
|
||||
def recover_stopped_fixture_claim(
|
||||
client: ApiClient, headers: Mapping[str, str], *, database: Any,
|
||||
runtime_root: Path, campaign_id: str, version_id: str,
|
||||
stopped_process: subprocess.Popen[bytes],
|
||||
) -> dict[str, bool]:
|
||||
"""Model supervisor proof ONLY in this runner's disposable SQLite fixture.
|
||||
|
||||
No job, attempt or recovery-operation state is edited here. After proving
|
||||
the exact fixture process exited, expire only its lease and mark its own
|
||||
runtime stopped. The real fenced HTTP action performs domain recovery.
|
||||
"""
|
||||
from sqlalchemy.engine import make_url
|
||||
from sqlalchemy.exc import ArgumentError
|
||||
from govoplan_core.core.runtime_coordination import DistributedLease, RuntimeNode, process_runtime_identity
|
||||
from govoplan_campaign.backend.db.models import CampaignJob
|
||||
from govoplan_campaign.backend.services.delivery_recovery import job_recovery_metadata
|
||||
|
||||
root = runtime_root.resolve()
|
||||
expected_database = root / "acceptance.db"
|
||||
allowed_parents = {Path(tempfile.gettempdir()).resolve(), Path("/tmp").resolve()}
|
||||
if (
|
||||
os.environ.get("APP_ENV") != "test"
|
||||
or root.parent not in allowed_parents
|
||||
or not root.name.startswith(("govoplan-campaign-greenmail-", "govoplan-campaign-celery-redelivery-"))
|
||||
or not expected_database.is_file() or expected_database.is_symlink()
|
||||
or database.engine.url.get_backend_name() != "sqlite"
|
||||
or not database.engine.url.database
|
||||
or Path(database.engine.url.database).resolve() != expected_database
|
||||
):
|
||||
raise AcceptanceError("Claim proof is restricted to the runner's isolated temporary SQLite fixture")
|
||||
try:
|
||||
environment_url = make_url(os.environ.get("DATABASE_URL", ""))
|
||||
except ArgumentError:
|
||||
raise AcceptanceError("Claim proof requires the isolated fixture database environment") from None
|
||||
if (
|
||||
environment_url.get_backend_name() != "sqlite" or not environment_url.database
|
||||
or Path(environment_url.database).resolve() != expected_database
|
||||
):
|
||||
raise AcceptanceError("Claim proof database does not match the isolated fixture environment")
|
||||
if stopped_process.poll() is None or stopped_process.returncode is None:
|
||||
raise AcceptanceError("Claim proof requires the exact fixture worker to have exited")
|
||||
identity = process_runtime_identity()
|
||||
with database.SessionLocal() as session:
|
||||
jobs = session.query(CampaignJob).filter(
|
||||
CampaignJob.campaign_id == campaign_id,
|
||||
CampaignJob.campaign_version_id == version_id,
|
||||
).all()
|
||||
if len(jobs) != 1 or jobs[0].send_status != "sending" or not jobs[0].claim_token:
|
||||
raise AcceptanceError("Claim proof requires exactly one unfinished fixture send")
|
||||
job = jobs[0]
|
||||
lease = session.query(DistributedLease).filter(
|
||||
DistributedLease.installation_id == identity.installation_id,
|
||||
DistributedLease.resource_key == f"campaign:delivery:{job.tenant_id}:{job.id}",
|
||||
).one_or_none()
|
||||
node = session.query(RuntimeNode).filter(
|
||||
RuntimeNode.installation_id == identity.installation_id,
|
||||
RuntimeNode.node_id == lease.holder_node_id,
|
||||
).one_or_none() if lease else None
|
||||
if (
|
||||
lease is None or node is None or node.incarnation != lease.holder_incarnation
|
||||
or (node.metadata_ or {}).get("acceptance_worker_pid") != stopped_process.pid
|
||||
):
|
||||
raise AcceptanceError("Stopped fixture process does not own this exact runtime claim")
|
||||
node.state = "stopped"
|
||||
node.stopped_at = datetime.now(timezone.utc)
|
||||
lease.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
|
||||
session.commit()
|
||||
metadata = job_recovery_metadata(session, [job])[job.id]["smtp"]
|
||||
if not metadata["eligible"]:
|
||||
raise AcceptanceError("Proven stopped fixture claim is not eligible for fenced recovery")
|
||||
job_id = job.id
|
||||
revision = metadata["revision"]
|
||||
payload = _expect(client.post(
|
||||
f"/api/v1/campaigns/{campaign_id}/jobs/{job_id}/recover-claim",
|
||||
headers=dict(headers),
|
||||
json={
|
||||
"channel": "smtp", "expected_revision": revision,
|
||||
"note": "Verified isolated fixture worker exited after DATA; expired its test lease. Mailbox/provider reconciliation is still required.",
|
||||
},
|
||||
), 200, "Explicit fenced fixture claim recovery")
|
||||
result = payload.get("result") if isinstance(payload.get("result"), dict) else {}
|
||||
if result.get("send_status") != "outcome_unknown" or result.get("reconciliation_required") is not True:
|
||||
raise AcceptanceError("Fenced recovery did not preserve the unknown SMTP outcome")
|
||||
return {"stopped_process_verified": True, "fixture_lease_expired": True, "explicit_fenced_recovery": True}
|
||||
|
||||
|
||||
def execute_worker_interruption_scenario(
|
||||
client: ApiClient,
|
||||
headers: Mapping[str, str],
|
||||
@@ -1218,6 +1315,7 @@ def execute_worker_interruption_scenario(
|
||||
audit_probe: Callable[[str, str], Mapping[str, int]],
|
||||
delivery_probe: Callable[[str, str], Mapping[str, Any]],
|
||||
worker_job_probe: Callable[[str], str],
|
||||
recover_claim: Callable[[str, str, subprocess.Popen[bytes]], Mapping[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
prepared = prepare_campaign_scenario(
|
||||
client,
|
||||
@@ -1286,23 +1384,30 @@ def execute_worker_interruption_scenario(
|
||||
restarted_state = _durable_state_evidence(
|
||||
delivery_probe(prepared.campaign_id, prepared.version_id)
|
||||
)
|
||||
expected_restarted_state = {
|
||||
if restarted_state != interrupted_state:
|
||||
raise AcceptanceError("Duplicate task changed an unfinished SMTP attempt without stopped-runtime proof")
|
||||
|
||||
protocol_evidence = endpoint.evidence()
|
||||
expected_protocol = {
|
||||
"connection_count": 1,
|
||||
"accepted_rcpt_commands": 1,
|
||||
"refused_rcpt_commands": 0,
|
||||
"data_transactions": 1,
|
||||
}
|
||||
if protocol_evidence != expected_protocol:
|
||||
raise AcceptanceError("Restarted worker contacted SMTP or produced an unexpected transaction")
|
||||
recovery_evidence = dict(recover_claim(prepared.campaign_id, prepared.version_id, first_worker))
|
||||
recovered_state = _durable_state_evidence(delivery_probe(prepared.campaign_id, prepared.version_id))
|
||||
expected_recovered_state = {
|
||||
"job_count": 1,
|
||||
"send_status_counts": {"outcome_unknown": 1},
|
||||
"attempt_status_counts": {"outcome_unknown": 1},
|
||||
"unfinished_attempt_count": 0,
|
||||
}
|
||||
if restarted_state != expected_restarted_state:
|
||||
raise AcceptanceError("Restarted worker did not freeze the unfinished SMTP attempt")
|
||||
|
||||
protocol_evidence = endpoint.evidence()
|
||||
if protocol_evidence != {
|
||||
"connection_count": 1,
|
||||
"accepted_rcpt_commands": 1,
|
||||
"refused_rcpt_commands": 0,
|
||||
"data_transactions": 1,
|
||||
}:
|
||||
raise AcceptanceError("Restarted worker contacted SMTP or produced an unexpected transaction")
|
||||
if recovered_state != expected_recovered_state:
|
||||
raise AcceptanceError("Explicit fenced recovery did not freeze the unfinished SMTP attempt")
|
||||
if endpoint.evidence() != expected_protocol:
|
||||
raise AcceptanceError("Explicit claim recovery contacted SMTP")
|
||||
|
||||
report = _expect(
|
||||
client.get(
|
||||
@@ -1333,12 +1438,15 @@ def execute_worker_interruption_scenario(
|
||||
"queue": queue_evidence,
|
||||
"interrupted_durable_state": interrupted_state,
|
||||
"restarted_durable_state": restarted_state,
|
||||
"recovered_durable_state": recovered_state,
|
||||
"claim_recovery": recovery_evidence,
|
||||
"protocol": protocol_evidence,
|
||||
"report": report_evidence,
|
||||
"audit_actions": audit_actions,
|
||||
"process_boundary": {
|
||||
"dedicated_task_process_terminated_after_data": True,
|
||||
"fresh_task_process_completed": True,
|
||||
"duplicate_task_left_sending_unchanged": True,
|
||||
"duplicate_smtp_transaction_prevented": True,
|
||||
"celery_broker_redelivery_exercised": False,
|
||||
},
|
||||
@@ -1355,6 +1463,7 @@ def run_acceptance(
|
||||
audit_probe: Callable[[str, str], Mapping[str, int]],
|
||||
delivery_probe: Callable[[str, str], Mapping[str, Any]],
|
||||
worker_job_probe: Callable[[str], str],
|
||||
recover_claim: Callable[[str, str, subprocess.Popen[bytes]], Mapping[str, Any]],
|
||||
include_failure_drills: bool,
|
||||
module_versions: Mapping[str, str],
|
||||
) -> dict[str, Any]:
|
||||
@@ -1619,6 +1728,7 @@ def run_acceptance(
|
||||
audit_probe=audit_probe,
|
||||
delivery_probe=delivery_probe,
|
||||
worker_job_probe=worker_job_probe,
|
||||
recover_claim=recover_claim,
|
||||
)
|
||||
drills["worker_interruption"] = worker_interruption
|
||||
|
||||
@@ -1647,6 +1757,8 @@ def run_acceptance(
|
||||
"post_data_connection_loss_outcome_unknown": include_failure_drills,
|
||||
"source_artifact_provenance": False,
|
||||
"worker_restart_interruption": include_failure_drills,
|
||||
"duplicate_worker_leaves_active_claim_unchanged": include_failure_drills,
|
||||
"explicit_stopped_runtime_fenced_recovery": include_failure_drills,
|
||||
"celery_broker_redelivery": False,
|
||||
},
|
||||
}
|
||||
@@ -1888,6 +2000,11 @@ def _bootstrap_and_run(
|
||||
audit_probe=audit_probe,
|
||||
delivery_probe=delivery_probe,
|
||||
worker_job_probe=worker_job_probe,
|
||||
recover_claim=lambda campaign_id, version_id, process: recover_stopped_fixture_claim(
|
||||
client, {"Authorization": f"Bearer {access_token}"}, database=database,
|
||||
runtime_root=runtime_root, campaign_id=campaign_id, version_id=version_id,
|
||||
stopped_process=process,
|
||||
),
|
||||
include_failure_drills=include_failure_drills,
|
||||
module_versions=module_versions,
|
||||
)
|
||||
|
||||
@@ -5,8 +5,9 @@ The default run starts an isolated Redis Compose service, two successive real
|
||||
Celery worker processes, and a controlled loopback SMTP endpoint. It kills the
|
||||
first worker after complete DATA but before a final SMTP response. The same
|
||||
unacknowledged broker task must be delivered to the replacement worker, which
|
||||
must freeze the unfinished durable attempt as ``outcome_unknown`` without a
|
||||
second SMTP connection or DATA transaction.
|
||||
must leave the unfinished durable attempt unchanged without a second SMTP
|
||||
connection or DATA transaction. Only then does explicit fenced recovery use
|
||||
verified process exit and an expired fixture lease to record outcome-unknown.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -52,6 +53,7 @@ from run_campaign_acceptance import ( # noqa: E402
|
||||
create_mail_profile,
|
||||
prepare_campaign_scenario,
|
||||
required_composition_versions,
|
||||
recover_stopped_fixture_claim,
|
||||
smtp_fault_endpoint,
|
||||
)
|
||||
|
||||
@@ -72,6 +74,13 @@ import os
|
||||
import sys
|
||||
|
||||
from govoplan_core.celery_app import celery
|
||||
from govoplan_core import celery_app as worker_runtime
|
||||
|
||||
# Test-only process identity evidence, confined to this disposable child.
|
||||
original_worker_metadata = worker_runtime._worker_metadata
|
||||
worker_runtime._worker_metadata = lambda: {
|
||||
**original_worker_metadata(), "acceptance_worker_pid": os.getpid(),
|
||||
}
|
||||
|
||||
visibility_timeout = int(os.environ["GOVOPLAN_CAMPAIGN_TEST_REDIS_VISIBILITY_TIMEOUT_SECONDS"])
|
||||
celery.conf.broker_transport_options = {
|
||||
@@ -408,6 +417,7 @@ def execute_redelivery_scenario(
|
||||
snapshot_probe: Callable[[str], tuple[Mapping[str, Any], Mapping[str, Any]]],
|
||||
audit_probe: Callable[[str, str], Mapping[str, int]],
|
||||
delivery_probe: Callable[[str, str], Mapping[str, Any]],
|
||||
recover_claim: Callable[[str, str, subprocess.Popen[bytes]], Mapping[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
profile_id = create_mail_profile(
|
||||
client,
|
||||
@@ -488,9 +498,23 @@ def execute_redelivery_scenario(
|
||||
task_id=redelivered_task_id,
|
||||
timeout_seconds=settings.provider_timeout_seconds,
|
||||
)
|
||||
recovered_state = _durable_state_evidence(
|
||||
redelivered_state = _durable_state_evidence(
|
||||
delivery_probe(prepared.campaign_id, prepared.version_id)
|
||||
)
|
||||
if redelivered_state != interrupted_state:
|
||||
raise AcceptanceError("Redelivered task changed an unfinished attempt without stopped-runtime proof")
|
||||
expected_protocol = {
|
||||
"connection_count": 1,
|
||||
"accepted_rcpt_commands": 1,
|
||||
"refused_rcpt_commands": 0,
|
||||
"data_transactions": 1,
|
||||
}
|
||||
if endpoint.evidence() != expected_protocol:
|
||||
raise AcceptanceError("Broker redelivery caused an unexpected SMTP transaction")
|
||||
recovery_evidence = dict(recover_claim(
|
||||
prepared.campaign_id, prepared.version_id, first_worker.process,
|
||||
))
|
||||
recovered_state = _durable_state_evidence(delivery_probe(prepared.campaign_id, prepared.version_id))
|
||||
expected_recovered = {
|
||||
"job_count": 1,
|
||||
"send_status_counts": {"outcome_unknown": 1},
|
||||
@@ -498,17 +522,11 @@ def execute_redelivery_scenario(
|
||||
"unfinished_attempt_count": 0,
|
||||
}
|
||||
if recovered_state != expected_recovered:
|
||||
raise AcceptanceError("Redelivered task did not freeze the unfinished attempt")
|
||||
raise AcceptanceError("Explicit fenced recovery did not freeze the unfinished attempt")
|
||||
|
||||
protocol = endpoint.evidence()
|
||||
expected_protocol = {
|
||||
"connection_count": 1,
|
||||
"accepted_rcpt_commands": 1,
|
||||
"refused_rcpt_commands": 0,
|
||||
"data_transactions": 1,
|
||||
}
|
||||
if protocol != expected_protocol:
|
||||
raise AcceptanceError("Broker redelivery caused an unexpected SMTP transaction")
|
||||
raise AcceptanceError("Explicit claim recovery caused an unexpected SMTP transaction")
|
||||
broker_after = _wait_for_broker_drained(
|
||||
redis_url,
|
||||
timeout_seconds=settings.provider_timeout_seconds,
|
||||
@@ -549,7 +567,9 @@ def execute_redelivery_scenario(
|
||||
**prepared.public_evidence(),
|
||||
"queue": queue_evidence,
|
||||
"interrupted_durable_state": interrupted_state,
|
||||
"redelivered_durable_state": redelivered_state,
|
||||
"recovered_durable_state": recovered_state,
|
||||
"claim_recovery": recovery_evidence,
|
||||
"protocol": protocol,
|
||||
"report": report,
|
||||
"audit_actions": audit_actions,
|
||||
@@ -566,6 +586,7 @@ def execute_redelivery_scenario(
|
||||
"first_worker_forced_exit": first_exit_code != 0,
|
||||
"replacement_worker_started": True,
|
||||
"replacement_worker_completed_redelivery": True,
|
||||
"duplicate_task_left_sending_unchanged": True,
|
||||
},
|
||||
}
|
||||
finally:
|
||||
@@ -743,6 +764,11 @@ def _bootstrap_and_run(
|
||||
snapshot_probe=snapshot_probe,
|
||||
audit_probe=audit_probe,
|
||||
delivery_probe=delivery_probe,
|
||||
recover_claim=lambda campaign_id, version_id, process: recover_stopped_fixture_claim(
|
||||
client, {"Authorization": f"Bearer {access_token}"}, database=database,
|
||||
runtime_root=runtime_root, campaign_id=campaign_id, version_id=version_id,
|
||||
stopped_process=process,
|
||||
),
|
||||
)
|
||||
|
||||
evidence = {
|
||||
@@ -766,6 +792,8 @@ def _bootstrap_and_run(
|
||||
"celery_worker_processes": True,
|
||||
"forced_worker_loss_after_complete_data": True,
|
||||
"same_task_broker_redelivery": True,
|
||||
"redelivery_leaves_active_claim_unchanged": True,
|
||||
"explicit_stopped_runtime_fenced_recovery": True,
|
||||
"durable_outcome_unknown_recovery": True,
|
||||
"duplicate_smtp_transaction_prevented": True,
|
||||
"production_daemon_supervisor": False,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
# Campaign Accessibility Review
|
||||
|
||||
The Campaign WebUI uses Core's semantic `Button`, `Dialog`, `DataGrid`, form,
|
||||
alert, and segmented-control components. Core owns focus trapping, focus return,
|
||||
Escape handling, labels, disabled state, and keyboard behavior for those shared
|
||||
alert, and segmented-control components. Its page frames use Core `PageLayout`
|
||||
and its full-canvas navigation/content shells use `WorkspaceLayout`, so pane
|
||||
scrolling, sticky headings, action collapse, narrow-layout behavior, and help
|
||||
scope remain platform-owned. Core also owns focus trapping, focus return,
|
||||
Escape handling, labels, disabled state, and keyboard behavior for the shared
|
||||
primitives.
|
||||
|
||||
## Repeatable review matrix
|
||||
|
||||
@@ -10,19 +10,60 @@ and they must identify that inheritance explicitly.
|
||||
- Campaign version
|
||||
- Campaign delivery job / built message
|
||||
- Computed Campaign report, identified by Campaign, version, and report kind
|
||||
- Recipient row, identified by `<version UUID>:<job UUID>`
|
||||
- Frozen recipient source snapshot, identified by its Campaign-version UUID
|
||||
- Campaign attachment binding and version-bound frozen attachment resolution
|
||||
- Persisted validation issue, version-bound review decision, and attachment-policy override
|
||||
- SMTP, IMAP append, Postbox, and printable-output attempts
|
||||
- Message action, message-action attempt, and job reconciliation decision
|
||||
- Campaign share and Core-owned Campaign ownership-transfer record
|
||||
- Independently user-owned recipient import mapping profile
|
||||
- Saved recipient import execution, identified by `<version UUID>:<import UUID>`
|
||||
- Persisted validation, build, execution-snapshot, and review evidence, identified
|
||||
by `<version UUID>:<artifact kind>`
|
||||
|
||||
## Planned Slices
|
||||
All persisted child IDs are random UUIDs. Embedded build/review children use a
|
||||
version UUID plus a random job UUID, so callers cannot enumerate a recipient
|
||||
index or infer an address. A version mismatch is reported as a stale reference.
|
||||
Missing and cross-tenant children use the same non-disclosing not-found
|
||||
provenance. Explanations never include recipient addresses, source rows,
|
||||
filenames, object locators, transport responses, worker claims, target
|
||||
snapshots, diagnostic text, or reconciliation notes.
|
||||
|
||||
1. Recipient rows and imported recipient-source snapshots
|
||||
2. Attachment bindings and frozen attachment resolutions
|
||||
3. Validation issues, review decisions, and attachment-policy overrides
|
||||
4. Delivery attempts, IMAP append attempts, Postbox attempts, and
|
||||
reconciliation decisions
|
||||
5. Campaign shares and ownership-transfer records
|
||||
6. Import mapping profiles and import executions
|
||||
7. Reusable Campaign templates and template revisions when the template
|
||||
library becomes persistent
|
||||
8. Export packages and protocol/report artifacts
|
||||
## Permission matrix
|
||||
|
||||
| Evidence | Parent boundary | Further restriction |
|
||||
| --- | --- | --- |
|
||||
| Recipient row or source snapshot | Campaign read/owner/share | `campaigns:recipient:read` |
|
||||
| Attachment binding/resolution, validation, review, override | Campaign read/owner/share | Campaign review and `campaigns:diagnostic:read` |
|
||||
| Delivery status | Campaign read/owner/share | `campaigns:report:read` |
|
||||
| Transport or worker diagnostics | Campaign read/owner/share | `campaigns:diagnostic:read` |
|
||||
| Exported delivery evidence | Campaign read/owner/share | `campaigns:report:export` |
|
||||
| Reconciliation decision | Campaign read/owner/share | Campaign reconcile and diagnostic read |
|
||||
| Share or ownership transfer | Campaign governance | Campaign share, transfer-participant, group-acceptance, or recovery authority; content access remains a separate decision |
|
||||
| Import mapping profile | Independent user owner | `campaigns:recipient:import`; no Campaign share is inherited |
|
||||
| Import execution | Campaign read/owner/share | Recipient read and import authority |
|
||||
| Persisted protocol artifact | Campaign read/owner/share | Recipient, review, report, diagnostic, or export authority appropriate to the artifact |
|
||||
|
||||
Postbox, Mail/IMAP, and printable attempts keep bounded Campaign-owned evidence
|
||||
after provider acceptance. Their explanation therefore remains available when
|
||||
an optional provider module is later disabled. A missing attempt reports only
|
||||
the optional owner and `unavailable_or_hidden`; it does not distinguish absence
|
||||
from hidden data.
|
||||
|
||||
## Optional and unsupported owner boundaries
|
||||
|
||||
Reusable templates and template revisions are independently governed by the
|
||||
optional Templates module; Campaign never treats a Campaign share as a template
|
||||
grant. Durable export packages are independently governed by the optional
|
||||
Reporting module. Asking the Campaign provider to explain either class therefore
|
||||
fails closed with `independently_governed_by_optional_module` and
|
||||
`unavailable_or_hidden`. The response does not reveal whether the optional
|
||||
module is absent, the object does not exist, or the caller cannot see it.
|
||||
|
||||
Campaign reports generated on demand remain non-persisted, version-bound
|
||||
resources. Their explanation names the report kind and its Campaign/version
|
||||
parent, and keeps report read, export, and diagnostic permissions distinct.
|
||||
|
||||
Each child explanation must include:
|
||||
|
||||
@@ -38,3 +79,9 @@ Delivery attempts, review decisions, reports, and exports can contain more
|
||||
sensitive evidence than the Campaign summary. Their read and diagnostic/export
|
||||
permissions therefore remain independently enforceable even when the parent
|
||||
Campaign is readable.
|
||||
|
||||
Import explanations include only the stable import identity, source type,
|
||||
opaque source identity, source revision, and whether additional provenance was
|
||||
recorded. They never return imported rows, filenames, column mappings, or source
|
||||
metadata. Mapping-profile explanations expose only non-reversible header
|
||||
fingerprints and shape information; headers and mappings remain hidden.
|
||||
|
||||
@@ -30,9 +30,24 @@ been validated, built, reviewed, and locked.
|
||||
for attempts, outcomes, and reconciliation.
|
||||
- Confirm the effective Send now recipient-job limit. The safe default is 25;
|
||||
use Queue for workers for ordinary batches or any run above that limit.
|
||||
System administrators can explicitly configure 0–500 under Administration →
|
||||
SYSTEM → Campaign delivery; an explicit deployment ceiling remains binding,
|
||||
and TENANT policy may only narrow the inherited limit. This setting affects
|
||||
one synchronous request, not campaign size. It is audited and never sends
|
||||
messages; large interactive requests may encounter proxy timeouts.
|
||||
|
||||
## Deliverability Preflight
|
||||
|
||||
The Mail server connection test checks that selected server and credential. It
|
||||
does not authorize the campaign's sender, recipients, or resource selection.
|
||||
SMTP runtime checks require the selected SMTP credential when policy forbids
|
||||
inheritance, independently of IMAP. Sent-folder append checks IMAP credentials
|
||||
independently; full campaign validation still checks both required selections.
|
||||
Preflight errors distinguish Mail profile/credential policy, SMTP configuration,
|
||||
authentication, and connectivity. Do not change TLS or credential policies merely
|
||||
because a campaign preflight failed. A preflight rejection leaves staged jobs
|
||||
uncommitted and starts no message delivery.
|
||||
|
||||
Before the first live send for a sender domain or mail-server profile:
|
||||
|
||||
- Confirm the selected SMTP identity matches the visible From/envelope sender
|
||||
@@ -50,18 +65,29 @@ Before the first live send for a sender domain or mail-server profile:
|
||||
|
||||
## Queue And Send
|
||||
|
||||
1. Validate the version with file checks enabled.
|
||||
1. Link all required managed files, then validate the version with file checks
|
||||
enabled. Locking waits for a fresh attachment preview and asks for explicit
|
||||
**Link and lock** confirmation when matches are not linked. A locked version
|
||||
cannot change attachment links: use an editable version and repeat validation,
|
||||
build and review rather than assuming unlinked files were included.
|
||||
2. Build the version and inspect all blocking review items.
|
||||
3. Queue only after the selected version is the intended immutable execution
|
||||
version. Select **Queue for workers**, then verify the committed and
|
||||
published counts.
|
||||
4. Use **Send now** only if the exact eligible count is non-zero and at or below
|
||||
the effective deployment/tenant limit shown on the page.
|
||||
the effective deployment/system/tenant limit shown on the page.
|
||||
5. In worker mode, verify queue counters move from queued/claimed/sending to a
|
||||
terminal SMTP state.
|
||||
6. If a synchronous request is used, keep Review and send open: it polls the
|
||||
durable counters while the request runs. A rejection occurs before SMTP and
|
||||
directs oversized runs to workers.
|
||||
6. If a synchronous request is used, keep its blocking progress dialog open.
|
||||
Only its small version-scoped persisted counters refresh; the workspace,
|
||||
recipient list, attachment preview and full summary stay unchanged. Read-only
|
||||
refresh failure retains the last counters and does not prove delivery failed.
|
||||
A disconnected request may still be executing; never repeat it blindly.
|
||||
Oversized initial runs are rejected before SMTP and directed to workers.
|
||||
7. Review the SMTP batch line. `ready` means DNS/connectivity/TLS/auth preflight
|
||||
succeeded. Connection and reconnect counts explain reuse. `paused` means a
|
||||
systemic transport failure stopped the remaining jobs before their SMTP
|
||||
effect; test/correct the Mail profile and explicitly resume the queue.
|
||||
|
||||
## Outcome Handling
|
||||
|
||||
@@ -88,11 +114,15 @@ unknown provider attempt merely to repair the other layer's state.
|
||||
- `failed_temporary`: Retry explicitly after checking the error and retry count.
|
||||
- `failed_permanent`: Retry only if the operator has corrected the root cause and
|
||||
intentionally includes permanent failures.
|
||||
- `paused` after a systemic SMTP failure: do not resume until the shared Mail
|
||||
profile passes its connection test. Authentication, sender rejection, and
|
||||
unavailable connectivity affect the batch rather than one recipient.
|
||||
- `outcome_unknown`: Do not retry directly. Check SMTP logs, mailbox evidence, or
|
||||
provider control panels, then reconcile as accepted or not sent.
|
||||
- `claimed` or `sending` that does not progress: treat as a worker interruption.
|
||||
Re-run worker handling or reconcile if SMTP may already have accepted the
|
||||
message.
|
||||
- `claimed` or `sending` that does not progress: investigate the owning runtime.
|
||||
Duplicate worker handling leaves active state unchanged. Never infer from
|
||||
elapsed time alone that SMTP did not accept the message. Use the fenced
|
||||
**Recover interrupted claim** action described below when it is available.
|
||||
- IMAP `appending`: A worker owns the durable append claim. Do not start a
|
||||
second append; if the worker cannot finish, reconcile only after checking the
|
||||
mailbox.
|
||||
@@ -110,6 +140,65 @@ unknown provider attempt merely to repair the other layer's state.
|
||||
- Add a note that identifies the evidence used, for example SMTP log line,
|
||||
provider message ID, or operator ticket.
|
||||
|
||||
For pure-Mail SMTP and channel-specific IMAP operations, reconciliation updates
|
||||
the original Campaign attempt, matching Campaign recovery operation and audit
|
||||
record atomically under a fresh lease. A SMTP-only decision cannot resolve an
|
||||
entire compound Mail/Postbox/Print operation; that ledger remains separately
|
||||
unresolved until all of its effects are established. Campaign does
|
||||
not rewrite Mail-owned nested provider-effect operations: those remain Mail's
|
||||
separate evidence and operational responsibility. A failed audit or conflicting
|
||||
claim must leave the prior unknown state intact.
|
||||
|
||||
### Recovery without workers
|
||||
|
||||
The Report offers explicit inline retry and continuation when workers are not
|
||||
configured. Retry uses `campaigns:campaign:retry` plus
|
||||
`campaigns:campaign:send`; continuation uses `campaigns:campaign:queue` plus
|
||||
`campaigns:campaign:send`. Both use the canonical immutable jobs and ordinary
|
||||
attempt ledger, not a separate one-message resend. Current Mail authorization,
|
||||
review/approval, execution integrity, retry limits and rate limits still apply.
|
||||
Each call is bounded by the effective synchronous recipient-job limit and
|
||||
reports remaining eligible work. Continue explicitly until none remains; it
|
||||
never selects accepted, excluded, active, uncertain or known failed jobs.
|
||||
Known failures have their own explicit retry action. These actions do not
|
||||
turn a long HTTP request into a background worker or guarantee exactly-once
|
||||
SMTP when a provider acknowledgement is lost.
|
||||
|
||||
For an abandoned active SMTP or IMAP claim, the report exposes recovery only
|
||||
after the original durable lease expires **and** the runtime registry proves
|
||||
that its owner stopped or was replaced. A stale heartbeat is insufficient.
|
||||
The opaque claim revision and original recovery evidence are rechecked under a
|
||||
fresh lease. Recovery records the effect as **outcome unknown**, never not sent.
|
||||
Then separately inspect external evidence and reconcile with a factual note
|
||||
before any retry. This requires `campaigns:campaign:reconcile`. If the original
|
||||
lease, evidence, or stopped-owner proof is missing, preserve the records and
|
||||
investigate through Ops; do not edit delivery rows or force a lease expiry in
|
||||
a real installation.
|
||||
|
||||
### Progress totals and Sent-folder batching
|
||||
|
||||
For each channel, **processed** includes successful, failed, uncertain and
|
||||
cancelled outcomes. **In progress** is separate from pending, so the currently
|
||||
sending/appending message remains visible. Paused SMTP work is also separate.
|
||||
Excluded/non-requested channel work is outside the denominator. The endpoint
|
||||
requires campaign read and object access and returns no recipient addresses,
|
||||
message bodies, attachments or credentials. It is not the privacy-thresholded
|
||||
aggregate Reports view and does not grant that view recipient access.
|
||||
|
||||
Append-to-Sent targets the selected campaign version, not all historical
|
||||
versions. Each message remains a separately fenced, sequential IMAP APPEND.
|
||||
Mail reuses the authenticated session and resolved folder for at most 100
|
||||
messages or 300 seconds by default, then rotates the connection. Current
|
||||
authorization, frozen transport revisions, credentials and recovery checks
|
||||
still run for every message. A stale idle connection is checked before another
|
||||
APPEND; an uncertain APPEND is never replayed. This removes repeated
|
||||
connect/login/folder-list round trips, not the time needed to upload each EML.
|
||||
It is not an atomic MULTIAPPEND transaction or parallel delivery.
|
||||
|
||||
SMTP and IMAP use the same progress dialog. An acknowledged operation remains
|
||||
successful even if loading its follow-up diagnostics fails: use Reload to
|
||||
refresh display, not to repeat the external effect.
|
||||
|
||||
## Fault Injection Checklist
|
||||
|
||||
Use mock infrastructure first, then repeat against the non-production real test
|
||||
@@ -134,17 +223,21 @@ deliberately excluded.
|
||||
|
||||
The runner also terminates a dedicated OS process executing the registered
|
||||
Campaign send task after complete DATA, then invokes the task in a fresh
|
||||
process. The unfinished durable attempt must become `outcome_unknown` and the
|
||||
endpoint must observe no second connection or DATA transaction. This covers
|
||||
the worker task/process boundary but not a broker or daemon.
|
||||
process. Redelivery must leave the active claim unchanged and the endpoint
|
||||
must observe no second connection or DATA transaction. Only a subsequent
|
||||
explicit, fenced recovery with test-fixture stopped-owner and expired-lease
|
||||
proof may change the unfinished attempt to `outcome_unknown`. This covers the
|
||||
worker task/process boundary but not a broker or daemon.
|
||||
|
||||
Run `dev/mail-testbed/run_celery_redelivery_acceptance.py` for the maintained
|
||||
Redis/Celery delivery and broker redelivery boundary. It starts an isolated
|
||||
Redis Compose service and real Celery workers, kills the first solo worker after complete DATA while the
|
||||
late-ack task is unacknowledged, and requires the same task identity to reach a
|
||||
replacement worker after Redis visibility recovery. Passing evidence also
|
||||
requires durable `outcome_unknown`, an empty broker queue/unacked set, and
|
||||
exactly one SMTP connection and DATA transaction. Raw worker logs and task,
|
||||
requires unchanged active state on duplicate delivery, followed by explicit
|
||||
fenced recovery to `outcome_unknown`, an empty broker queue/unacked set, and
|
||||
exactly one SMTP connection and DATA transaction. Lease expiration is simulated
|
||||
only in the isolated fixture after its owner was stopped. Raw worker logs and task,
|
||||
database, endpoint, and credential identifiers are never retained.
|
||||
|
||||
That second runner proves local runner-supervised process replacement, not the
|
||||
@@ -183,6 +276,11 @@ and retention.
|
||||
|
||||
## Reporting Checks
|
||||
|
||||
- The recipient-aware report shows every frozen To/Cc/Bcc address in authored
|
||||
order, with a primary-address fallback only for old rows without that snapshot.
|
||||
SMTP envelope evidence, not the old primary-only UI, establishes how many
|
||||
recipients were actually offered to the provider. SMTP and IMAP diagnostics
|
||||
use list filters and consistent translated labels.
|
||||
- Partial delivery must show accepted, failed, and unknown counts separately.
|
||||
- Excluded messages must show SMTP and IMAP as `skipped`, with skipped counts
|
||||
and filters separate from unattempted or failed delivery.
|
||||
|
||||
+330
-7
@@ -45,7 +45,8 @@ Campaign owns:
|
||||
- message and attachment rules for a version;
|
||||
- validation, review, build, queue, and delivery-control state;
|
||||
- the durable jobs and attempts needed to explain delivery outcomes; and
|
||||
- campaign-specific reports, shares, and frozen execution evidence.
|
||||
- campaign-specific reports, shares, frozen execution evidence, and governed
|
||||
human collaboration entries.
|
||||
|
||||
Campaign does not own:
|
||||
|
||||
@@ -111,6 +112,61 @@ action.
|
||||
|
||||
## User tasks
|
||||
|
||||
### Discuss campaign work
|
||||
|
||||
Open **Collaboration** inside a Campaign to keep human coordination beside the
|
||||
work without changing its version history. Discussion access is independent
|
||||
from Campaign editing: parent Campaign read access remains mandatory, while
|
||||
`campaigns:discussion:read`, `campaigns:discussion:post`, and
|
||||
`campaigns:discussion:moderate` separately control reading, posting, and
|
||||
moderation. A read share is sufficient as the parent grant and a comment never
|
||||
turns that share into write access.
|
||||
|
||||
Comments are append-only and bounded to 8,000 characters. They can carry one
|
||||
validated reference to an immutable Campaign version, saved recipient import,
|
||||
attachment rule, delivery job, or report. References to version-bound evidence
|
||||
include the exact version ID and never edit that version. Authors can withdraw
|
||||
their own comments; moderators can redact comments and use moderator-only
|
||||
visibility. Both operations remove displayed text but preserve a tombstone,
|
||||
content hash, actor snapshot, timestamp, reference context, and bounded Audit
|
||||
event. There is deliberately no comment-edit API.
|
||||
|
||||
Mentions are limited to 20 active users who already have Campaign ownership or
|
||||
share access. When Notifications is installed and healthy, Campaign emits a
|
||||
content-free in-app mention notification. Collaboration remains usable without
|
||||
Notifications. The thread displays only human discussion; approvals, workflow
|
||||
state, delivery events, and durable system evidence remain on their owning
|
||||
surfaces and in Tenant audit.
|
||||
|
||||
### Assign accountable campaign work
|
||||
|
||||
Open **Work** to assign one bounded purpose to an account, group, or
|
||||
organization function that already has Campaign access. Assignment records
|
||||
responsibility only: it never creates a share, transfers ownership, or grants a
|
||||
permission. Assignees may accept, complete, or reject their work; rejection is
|
||||
distinct from administrative cancellation. Managers may reassign or cancel
|
||||
open work, and every transition retains the expected revision, actor snapshot,
|
||||
typed target, and append-only event history.
|
||||
|
||||
Workflow may create or reference a Campaign and open the same assignment through
|
||||
the optional `campaigns.workOrchestration` capability. Those assignments pin the
|
||||
Campaign version and store the Workflow instance, step, correlation, and
|
||||
idempotency provenance. Campaign emits `campaign.work.changed` for assignment,
|
||||
acceptance, start, reassignment, completion, rejection, and cancellation.
|
||||
Workflow uses the assignment ID and event revision, rechecks current Campaign
|
||||
access, and then resumes the matching durable external hand-off without browser
|
||||
polling. A missing Tasks or Notifications capability only removes the optional
|
||||
projection or notification. A missing Campaign provider, revoked Campaign
|
||||
access, or stale event revision keeps the Workflow blocked and inspectable.
|
||||
|
||||
Campaign also contributes the opt-in **Accountable Campaign work hand-off**
|
||||
Workflow template. It is deliberately not activated on installation. A
|
||||
configurator must copy or activate it and supply either `campaign_id` or
|
||||
`create_campaign`; unused optional input keys must be present with `null`
|
||||
values. The template prepares the assignment idempotently, opens the exact
|
||||
Campaign work URL, and waits for completion, rejection, cancellation, or the
|
||||
configured timeout. Opening the link never completes the Workflow.
|
||||
|
||||
### Prepare a campaign
|
||||
|
||||
1. Create a campaign and confirm its owner or owning group.
|
||||
@@ -130,6 +186,11 @@ action.
|
||||
password generator keeps its candidate separate from the form until **Use
|
||||
password** is explicitly confirmed. Copying a candidate does not save or
|
||||
submit it.
|
||||
If legacy transport data is reported, choose **Migrate selected Mail
|
||||
profile**, including when the existing profile selection is unchanged.
|
||||
Follow a locked version's supported unlock or editable-successor action
|
||||
before migration. Migration is explicit and audited, never sends mail, and
|
||||
requires validation, build, and review again.
|
||||
6. Save the editable version, validate the relevant sections, and resolve every
|
||||
blocking issue. Warnings remain explicit review decisions.
|
||||
7. Build the exact messages and inspect recipient, addressing, template,
|
||||
@@ -139,11 +200,77 @@ If a selected optional module is absent, Campaign remains loadable and explains
|
||||
which function is unavailable. It must not fail startup because Mail, Files, or
|
||||
Addresses is not installed.
|
||||
|
||||
Opening or leaving Template without editing does not change the saved HTML or
|
||||
mark the page dirty. Visual/source inspection and read-only changes likewise
|
||||
do not require a save. Actual saves send only client-owned editor metadata. Review
|
||||
and approval evidence remains server-owned and cannot be overwritten by an
|
||||
ordinary editor save. Omitting that readable evidence from a save does not
|
||||
delete it; normal version-lock and invalidation rules remain authoritative.
|
||||
|
||||
### Preserve recipient address order
|
||||
|
||||
In an individual or global address dialog, use the up/down actions to arrange
|
||||
the addresses, then choose **Save** in the dialog. The campaign draft keeps that
|
||||
order; saving no longer alphabetically sorts it. Duplicate email addresses keep
|
||||
their first position, and pasted addresses append in their entered order.
|
||||
The first individual To address is also the primary name/email shown in the
|
||||
recipient row. Use the page's **Save** to persist the campaign draft. A rejected
|
||||
page save keeps the reordered draft for an explicit retry. **Cancel** in the
|
||||
dialog discards only its unconfirmed changes.
|
||||
|
||||
### Permit Legacy ZipCrypto as an explicit compatibility exception
|
||||
|
||||
AES remains the secure default. Campaign **Settings**, **Policies**, and
|
||||
**Attachments** expose the effective archive policy, configuration links for
|
||||
authorized administrators, and **Reload archive policy**.
|
||||
|
||||
1. A policy administrator opens **Administration → SYSTEM → Campaign archive
|
||||
encryption**, enables **Legacy ZipCrypto**, and saves. The controls work
|
||||
before the first system override exists; opening defaults alone does not
|
||||
create an override or unsaved changes. Changing this global ceiling requires
|
||||
both `system:settings:write` and `admin:policies:write`; tenant policy
|
||||
authority alone cannot loosen it.
|
||||
2. Check tenant and owner policy restrictions. Lower scopes may narrow, never
|
||||
loosen, inherited methods and password-delivery channels.
|
||||
3. The Campaign actor also needs `campaigns:archive:use_legacy_zipcrypto` and
|
||||
edit access to the selected version. Policy administration does not replace
|
||||
that dedicated permission.
|
||||
4. Return to Campaign, reload archive policy, and select **Legacy ZipCrypto**
|
||||
under **Attachments → ZIP attachments**. Acknowledge weak encryption, enter
|
||||
an operational reason of at least 10 characters, and select an allowed
|
||||
separate password-delivery channel.
|
||||
5. Save, validate, build, and review. Policy/configuration saves never send
|
||||
mail; delivery remains a separate action.
|
||||
|
||||
Legacy remains blocked while Policy is unavailable. Neither an encryption
|
||||
error nor an incompatible client causes automatic fallback from AES to
|
||||
ZipCrypto. The build retains policy and acknowledgement evidence but never the
|
||||
password; see the manifest topic `campaigns.archive-encryption-governance`.
|
||||
|
||||
Mail migration and ZIP corrections can be saved in either order. A Mail-only
|
||||
migration preserves unchanged ZIP settings without granting permission to use
|
||||
them or adding acknowledgement evidence. An archive correction with unchanged
|
||||
Mail references preserves legacy transport server-side until its separate,
|
||||
explicit migration. Changes to ZIP settings still require the current policy
|
||||
and any dedicated legacy permission; changes to Mail references still require
|
||||
authorized migration. Validate, build and review again after both repairs.
|
||||
|
||||
A save and the following workspace refresh are separate operations. A failed
|
||||
refresh does not undo a committed save or clear the last usable workspace.
|
||||
Keep any newer unsaved edits, inspect the refresh error, and use Reload to fetch
|
||||
the current state. Responses for an earlier campaign, version or signed-in
|
||||
identity cannot overwrite the current workspace.
|
||||
|
||||
### Review and complete review
|
||||
|
||||
The reviewer should verify the immutable candidate that will be delivered, not
|
||||
just the authoring form:
|
||||
|
||||
If a legacy Mail migration notice appears, follow **Open Mail settings** for
|
||||
that exact version, complete migration, and validate and build again. Review
|
||||
stays read-only until migration is resolved and does not repeatedly request
|
||||
an attachment preview that the legacy transport boundary must reject.
|
||||
|
||||
1. Confirm purpose, owner, selected version, and recipient count.
|
||||
2. Inspect blocking errors, warnings, exclusions, and recipients requiring
|
||||
review.
|
||||
@@ -167,6 +294,67 @@ the required action, the responsible role, and the workspace to open. The
|
||||
review summary keeps reviewed and remaining counts visible; a completed review
|
||||
acknowledges the group items and remains bound to the current build token.
|
||||
|
||||
Save each individual acceptance to persist its reason and reviewed state before
|
||||
completing the entire review. Wait for acknowledgement; reloading then resumes
|
||||
that build's saved progress. If saving fails or conflicts, the pending note
|
||||
remains available for an explicit retry rather than becoming a false success.
|
||||
This small save only loads the selected persisted jobs: it does not rebuild
|
||||
messages, materialize attachments, or reload the whole workspace. Another
|
||||
reviewer's existing decisions and attribution remain intact. Partial progress
|
||||
does not enable delivery; final completion still checks the complete build.
|
||||
|
||||
Use **Accept similar review conditions** to record the same decision for a
|
||||
counted selection of currently loaded matching messages. The server defines
|
||||
eligible categories from the complete combination of overridable conditions;
|
||||
the UI does not interpret a warning badge as permission to override. Select
|
||||
one category, inspect the listed recipients, deselect any exceptions and enter
|
||||
a common reason (required for attachment exceptions). A submission contains
|
||||
at most 200 explicit message IDs. When more remain, save this selection and
|
||||
reopen the dialog; the counts never imply acceptance of unloaded messages or
|
||||
other categories. The reason is recorded separately against each selected
|
||||
message's frozen evidence. A failed save retains the selection and reason for
|
||||
an explicit retry, while a changed build prevents stale acceptance. This
|
||||
action neither sends messages nor completes the final review gate. Hard
|
||||
blockers cannot be accepted this way. Deliberate policy exclusions and
|
||||
attachment rules that explicitly permit zero matches remain informational
|
||||
and do not require review decisions.
|
||||
|
||||
An optional rule with explicit `missing_behavior: continue` may yield no files
|
||||
without creating review work; that outcome remains informational evidence.
|
||||
Required attachment and hard-block policies cannot be weakened by this setting.
|
||||
The separate policy for sending a wholly attachment-free message still applies.
|
||||
Rebuild existing messages after changing attachment policy; historical build
|
||||
evidence is not rewritten.
|
||||
|
||||
The incremental review API uses `merge_progress: true`, the acknowledged
|
||||
`base_revision`, and `build_token` set to the public `review_build_token`.
|
||||
It merges exact reviewed keys/decision job IDs for the current build, with an
|
||||
optional `decision_category_key` to bind a grouped acceptance. The safe review
|
||||
reference is available without diagnostic access; raw build tokens remain
|
||||
diagnostic data. Stale build/revision or simultaneous writes return HTTP 409
|
||||
without overwriting progress. Normal review authorization and audit apply.
|
||||
|
||||
Accepted or expected attachment conditions remain satisfied in **Confirm and
|
||||
send** for the same build. Raw missing/ambiguous source counts remain visible
|
||||
for context; they are not a second approval gate. Reviewed-stage mock delivery
|
||||
uses `use_reviewed_build: true`: it verifies the existing execution seal,
|
||||
completed review, frozen job issues and EML integrity, and current Mail transport
|
||||
before capturing anything in the mock mailbox. It uses those stored messages,
|
||||
not freshly rendered replacements, and never mutates Campaign delivery state.
|
||||
Stale review, changed inputs, changed bytes or changed transport stop the test
|
||||
before captures or requested mailbox clearing. The authoring/mock-preview API
|
||||
keeps its existing transient-build default; `include_needs_review` does not
|
||||
bypass frozen review checks.
|
||||
|
||||
Validation details and repeated-file lists use the shared DataGrid pagination
|
||||
controls so every item is reachable. Related missing-rule causes and their
|
||||
attachment-free policy outcomes appear together, with the technical evidence
|
||||
still expandable. Built messages have four operational states: **Ready**,
|
||||
**Needs review**, **Blocked**, and **Excluded**, plus an explanatory column.
|
||||
Accepted explicit decisions are Ready; warnings awaiting acknowledgment remain
|
||||
Needs review. This presentation does not remove or rewrite frozen issues or
|
||||
audit evidence.
|
||||
|
||||
This evidence is the Campaign input to separation-of-duties policy. Generic
|
||||
approve/reject chains, delegation, substitutions, escalation, and signatures
|
||||
belong to the optional Approvals capability. Campaign must not claim an
|
||||
@@ -223,7 +411,7 @@ At a minimum:
|
||||
ordinary batches; the durable progress remains visible after leaving and
|
||||
returning to Review and send.
|
||||
2. Use **Send now** only when the exact persisted eligible build is within the
|
||||
effective synchronous limit shown by the UI. The default deployment limit
|
||||
effective synchronous limit shown by the UI. The unchanged default limit
|
||||
is 25 recipient jobs. The backend repeats the count and preflights every
|
||||
message and the Mail profile revision before contacting SMTP.
|
||||
3. Treat `smtp_accepted` as protected from ordinary retry.
|
||||
@@ -246,10 +434,24 @@ Pause stops new eligible work but cannot undo a provider effect already in
|
||||
progress. Cancel marks work that has not yet produced a protected SMTP outcome;
|
||||
it cannot recall accepted mail.
|
||||
|
||||
The deployment ceiling is configured with
|
||||
`GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS` (0 disables Send now; the
|
||||
accepted range is 0–500). A tenant may only narrow that ceiling with
|
||||
`tenant.settings.campaign_delivery_policy.synchronous_send_max_recipients`.
|
||||
Configure **Administration → SYSTEM → Campaign delivery** with
|
||||
`system:settings:read/write`. The default stays 25, but an administrator may
|
||||
explicitly choose 0–500, for example 200 for a 183-recipient-job run. Zero disables
|
||||
Send now. **TENANT → Campaign delivery** uses `admin:policies:read/write` and may
|
||||
only narrow the inherited system policy. Clearing an override restores
|
||||
inheritance. An explicitly configured deployment ceiling
|
||||
`GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS` remains authoritative; the
|
||||
implicit default does not prevent a system administrator choosing a larger
|
||||
bounded value. Save changes only this setting, preserves unrelated settings,
|
||||
checks a revision token including inherited policy, and records before/after
|
||||
configuration history and audit. Failed saves retain the draft; conflicting
|
||||
saves require explicit reload/reconciliation. Policy edits never send mail or
|
||||
change existing reviews or approval requirements.
|
||||
|
||||
This limit applies to one interactive Send now request, not campaign size or
|
||||
worker batching. Larger interactive requests run longer and can meet proxy
|
||||
timeouts. Queue for workers is independent and requires enabled, healthy
|
||||
Redis/Celery infrastructure; changing the numeric limit does not start workers.
|
||||
The effective value and source are returned by the protected delivery-options
|
||||
API, recorded for successful/rejected synchronous commands, and stated in the
|
||||
configured handbook topic.
|
||||
@@ -276,6 +478,32 @@ default.
|
||||
|
||||
## Data and evidence model
|
||||
|
||||
### Portable Campaign transfer
|
||||
|
||||
Campaign offers two reuse paths with different boundaries. **Copy campaign**
|
||||
creates another campaign inside the same installation and can reuse selected
|
||||
local shares, policies, and Mail profile references. **Export package** creates
|
||||
a versioned JSON hand-off whose selected scopes can cross an installation
|
||||
boundary; **Import package** always creates a separately owned draft.
|
||||
|
||||
The export dialog starts with only metadata and template/configuration. Add
|
||||
recipients, attachment rules, review state, or delivery history only when the
|
||||
handoff requires them and the destination and retention are approved. Recipient
|
||||
and delivery scopes remain protected by recipient/report export permissions.
|
||||
Transport secrets, credential references, password-field values, local storage
|
||||
locators, and attachment bytes are always removed. The manifest records scope
|
||||
counts and redactions, while the envelope carries source Campaign/version
|
||||
provenance and a SHA-256 digest.
|
||||
|
||||
Import verifies format, scope, checksum, schema, and destination identity before
|
||||
showing the plan. Editing the destination identity or selected scopes makes the
|
||||
preview stale and requires a new check. The apply step clears source Mail
|
||||
references, creates one editable draft, and stores a bounded source/package and
|
||||
created/skipped receipt. Historical validation/build summaries, review state,
|
||||
approvals, delivery jobs, attempts, and sent outcomes are never replayed. File
|
||||
content is never embedded, so reconnect managed files and local Mail profiles,
|
||||
then validate, build, review, and approve normally.
|
||||
|
||||
### Versions and snapshots
|
||||
|
||||
Editable campaign JSON is versioned. Build creates recipient jobs and an
|
||||
@@ -384,6 +612,73 @@ create an editable successor.
|
||||
|
||||
## Operations and recovery
|
||||
|
||||
### Interactive delivery and Sent-folder progress
|
||||
|
||||
Send now and workerless Report retry/continue use a compact blocking progress
|
||||
dialog, as does inline append-to-Sent. It refreshes saved counters only, not the
|
||||
whole campaign behind the overlay. Successful, pending, in-progress, failed,
|
||||
uncertain and excluded messages are shown separately; a currently sending or
|
||||
appending message therefore does not disappear between totals. Read errors keep
|
||||
the last known counters. After a connection interruption, processing may still
|
||||
be running; inspect saved evidence before repeating any action. A successful
|
||||
write is not reclassified as failed when its later display refresh fails.
|
||||
|
||||
The recipient-aware Report shows all frozen To, Cc and Bcc addresses, not only
|
||||
the primary row identity. Address order and recipient-read authorization remain
|
||||
unchanged. SMTP/IMAP diagnostics use translated status-list filters.
|
||||
|
||||
Without workers, explicitly retry eligible failures or continue unattempted
|
||||
jobs through Report. Each request uses the canonical job/attempt recovery
|
||||
boundary and is limited by the effective synchronous policy. Accepted and
|
||||
uncertain SMTP outcomes remain protected. Active abandoned claims require an
|
||||
expired durable lease, proven stopped/replaced owner, current revision and
|
||||
valid original evidence before recovery can mark them unknown. A separate
|
||||
evidence-note reconciliation is required before retrying. A timeout alone is
|
||||
never proof of non-delivery. See the delivery runbook for required permissions
|
||||
and operational limitations.
|
||||
|
||||
Append-to-Sent is scoped to the selected version and reuses a bounded Mail-owned
|
||||
connection/folder resolution (default 100 messages or 300 seconds), while
|
||||
performing one sequential APPEND and all current checks per message. Uncertain
|
||||
appends are never automatically repeated, and repairing Sent never resends SMTP.
|
||||
|
||||
Link required files before locking. Lock and validate waits for attachment
|
||||
matches, rechecks them immediately before locking, and asks for Link and lock
|
||||
confirmation if new unlinked files are found. A locked version cannot acquire
|
||||
new attachment links; use an editable version and validate/build/review again.
|
||||
|
||||
### Fortschritt, Wiederherstellung und Dateiverknüpfungen
|
||||
|
||||
Jetzt senden, synchrone Wiederholung/Fortsetzung im Bericht und Kopieren nach
|
||||
Gesendet verwenden einen kompakten sperrenden Fortschrittsdialog. Nur gespeicherte
|
||||
Zähler werden aktualisiert, nicht der Arbeitsbereich im Hintergrund. Erfolgreich,
|
||||
ausstehend, in Bearbeitung, fehlgeschlagen, ungewiss und ausgeschlossen bleiben
|
||||
getrennt sichtbar. Bei Lesefehlern bleiben die letzten Werte erhalten. Nach einer
|
||||
getrennten Verbindung kann die Verarbeitung weiterlaufen; prüfen Sie Nachweise,
|
||||
bevor Sie erneut handeln. Ein bestätigter Versand wird durch einen nachfolgenden
|
||||
Anzeigefehler nicht nachträglich als fehlgeschlagen dargestellt.
|
||||
|
||||
Der empfängerbezogene Bericht zeigt alle eingefrorenen An-, Cc- und Bcc-Adressen
|
||||
in ihrer Reihenfolge. Leseberechtigungen bleiben unverändert. SMTP und IMAP
|
||||
verwenden übersetzte Zustandslisten zum Filtern.
|
||||
|
||||
Ohne Worker können bekannte Fehler ausdrücklich wiederholt und unversuchte
|
||||
Aufträge begrenzt fortgesetzt werden. Die wirksame synchrone Grenze, gespeicherte
|
||||
Aufträge, Prüfungen, Freigaben und Wiederherstellungsnachweise bleiben verbindlich.
|
||||
Angenommene und ungewisse SMTP-Ergebnisse werden nicht blind wiederholt. Die
|
||||
Wiederherstellung aktiver, verlassener Aufträge benötigt eine abgelaufene Sperre,
|
||||
nachweislich gestoppte/ersetzte Laufzeit und gültige ursprüngliche Nachweise. Sie
|
||||
setzt ausschließlich auf ungewiss; vor Wiederholung sind externe Nachweise und
|
||||
ein getrennter Abgleich mit Notiz erforderlich. Zeitablauf allein genügt nicht.
|
||||
|
||||
Kopieren nach Gesendet betrifft nur die ausgewählte Version. Mail verwendet die
|
||||
Verbindung und Ordnerauflösung begrenzt wieder (Standard: 100 Nachrichten oder
|
||||
300 Sekunden), prüft aber jede Nachricht erneut und führt APPEND nacheinander
|
||||
aus. Ungewisse Ergebnisse werden nicht automatisch wiederholt. Verknüpfen Sie
|
||||
benötigte Dateien vor dem Sperren; eine frische Prüfung fragt bei unverknüpften
|
||||
Treffern nach Verknüpfen und sperren. Gesperrte Versionen benötigen zum Ändern
|
||||
eine bearbeitbare Version mit erneuter Validierung, Build und Prüfung.
|
||||
|
||||
### Health to observe
|
||||
|
||||
- database and migration health;
|
||||
@@ -557,6 +852,35 @@ purpose, lawful basis, minimization, export control, and retention before the
|
||||
campaign starts; do not use Campaign as a substitute consent or address-master
|
||||
system.
|
||||
|
||||
The Core data-subject-request workflow discovers Campaign through the optional
|
||||
`privacy.dsar.campaigns` capability. After the request's email, membership, and
|
||||
namespaced Campaign references have been independently authorized and
|
||||
corroborated, the provider searches only the effective tenant and isolates the
|
||||
matching recipient entries and jobs. Its JSON result includes safe Campaign,
|
||||
version, delivery-attempt, schedule, report-projection, share, import-mapping,
|
||||
attachment, generated-artifact, and relevant collaboration metadata. It also
|
||||
finds collaboration entries authored, mentioned, or moderated by the subject.
|
||||
Text authored by the subject is included; somebody else's text is not copied
|
||||
merely because the subject was mentioned. Generated EML bytes and paths,
|
||||
storage locators, delivery target snapshots, worker claims, idempotency
|
||||
material, credentials, secret-like values, and unrelated recipients are never
|
||||
embedded in that result. Authorized Campaign and Files review surfaces remain
|
||||
the source for content that cannot safely be copied into the DSAR case.
|
||||
|
||||
Built, locked, published, terminal, delivered, or corrected records are
|
||||
retained with an explicit reason and continue through Campaign's configured
|
||||
retention and redaction process. Draft recipient content and user-owned
|
||||
attachment content require coordinated manual review because copies may span
|
||||
version JSON, jobs, generated messages, and managed files. The provider can
|
||||
idempotently revoke an active share aimed at the subject and delete the
|
||||
subject's personal recipient-import mapping profile. It does not rewrite
|
||||
delivery evidence, delete generated artifacts, or report derived Campaign
|
||||
counts as a separate store. Collaboration withdrawal and redaction retain the
|
||||
tombstone, content hash, context, and Audit evidence; the DSAR workflow does
|
||||
not rewrite these append-only records. Re-running an approved action is safe: already
|
||||
revoked or absent data is reported as unchanged, and tenant, subject, and row
|
||||
ownership are revalidated immediately before mutation.
|
||||
|
||||
### Audit and destructive actions
|
||||
|
||||
Material authoring, validation, locking, review, queueing, send, retry,
|
||||
@@ -611,7 +935,6 @@ The following are part of the selected reference journey but are not implied by
|
||||
the current baseline:
|
||||
|
||||
- the final audited **test / single send / single resend** semantics;
|
||||
- reusable SMTP batch sessions and their measured throughput benefit;
|
||||
- durable, idempotent Campaign report delivery through a Mail-owned outbox
|
||||
([`govoplan-mail#17`](https://git.add-ideas.de/GovOPlaN/govoplan-mail/issues/17));
|
||||
- a fully packaged one-command Campaign reference composition with production
|
||||
|
||||
@@ -68,7 +68,9 @@ material and does not delete or rewrite the stored audit rows automatically:
|
||||
`mail_profile_migration_required` marker.
|
||||
- validation, build, queue, retry, and delivery fail closed with an actionable
|
||||
profile-migration error;
|
||||
- unrelated edits cannot silently scrub the legacy fields;
|
||||
- unrelated edits preserve the exact stored legacy server object when the
|
||||
submitted public Mail references are unchanged; they cannot silently scrub,
|
||||
edit, or re-submit legacy fields or credentials;
|
||||
- an editable version is migrated only through an explicit Mail-settings save
|
||||
with an authorized profile; and
|
||||
- a locked version remains unchanged. Creating its editable successor records
|
||||
@@ -81,6 +83,63 @@ database as a whole; the product does not define a separate historical-JSON or
|
||||
inline-secret recovery workflow. A restored legacy row remains inert and
|
||||
fail-closed under the same rules.
|
||||
|
||||
### Migrate from the Campaign UI
|
||||
|
||||
Open **Mail settings** from the migration notice for the selected version.
|
||||
Select an authorized Mail profile and choose **Migrate selected Mail profile**.
|
||||
The migration action is available even when that profile was already selected
|
||||
and the draft has no other unsaved changes. If the version is locked, first use
|
||||
its supported unlock or editable-successor action; protected source evidence is
|
||||
not rewritten. Reopen the settings and confirm that the migration notice has
|
||||
gone, then validate, build, and review before separately authorizing delivery.
|
||||
Migration itself never sends mail.
|
||||
|
||||
Mail migration and ZIP policy repairs can be saved in either order. An exact,
|
||||
unchanged ZIP configuration does not require a new acknowledgement merely to
|
||||
save a Mail migration, even if the existing ZIP policy or actor's permission is
|
||||
no longer valid. No actor, timestamp, or consent is invented. Any ZIP change
|
||||
still requires the complete current archive policy and, for ZipCrypto, the
|
||||
dedicated permission and reasoned acknowledgement. Conversely, saving an
|
||||
archive correction with unchanged public Mail references retains the exact
|
||||
legacy transport server-side, without using or reauthorizing the old profile.
|
||||
The migration notice remains until the explicit authorized migration succeeds.
|
||||
Changing any Mail profile, server, or credential reference is not an unrelated
|
||||
repair. Inline transport is rejected even if a caller echoes stored values.
|
||||
The same edit-time separation applies after migration: retaining an unchanged
|
||||
Mail selection does not invoke use-policy while saving an unrelated correction,
|
||||
even if a later policy requires an explicit credential. Selecting a new resource
|
||||
or explicitly migrating still checks Mail permission and current policy; actual
|
||||
validation and delivery always recheck them, regardless of save history.
|
||||
Both repair orders invalidate build/execution evidence; validation, build,
|
||||
review and delivery remain blocked until all outstanding conditions are valid.
|
||||
|
||||
Successful saves and subsequent refreshes are separate outcomes. A committed
|
||||
save is not undone by a failed workspace refresh. The workspace retains its
|
||||
last usable same-campaign/version data and displays the refresh error; retry
|
||||
Reload to fetch the current server state. Obsolete responses from an earlier
|
||||
campaign, version, signed-in identity, or reload cannot replace newer data.
|
||||
|
||||
The normal profile selector requests only campaign-authorized profiles. The
|
||||
administrative profile catalogue is requested separately on **Mail policy**;
|
||||
failure or lack of authority there does not empty the usable profile selector.
|
||||
Profile-list errors remain visible next to the affected settings.
|
||||
|
||||
While migration is required, **Review and send** remains read-only and links to
|
||||
the exact version's Mail settings. It does not repeatedly attempt incompatible
|
||||
attachment-preview requests. These UI affordances do not relax backend
|
||||
validation, build, queue, retry, or delivery enforcement.
|
||||
|
||||
### Editor metadata and trusted evidence
|
||||
|
||||
Read responses may contain server-owned `review_send` and `approval_gate`
|
||||
evidence. Ordinary version mutations send only client-owned `created_from`,
|
||||
`field_overrides`, and `opt_ins` editor metadata. The client omits review and
|
||||
approval evidence; the server rejects attempts to write it through an editor
|
||||
mutation and preserves its existing trusted value during metadata updates.
|
||||
Supported unlock, successor-version, and build invalidation rules still remove
|
||||
stale evidence when required. Opening or leaving the Template editor must not
|
||||
require manually deleting server review metadata.
|
||||
|
||||
## Operator checks
|
||||
|
||||
Before live delivery, confirm that:
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/campaign-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.29",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "webui/src/index.ts",
|
||||
@@ -22,7 +22,7 @@
|
||||
"read-excel-file": "9.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.45",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
|
||||
+2
-2
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-campaign"
|
||||
version = "0.1.15"
|
||||
version = "0.1.29"
|
||||
description = "GovOPlaN campaigns module with backend and WebUI integration."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = { file = "LICENSE" }
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.15",
|
||||
"govoplan-core>=0.1.46",
|
||||
"jsonschema>=4,<5",
|
||||
"pydantic>=2,<3",
|
||||
"SQLAlchemy>=2,<3",
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any, Mapping
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, has_scope
|
||||
from govoplan_core.core.policy import (
|
||||
CampaignArchiveEncryptionDecision,
|
||||
CampaignArchiveEncryptionRequest,
|
||||
campaign_archive_encryption_policy,
|
||||
)
|
||||
from govoplan_campaign.backend.db.models import Campaign
|
||||
from govoplan_campaign.backend.runtime import get_registry
|
||||
|
||||
|
||||
LEGACY_ZIPCRYPTO_SCOPE = "campaigns:archive:use_legacy_zipcrypto"
|
||||
LEGACY_ZIPCRYPTO_LABEL = "Legacy ZipCrypto — Windows-compatible, weak encryption"
|
||||
|
||||
|
||||
class CampaignArchiveEncryptionError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EffectiveArchiveEncryptionPolicy:
|
||||
available: bool
|
||||
allowed_password_encryption_methods: frozenset[str]
|
||||
allowed_password_delivery_channels: frozenset[str]
|
||||
policy_hash: str
|
||||
source_path: tuple[Mapping[str, Any], ...]
|
||||
reason: str
|
||||
diagnostics: tuple[Mapping[str, Any], ...] = ()
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"available": self.available,
|
||||
"allowed_password_encryption_methods": sorted(
|
||||
self.allowed_password_encryption_methods
|
||||
),
|
||||
"allowed_password_delivery_channels": sorted(
|
||||
self.allowed_password_delivery_channels
|
||||
),
|
||||
"policy_hash": self.policy_hash,
|
||||
"source_path": [dict(item) for item in self.source_path],
|
||||
"reason": self.reason,
|
||||
"diagnostics": [dict(item) for item in self.diagnostics],
|
||||
"legacy_label": LEGACY_ZIPCRYPTO_LABEL,
|
||||
}
|
||||
|
||||
|
||||
def effective_archive_encryption_policy(
|
||||
session: Session,
|
||||
campaign: Campaign,
|
||||
) -> EffectiveArchiveEncryptionPolicy:
|
||||
provider = campaign_archive_encryption_policy(get_registry())
|
||||
if provider is None:
|
||||
payload = {
|
||||
"available": False,
|
||||
"allowed_password_encryption_methods": ["aes"],
|
||||
"allowed_password_delivery_channels": [
|
||||
"in_person",
|
||||
"letter",
|
||||
"phone",
|
||||
"separate_mail",
|
||||
"sms",
|
||||
],
|
||||
"source_path": [
|
||||
{
|
||||
"scope_type": "system",
|
||||
"scope_id": None,
|
||||
"path": "system",
|
||||
"label": "Secure local fallback",
|
||||
"applied_fields": ["allowed_password_encryption_methods"],
|
||||
"policy": {
|
||||
"allowed_password_encryption_methods": ["aes"],
|
||||
"policy_provider": "unavailable",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
return EffectiveArchiveEncryptionPolicy(
|
||||
available=False,
|
||||
allowed_password_encryption_methods=frozenset({"aes"}),
|
||||
allowed_password_delivery_channels=frozenset(
|
||||
{"separate_mail", "sms", "letter", "phone", "in_person"}
|
||||
),
|
||||
policy_hash=_hash(payload),
|
||||
source_path=tuple(payload["source_path"]),
|
||||
reason=(
|
||||
"Policy is unavailable. AES remains available through the secure "
|
||||
"local baseline; legacy ZipCrypto fails closed."
|
||||
),
|
||||
)
|
||||
owner_type: str | None = None
|
||||
owner_id: str | None = None
|
||||
if campaign.owner_group_id:
|
||||
owner_type, owner_id = "group", campaign.owner_group_id
|
||||
elif campaign.owner_user_id:
|
||||
owner_type, owner_id = "user", campaign.owner_user_id
|
||||
decision: CampaignArchiveEncryptionDecision = (
|
||||
provider.resolve_campaign_archive_encryption(
|
||||
session,
|
||||
request=CampaignArchiveEncryptionRequest(
|
||||
tenant_id=campaign.tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
owner_type=owner_type, # type: ignore[arg-type]
|
||||
owner_id=owner_id,
|
||||
),
|
||||
)
|
||||
)
|
||||
return EffectiveArchiveEncryptionPolicy(
|
||||
available=True,
|
||||
allowed_password_encryption_methods=frozenset(
|
||||
decision.allowed_password_encryption_methods
|
||||
),
|
||||
allowed_password_delivery_channels=frozenset(
|
||||
decision.allowed_password_delivery_channels
|
||||
),
|
||||
policy_hash=decision.policy_hash,
|
||||
source_path=tuple(step.to_dict() for step in decision.source_path),
|
||||
reason=decision.reason or "Effective archive-encryption policy resolved.",
|
||||
diagnostics=decision.diagnostics,
|
||||
)
|
||||
|
||||
|
||||
def assert_archive_encryption_allowed(
|
||||
session: Session,
|
||||
campaign: Campaign,
|
||||
raw_json: Mapping[str, Any],
|
||||
*,
|
||||
principal: ApiPrincipal | None = None,
|
||||
) -> EffectiveArchiveEncryptionPolicy:
|
||||
policy = effective_archive_encryption_policy(session, campaign)
|
||||
for archive in _archive_configs(raw_json):
|
||||
method = str(archive.get("method") or "aes")
|
||||
if method not in policy.allowed_password_encryption_methods:
|
||||
raise CampaignArchiveEncryptionError(
|
||||
f"{_method_label(method)} is blocked. {policy.reason}"
|
||||
)
|
||||
if method == "zip_standard":
|
||||
if not policy.available:
|
||||
raise CampaignArchiveEncryptionError(
|
||||
"Legacy ZipCrypto cannot be used while Policy is unavailable."
|
||||
)
|
||||
if principal is not None and not has_scope(principal, LEGACY_ZIPCRYPTO_SCOPE):
|
||||
raise CampaignArchiveEncryptionError(
|
||||
f"Missing scope: {LEGACY_ZIPCRYPTO_SCOPE}"
|
||||
)
|
||||
if not archive.get("legacy_zipcrypto_acknowledged"):
|
||||
raise CampaignArchiveEncryptionError(
|
||||
f"{LEGACY_ZIPCRYPTO_LABEL} requires explicit acknowledgement."
|
||||
)
|
||||
if len(str(archive.get("legacy_zipcrypto_reason") or "").strip()) < 10:
|
||||
raise CampaignArchiveEncryptionError(
|
||||
f"{LEGACY_ZIPCRYPTO_LABEL} requires a reason of at least 10 characters."
|
||||
)
|
||||
if not archive.get("legacy_zipcrypto_acknowledged_by") or not archive.get(
|
||||
"legacy_zipcrypto_acknowledged_at"
|
||||
):
|
||||
raise CampaignArchiveEncryptionError(
|
||||
"Legacy ZipCrypto acknowledgement has no server-recorded actor or time. Save the campaign again."
|
||||
)
|
||||
if archive.get("password_enabled"):
|
||||
# Existing campaign revisions predate the explicit field. Their
|
||||
# model default is the separate-mail channel; apply the same
|
||||
# normalization before policy enforcement so saved revisions do
|
||||
# not become unusable merely because the field was omitted.
|
||||
channel = str(
|
||||
archive.get("password_delivery_channel") or "separate_mail"
|
||||
)
|
||||
if channel not in policy.allowed_password_delivery_channels:
|
||||
raise CampaignArchiveEncryptionError(
|
||||
f"Password-delivery channel {channel!r} is blocked by the effective policy."
|
||||
)
|
||||
return policy
|
||||
|
||||
|
||||
def stamp_legacy_zipcrypto_acknowledgements(
|
||||
session: Session,
|
||||
campaign: Campaign,
|
||||
current_raw_json: Mapping[str, Any],
|
||||
candidate_raw_json: dict[str, Any] | None,
|
||||
*,
|
||||
principal: ApiPrincipal,
|
||||
) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
|
||||
if candidate_raw_json is None:
|
||||
return None, []
|
||||
candidate = copy.deepcopy(candidate_raw_json)
|
||||
current_attachments = current_raw_json.get("attachments")
|
||||
candidate_attachments = candidate.get("attachments")
|
||||
current_zip = current_attachments.get("zip") if isinstance(current_attachments, Mapping) else None
|
||||
candidate_zip = candidate_attachments.get("zip") if isinstance(candidate_attachments, Mapping) else None
|
||||
if json.dumps(current_zip, sort_keys=True) == json.dumps(candidate_zip, sort_keys=True):
|
||||
# Saving an unrelated repair does not authorize use of an existing
|
||||
# archive or invent an acknowledgement. Preserve the exact stored ZIP
|
||||
# configuration, including missing evidence or now-revoked policy.
|
||||
# Build/review/delivery still validate the complete configuration.
|
||||
return candidate, []
|
||||
current_by_id = {
|
||||
str(item.get("id") or index): item
|
||||
for index, item in enumerate(_archive_configs(current_raw_json))
|
||||
}
|
||||
acknowledgements: list[dict[str, Any]] = []
|
||||
policy = effective_archive_encryption_policy(session, campaign)
|
||||
archives = _archive_configs(candidate)
|
||||
for index, archive in enumerate(archives):
|
||||
if str(archive.get("method") or "aes") != "zip_standard":
|
||||
archive.pop("legacy_zipcrypto_acknowledged_by", None)
|
||||
archive.pop("legacy_zipcrypto_acknowledged_at", None)
|
||||
continue
|
||||
if not has_scope(principal, LEGACY_ZIPCRYPTO_SCOPE):
|
||||
raise CampaignArchiveEncryptionError(
|
||||
f"Missing scope: {LEGACY_ZIPCRYPTO_SCOPE}"
|
||||
)
|
||||
if not policy.available or "zip_standard" not in policy.allowed_password_encryption_methods:
|
||||
raise CampaignArchiveEncryptionError(
|
||||
f"{LEGACY_ZIPCRYPTO_LABEL} is blocked. {policy.reason}"
|
||||
)
|
||||
reason = str(archive.get("legacy_zipcrypto_reason") or "").strip()
|
||||
if not archive.get("legacy_zipcrypto_acknowledged") or len(reason) < 10:
|
||||
raise CampaignArchiveEncryptionError(
|
||||
f"{LEGACY_ZIPCRYPTO_LABEL} requires acknowledgement and a reason of at least 10 characters."
|
||||
)
|
||||
key = str(archive.get("id") or index)
|
||||
previous = current_by_id.get(key, {})
|
||||
unchanged = (
|
||||
previous.get("method") == "zip_standard"
|
||||
and previous.get("legacy_zipcrypto_acknowledged") is True
|
||||
and str(previous.get("legacy_zipcrypto_reason") or "").strip() == reason
|
||||
and previous.get("legacy_zipcrypto_acknowledged_by")
|
||||
and previous.get("legacy_zipcrypto_acknowledged_at")
|
||||
)
|
||||
if unchanged:
|
||||
archive["legacy_zipcrypto_acknowledged_by"] = previous[
|
||||
"legacy_zipcrypto_acknowledged_by"
|
||||
]
|
||||
archive["legacy_zipcrypto_acknowledged_at"] = previous[
|
||||
"legacy_zipcrypto_acknowledged_at"
|
||||
]
|
||||
else:
|
||||
archive["legacy_zipcrypto_acknowledged_by"] = principal.user.id
|
||||
archive["legacy_zipcrypto_acknowledged_at"] = datetime.now(UTC).isoformat()
|
||||
acknowledgements.append(
|
||||
{
|
||||
"archive_id": key,
|
||||
"reason": reason,
|
||||
"policy_hash": policy.policy_hash,
|
||||
}
|
||||
)
|
||||
# Modified archive settings must satisfy the complete current policy, not
|
||||
# only the special ZipCrypto acknowledgement checks above.
|
||||
assert_archive_encryption_allowed(session, campaign, candidate, principal=principal)
|
||||
return candidate, acknowledgements
|
||||
|
||||
|
||||
def has_password_archives(raw_json: Mapping[str, Any]) -> bool:
|
||||
return any(bool(item.get("password_enabled")) for item in _archive_configs(raw_json))
|
||||
|
||||
|
||||
def _archive_configs(raw_json: Mapping[str, Any]) -> list[dict[str, Any]]:
|
||||
attachments = raw_json.get("attachments")
|
||||
zip_config = attachments.get("zip") if isinstance(attachments, Mapping) else None
|
||||
archives = zip_config.get("archives") if isinstance(zip_config, Mapping) else None
|
||||
if isinstance(archives, list):
|
||||
return [item for item in archives if isinstance(item, dict)]
|
||||
return []
|
||||
|
||||
|
||||
def _method_label(method: str) -> str:
|
||||
return LEGACY_ZIPCRYPTO_LABEL if method == "zip_standard" else method.upper()
|
||||
|
||||
|
||||
def _hash(value: object) -> str:
|
||||
return hashlib.sha256(
|
||||
json.dumps(value, sort_keys=True, separators=(",", ":"), default=str).encode()
|
||||
).hexdigest()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CampaignArchiveEncryptionError",
|
||||
"EffectiveArchiveEncryptionPolicy",
|
||||
"LEGACY_ZIPCRYPTO_LABEL",
|
||||
"LEGACY_ZIPCRYPTO_SCOPE",
|
||||
"assert_archive_encryption_allowed",
|
||||
"effective_archive_encryption_policy",
|
||||
"has_password_archives",
|
||||
"stamp_legacy_zipcrypto_acknowledgements",
|
||||
]
|
||||
@@ -233,15 +233,20 @@ def _missing_policy_decision(
|
||||
candidates,
|
||||
key=lambda behavior: _MISSING_BEHAVIOR_STRENGTH[behavior],
|
||||
)
|
||||
legacy_drop_normalized = configured == Behavior.DROP
|
||||
if legacy_drop_normalized:
|
||||
configured = Behavior.BLOCK if config.required else Behavior.ASK
|
||||
if Behavior.BLOCK in candidates:
|
||||
configured = Behavior.BLOCK
|
||||
elif not config.required and config.missing_behavior == Behavior.CONTINUE:
|
||||
# An explicitly optional, allowed-empty rule is an expected outcome,
|
||||
# not an exception to accept. Hard blocking policy still wins above.
|
||||
configured = Behavior.CONTINUE
|
||||
elif Behavior.DROP in candidates:
|
||||
configured = Behavior.DROP
|
||||
return AttachmentPolicyDecision(
|
||||
requirement_policy=requirement_policy,
|
||||
campaign_policy=campaign_config.attachments.missing_behavior,
|
||||
rule_policy=config.missing_behavior,
|
||||
effective_behavior=configured,
|
||||
legacy_drop_normalized=legacy_drop_normalized,
|
||||
legacy_drop_normalized=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -410,7 +415,10 @@ def _issue_for_missing(
|
||||
) -> AttachmentIssue:
|
||||
code = "missing_required_attachment" if config.required else "missing_optional_attachment"
|
||||
behavior = policy.effective_behavior
|
||||
severity = ResolutionSeverity.ERROR if behavior == Behavior.BLOCK else ResolutionSeverity.WARNING
|
||||
severity = (
|
||||
ResolutionSeverity.ERROR if behavior == Behavior.BLOCK else
|
||||
ResolutionSeverity.INFO if behavior in {Behavior.CONTINUE, Behavior.DROP} else ResolutionSeverity.WARNING
|
||||
)
|
||||
return AttachmentIssue(
|
||||
severity=severity,
|
||||
code=code,
|
||||
@@ -434,9 +442,9 @@ def effective_send_without_attachments_behavior(config: CampaignConfig) -> Behav
|
||||
configured = config.attachments.send_without_attachments_behavior or (
|
||||
Behavior.CONTINUE if config.attachments.send_without_attachments else Behavior.BLOCK
|
||||
)
|
||||
# Recipient exclusion must be an explicit reviewed action, not an implicit
|
||||
# consequence of a legacy attachment policy value.
|
||||
return Behavior.ASK if configured == Behavior.DROP else configured
|
||||
# Configured exclusion is already an explicit policy decision. It must not
|
||||
# be converted into an acceptance prompt that would send the excluded mail.
|
||||
return configured
|
||||
|
||||
|
||||
def _issue_for_missing_attachment_coverage(behavior: Behavior) -> AttachmentIssue:
|
||||
@@ -447,7 +455,7 @@ def _issue_for_missing_attachment_coverage(behavior: Behavior) -> AttachmentIssu
|
||||
Behavior.WARN: "No attachment file was resolved for this message. Campaign policy allows sending with a warning.",
|
||||
}
|
||||
return AttachmentIssue(
|
||||
severity=ResolutionSeverity.ERROR if behavior == Behavior.BLOCK else ResolutionSeverity.WARNING,
|
||||
severity=(ResolutionSeverity.ERROR if behavior == Behavior.BLOCK else ResolutionSeverity.INFO if behavior == Behavior.DROP else ResolutionSeverity.WARNING),
|
||||
code="missing_attachment_coverage",
|
||||
message=messages.get(behavior, "No attachment file was resolved for this message."),
|
||||
behavior=behavior,
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_campaign.backend.campaign.models import (
|
||||
AttachmentReuseAction,
|
||||
AttachmentReuseAllowance,
|
||||
AttachmentReusePolicy,
|
||||
)
|
||||
from govoplan_campaign.backend.messages.models import MessageDraft, MessageIssue
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _AttachmentUse:
|
||||
message: MessageDraft
|
||||
message_key: str
|
||||
recipient_key: tuple[str, ...]
|
||||
source_identity: str
|
||||
file_name: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AttachmentReuseEvaluation:
|
||||
report: dict[str, object]
|
||||
issues_by_entry_index: dict[int, list[MessageIssue]]
|
||||
|
||||
|
||||
def evaluate_attachment_reuse(
|
||||
messages: list[MessageDraft],
|
||||
*,
|
||||
policy: AttachmentReusePolicy,
|
||||
) -> AttachmentReuseEvaluation:
|
||||
"""Evaluate repeated resolved-file use without exposing source paths.
|
||||
|
||||
A use is one resolved file occurrence in one attachment rule. The same
|
||||
source file can therefore be detected both across built messages and when
|
||||
two rules add it to one message. Allowed findings remain in the build
|
||||
protocol; policy violations additionally become recipient-level issues.
|
||||
"""
|
||||
|
||||
uses_by_source: dict[str, list[_AttachmentUse]] = defaultdict(list)
|
||||
for message in messages:
|
||||
if not message.active:
|
||||
continue
|
||||
message_key = str(message.entry_id or message.entry_index)
|
||||
recipient_key = _recipient_key(message, fallback=message_key)
|
||||
for attachment in message.attachments:
|
||||
for match in attachment.matches:
|
||||
source_identity = _source_identity(match)
|
||||
uses_by_source[source_identity].append(
|
||||
_AttachmentUse(
|
||||
message=message,
|
||||
message_key=message_key,
|
||||
recipient_key=recipient_key,
|
||||
source_identity=source_identity,
|
||||
file_name=Path(match).name,
|
||||
)
|
||||
)
|
||||
|
||||
findings: list[dict[str, object]] = []
|
||||
issues_by_entry_index: dict[int, list[MessageIssue]] = defaultdict(list)
|
||||
affected_entry_indexes: set[int] = set()
|
||||
allowed_count = 0
|
||||
violation_count = 0
|
||||
|
||||
for source_identity, uses in sorted(uses_by_source.items()):
|
||||
if len(uses) < 2:
|
||||
continue
|
||||
fingerprint = hashlib.sha256(source_identity.encode("utf-8")).hexdigest()
|
||||
message_keys = {item.message_key for item in uses}
|
||||
recipient_keys = {item.recipient_key for item in uses}
|
||||
allowed, explanation = _is_allowed(
|
||||
policy,
|
||||
message_count=len(message_keys),
|
||||
recipient_count=len(recipient_keys),
|
||||
)
|
||||
disposition = "allowed" if allowed else policy.action.value
|
||||
finding = {
|
||||
"file_fingerprint": fingerprint,
|
||||
"file_name": uses[0].file_name,
|
||||
"use_count": len(uses),
|
||||
"message_count": len(message_keys),
|
||||
"recipient_count": len(recipient_keys),
|
||||
"disposition": disposition,
|
||||
"explanation": explanation,
|
||||
}
|
||||
findings.append(finding)
|
||||
if allowed:
|
||||
allowed_count += 1
|
||||
continue
|
||||
|
||||
violation_count += 1
|
||||
behavior = _issue_behavior(policy.action)
|
||||
severity = (
|
||||
"error" if policy.action == AttachmentReuseAction.BLOCK else "warning"
|
||||
)
|
||||
for use in _unique_message_uses(uses):
|
||||
affected_entry_indexes.add(use.message.entry_index)
|
||||
issues_by_entry_index[use.message.entry_index].append(
|
||||
MessageIssue(
|
||||
severity=severity,
|
||||
code="duplicate_attachment_reuse",
|
||||
message=(
|
||||
f"Attachment {use.file_name!r} is reused {len(uses)} times "
|
||||
f"across {len(message_keys)} built message(s); the configured "
|
||||
f"policy requires {disposition}."
|
||||
),
|
||||
behavior=behavior,
|
||||
source="attachments:reuse_policy",
|
||||
details={
|
||||
**finding,
|
||||
"policy": policy.model_dump(mode="json"),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
return AttachmentReuseEvaluation(
|
||||
report={
|
||||
"contract_version": "1",
|
||||
"policy": policy.model_dump(mode="json"),
|
||||
"duplicate_file_count": len(findings),
|
||||
"allowed_file_count": allowed_count,
|
||||
"violation_file_count": violation_count,
|
||||
"affected_message_count": len(affected_entry_indexes),
|
||||
"findings": findings,
|
||||
},
|
||||
issues_by_entry_index=dict(issues_by_entry_index),
|
||||
)
|
||||
|
||||
|
||||
def _source_identity(value: str) -> str:
|
||||
return str(Path(value).resolve(strict=False))
|
||||
|
||||
|
||||
def _recipient_key(message: MessageDraft, *, fallback: str) -> tuple[str, ...]:
|
||||
addresses = message.to or message.bcc or message.cc
|
||||
normalized = sorted(
|
||||
{
|
||||
item.email.strip().casefold()
|
||||
for item in addresses
|
||||
if item.email and item.email.strip()
|
||||
}
|
||||
)
|
||||
return tuple(normalized) if normalized else (f"entry:{fallback}",)
|
||||
|
||||
|
||||
def _is_allowed(
|
||||
policy: AttachmentReusePolicy,
|
||||
*,
|
||||
message_count: int,
|
||||
recipient_count: int,
|
||||
) -> tuple[bool, str]:
|
||||
if policy.action == AttachmentReuseAction.ALLOW:
|
||||
return True, "The campaign policy explicitly allows attachment reuse."
|
||||
if (
|
||||
policy.allow_within == AttachmentReuseAllowance.SAME_MESSAGE
|
||||
and message_count == 1
|
||||
):
|
||||
return True, "Reuse is confined to one built message as allowed by policy."
|
||||
if (
|
||||
policy.allow_within == AttachmentReuseAllowance.SAME_RECIPIENT
|
||||
and recipient_count == 1
|
||||
):
|
||||
return True, "Reuse is confined to one recipient as allowed by policy."
|
||||
return False, (
|
||||
"Reuse crosses the configured allowance and is handled by the "
|
||||
f"{policy.action.value} policy."
|
||||
)
|
||||
|
||||
|
||||
def _issue_behavior(action: AttachmentReuseAction) -> str:
|
||||
if action == AttachmentReuseAction.REVIEW:
|
||||
return "ask"
|
||||
return action.value
|
||||
|
||||
|
||||
def _unique_message_uses(uses: list[_AttachmentUse]) -> list[_AttachmentUse]:
|
||||
unique: dict[int, _AttachmentUse] = {}
|
||||
for use in uses:
|
||||
unique.setdefault(use.message.entry_index, use)
|
||||
return list(unique.values())
|
||||
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from collections.abc import Mapping
|
||||
|
||||
|
||||
DEFAULT_COPY_OPTIONS: dict[str, bool] = {
|
||||
"include_recipients": True,
|
||||
"include_files": True,
|
||||
"include_shares": False,
|
||||
"include_policies": True,
|
||||
"include_mail_profile": True,
|
||||
}
|
||||
|
||||
|
||||
def campaign_copy_configuration(
|
||||
source: Mapping[str, object],
|
||||
options: Mapping[str, object],
|
||||
) -> dict[str, object]:
|
||||
"""Return an editable configuration copy without operational evidence."""
|
||||
|
||||
selected = {**DEFAULT_COPY_OPTIONS, **dict(options)}
|
||||
raw_json = copy.deepcopy(dict(source))
|
||||
if not selected["include_recipients"]:
|
||||
raw_json["recipients"] = {}
|
||||
raw_json["entries"] = {"inline": [], "imports": []}
|
||||
if not selected["include_files"]:
|
||||
raw_json["attachments"] = {}
|
||||
entries = raw_json.get("entries")
|
||||
if isinstance(entries, dict):
|
||||
inline = entries.get("inline")
|
||||
if isinstance(inline, list):
|
||||
for entry in inline:
|
||||
if isinstance(entry, dict):
|
||||
entry["attachments"] = []
|
||||
entry["combine_attachments"] = True
|
||||
if not selected["include_policies"]:
|
||||
raw_json["validation_policy"] = {}
|
||||
if not selected["include_mail_profile"]:
|
||||
raw_json["server"] = {}
|
||||
return raw_json
|
||||
|
||||
|
||||
__all__ = ["DEFAULT_COPY_OPTIONS", "campaign_copy_configuration"]
|
||||
@@ -12,6 +12,7 @@ from sqlalchemy.orm import Session
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignJob,
|
||||
CampaignSchedule,
|
||||
CampaignShare,
|
||||
CampaignVersion,
|
||||
)
|
||||
@@ -19,7 +20,7 @@ from govoplan_core.auth import ApiPrincipal, has_scope
|
||||
|
||||
|
||||
POLICY_ID = "campaign.lifecycle"
|
||||
POLICY_VERSION = "1"
|
||||
POLICY_VERSION = "2"
|
||||
|
||||
_ACTIVE_QUEUE_STATES = {"queued", "sending"}
|
||||
_ACTIVE_SEND_STATES = {"queued", "claimed", "sending", "outcome_unknown"}
|
||||
@@ -104,6 +105,12 @@ def campaign_lifecycle_policy(
|
||||
.order_by(CampaignShare.id.asc())
|
||||
.all()
|
||||
)
|
||||
schedules = (
|
||||
session.query(CampaignSchedule)
|
||||
.filter(CampaignSchedule.campaign_id == campaign.id)
|
||||
.order_by(CampaignSchedule.id.asc())
|
||||
.all()
|
||||
)
|
||||
selected_version = next(
|
||||
(version for version in versions if version.id == version_id),
|
||||
None,
|
||||
@@ -116,6 +123,10 @@ def campaign_lifecycle_policy(
|
||||
"id": campaign.id,
|
||||
"status": campaign.status,
|
||||
"current_version_id": campaign.current_version_id,
|
||||
"settings_sha256": _canonical_hash(campaign.settings or {}),
|
||||
"mail_profile_policy_sha256": _canonical_hash(
|
||||
campaign.mail_profile_policy or {}
|
||||
),
|
||||
"updated_at": _timestamp(campaign.updated_at),
|
||||
},
|
||||
"versions": [
|
||||
@@ -129,6 +140,7 @@ def campaign_lifecycle_policy(
|
||||
"published_at": _timestamp(version.published_at),
|
||||
"execution_snapshot_at": _timestamp(version.execution_snapshot_at),
|
||||
"archived_at": _timestamp(version.archived_at),
|
||||
"configuration_sha256": _canonical_hash(version.raw_json or {}),
|
||||
"updated_at": _timestamp(version.updated_at),
|
||||
}
|
||||
for version in versions
|
||||
@@ -145,13 +157,34 @@ def campaign_lifecycle_policy(
|
||||
}
|
||||
for job in jobs
|
||||
],
|
||||
"active_share_ids": [share.id for share in shares],
|
||||
"active_shares": [
|
||||
{
|
||||
"id": share.id,
|
||||
"target_type": share.target_type,
|
||||
"target_id": share.target_id,
|
||||
"permission": share.permission,
|
||||
"updated_at": _timestamp(share.updated_at),
|
||||
}
|
||||
for share in shares
|
||||
],
|
||||
"schedules": [
|
||||
{
|
||||
"id": schedule.id,
|
||||
"active": schedule.active,
|
||||
"resource_revision": schedule.resource_revision,
|
||||
"next_fire_at": _timestamp(schedule.next_fire_at),
|
||||
"occurrence_count": schedule.occurrence_count,
|
||||
"updated_at": _timestamp(schedule.updated_at),
|
||||
}
|
||||
for schedule in schedules
|
||||
],
|
||||
"selected_version_id": version_id,
|
||||
}
|
||||
token = _canonical_hash(snapshot)
|
||||
|
||||
active_delivery = any(_active_delivery(job) for job in jobs)
|
||||
protected_versions = any(_protected_version(version) for version in versions)
|
||||
active_schedules = any(schedule.active for schedule in schedules)
|
||||
|
||||
archive = LifecycleDecision(True)
|
||||
if not has_scope(principal, "campaigns:campaign:archive"):
|
||||
@@ -163,6 +196,11 @@ def campaign_lifecycle_policy(
|
||||
False,
|
||||
"Active or uncertain delivery must be resolved before archiving.",
|
||||
)
|
||||
elif active_schedules:
|
||||
archive = LifecycleDecision(
|
||||
False,
|
||||
"Pause active Campaign schedules before archiving.",
|
||||
)
|
||||
|
||||
delete = LifecycleDecision(True)
|
||||
if not has_scope(principal, "campaigns:campaign:delete"):
|
||||
@@ -184,12 +222,15 @@ def campaign_lifecycle_policy(
|
||||
False,
|
||||
"Revoke active campaign shares before deleting the untouched draft.",
|
||||
)
|
||||
elif schedules:
|
||||
delete = LifecycleDecision(
|
||||
False,
|
||||
"Campaigns with schedule evidence must be archived instead of deleted.",
|
||||
)
|
||||
|
||||
copy = LifecycleDecision(True)
|
||||
if not has_scope(principal, "campaigns:campaign:copy"):
|
||||
copy = LifecycleDecision(False, "Missing campaign copy permission.")
|
||||
elif not has_scope(principal, "campaigns:recipient:read"):
|
||||
copy = LifecycleDecision(False, "Recipient read permission is required to copy a campaign.")
|
||||
elif version_id is not None and selected_version is None:
|
||||
copy = LifecycleDecision(False, "The selected source version does not exist.")
|
||||
|
||||
@@ -220,9 +261,10 @@ def campaign_lifecycle_policy(
|
||||
"campaign_state",
|
||||
"retained_evidence",
|
||||
"active_delivery",
|
||||
"scheduled_automation",
|
||||
"optimistic_concurrency",
|
||||
),
|
||||
"evidence_retention": "Versions, delivery outcomes, reports, and audit records are never deleted by archival.",
|
||||
"evidence_retention": "Versions, schedule occurrences, delivery outcomes, reports, and audit records are never deleted by archival.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
from typing import Any
|
||||
|
||||
|
||||
@@ -50,6 +51,12 @@ class CampaignMailProfileBoundaryError(ValueError):
|
||||
"""
|
||||
|
||||
|
||||
def campaign_review_reference(version_id: str, build_token: Any) -> str | None:
|
||||
"""A public concurrency reference, not a raw diagnostic/build claim token."""
|
||||
token = str(build_token or "").strip()
|
||||
return hashlib.sha256(f"campaign-review:{version_id}:{token}".encode()).hexdigest() if token else None
|
||||
|
||||
|
||||
def _validated_opt_ins(value: Any) -> dict[str, bool]:
|
||||
if not isinstance(value, dict) or any(
|
||||
key not in CAMPAIGN_OPT_IN_KEYS for key in value
|
||||
@@ -198,6 +205,24 @@ def campaign_editor_state_for_edit(value: Any) -> dict[str, Any]:
|
||||
return state
|
||||
|
||||
|
||||
def campaign_editor_state_with_client_update(
|
||||
stored: Any, client_state: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Replace client metadata without accepting or erasing server evidence.
|
||||
|
||||
Read responses contain review and approval state, but ordinary editor saves
|
||||
may only supply client-owned fields. Their omission must not delete trusted
|
||||
server evidence; content/build invalidation remains owned by its lifecycle.
|
||||
"""
|
||||
|
||||
result = validate_campaign_editor_state(client_state)
|
||||
server_state = public_campaign_editor_state(stored, include_diagnostics=True)
|
||||
for key in ("review_send", "approval_gate"):
|
||||
if key in server_state:
|
||||
result[key] = server_state[key]
|
||||
return result
|
||||
|
||||
|
||||
def _validated_server_approval_gate(value: Any) -> dict[str, Any]:
|
||||
if not isinstance(value, dict) or any(
|
||||
key not in CAMPAIGN_APPROVAL_GATE_KEYS for key in value
|
||||
@@ -483,3 +508,23 @@ def public_campaign_mail_server(raw_json: dict[str, Any] | None) -> dict[str, st
|
||||
for key, value in campaign_mail_resource_ids(raw_json).items()
|
||||
if value
|
||||
}
|
||||
|
||||
|
||||
def campaign_mail_references_unchanged(
|
||||
current: dict[str, Any] | None, candidate: dict[str, Any] | None
|
||||
) -> bool:
|
||||
"""Recognize an unchanged public selection, never client-owned transport."""
|
||||
|
||||
return (
|
||||
isinstance(candidate, dict)
|
||||
and not campaign_mail_profile_boundary_violations(candidate)
|
||||
and candidate.get("server", {}) == public_campaign_mail_server(current)
|
||||
)
|
||||
|
||||
|
||||
def campaign_preserves_legacy_mail_settings(
|
||||
current: dict[str, Any] | None, candidate: dict[str, Any] | None
|
||||
) -> bool:
|
||||
"""Keep stored legacy transport inert without accepting it from a client."""
|
||||
|
||||
return bool(campaign_mail_profile_boundary_violations(current)) and campaign_mail_references_unchanged(current, candidate)
|
||||
|
||||
@@ -76,6 +76,14 @@ class ZipPasswordScope(StrEnum):
|
||||
GLOBAL = "global"
|
||||
|
||||
|
||||
class ZipPasswordDeliveryChannel(StrEnum):
|
||||
SEPARATE_MAIL = "separate_mail"
|
||||
SMS = "sms"
|
||||
LETTER = "letter"
|
||||
PHONE = "phone"
|
||||
IN_PERSON = "in_person"
|
||||
|
||||
|
||||
class ZipPasswordMode(StrEnum):
|
||||
NONE = "none"
|
||||
DIRECT = "direct"
|
||||
@@ -349,6 +357,13 @@ class ZipArchiveConfig(StrictModel):
|
||||
password_field: str | None = None
|
||||
password_scope: ZipPasswordScope = ZipPasswordScope.LOCAL
|
||||
method: ZipMethod = ZipMethod.AES
|
||||
password_delivery_channel: ZipPasswordDeliveryChannel = (
|
||||
ZipPasswordDeliveryChannel.SEPARATE_MAIL
|
||||
)
|
||||
legacy_zipcrypto_acknowledged: bool = False
|
||||
legacy_zipcrypto_reason: str | None = Field(default=None, max_length=1000)
|
||||
legacy_zipcrypto_acknowledged_by: str | None = Field(default=None, max_length=255)
|
||||
legacy_zipcrypto_acknowledged_at: str | None = Field(default=None, max_length=80)
|
||||
|
||||
# Compatibility fields for campaigns created by the first single-archive
|
||||
# implementation. New WebUI campaigns use password_enabled/field/scope.
|
||||
@@ -376,6 +391,20 @@ class ZipArchiveConfig(StrictModel):
|
||||
normalized["password_scope"] = ZipPasswordScope.LOCAL.value
|
||||
return normalized
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_legacy_zipcrypto_acknowledgement(self) -> "ZipArchiveConfig":
|
||||
if self.method != ZipMethod.ZIP_STANDARD:
|
||||
return self
|
||||
if not self.legacy_zipcrypto_acknowledged:
|
||||
raise ValueError(
|
||||
"Legacy ZipCrypto requires explicit acknowledgement of its weak encryption"
|
||||
)
|
||||
if len((self.legacy_zipcrypto_reason or "").strip()) < 10:
|
||||
raise ValueError(
|
||||
"Legacy ZipCrypto requires an acknowledgement reason of at least 10 characters"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class ZipCollectionConfig(StrictModel):
|
||||
enabled: bool = False
|
||||
@@ -468,6 +497,48 @@ class AttachmentBasePathConfig(StrictModel):
|
||||
source: str | None = None
|
||||
|
||||
|
||||
class ResidualFileMode(StrEnum):
|
||||
NONE = "none"
|
||||
REPORT = "report"
|
||||
ATTACH = "attach"
|
||||
|
||||
|
||||
class AttachmentReuseAction(StrEnum):
|
||||
ALLOW = "allow"
|
||||
WARN = "warn"
|
||||
REVIEW = "review"
|
||||
BLOCK = "block"
|
||||
|
||||
|
||||
class AttachmentReuseAllowance(StrEnum):
|
||||
NONE = "none"
|
||||
SAME_RECIPIENT = "same_recipient"
|
||||
SAME_MESSAGE = "same_message"
|
||||
|
||||
|
||||
class AttachmentReusePolicy(StrictModel):
|
||||
action: AttachmentReuseAction = AttachmentReuseAction.ALLOW
|
||||
allow_within: AttachmentReuseAllowance = AttachmentReuseAllowance.NONE
|
||||
|
||||
|
||||
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 +580,12 @@ class AttachmentsConfig(StrictModel):
|
||||
global_: list[AttachmentConfig] = Field(default_factory=list, alias="global")
|
||||
missing_behavior: Behavior = Behavior.WARN
|
||||
ambiguous_behavior: Behavior = Behavior.ASK
|
||||
reuse_policy: AttachmentReusePolicy = Field(
|
||||
default_factory=AttachmentReusePolicy
|
||||
)
|
||||
residual_files: ResidualFileDispositionConfig = Field(
|
||||
default_factory=ResidualFileDispositionConfig
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def normalize_send_without_attachments_behavior(self) -> "AttachmentsConfig":
|
||||
|
||||
@@ -0,0 +1,964 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from email import policy
|
||||
from email.parser import BytesParser
|
||||
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,
|
||||
CampaignJob,
|
||||
CampaignSchedule,
|
||||
CampaignScheduleOccurrence,
|
||||
CampaignShare,
|
||||
CampaignVersion,
|
||||
JobBuildStatus,
|
||||
)
|
||||
from govoplan_campaign.backend.approval_gate import (
|
||||
assert_campaign_approval,
|
||||
campaign_approval_gate,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.models import DeliveryChannelPolicy
|
||||
from govoplan_campaign.backend.integrations import mail_integration
|
||||
from govoplan_campaign.backend.persistence.campaigns import (
|
||||
create_campaign_version_from_json,
|
||||
)
|
||||
from govoplan_campaign.backend.sending.execution import ensure_execution_snapshot
|
||||
from govoplan_campaign.backend.sending.jobs import (
|
||||
_from_header_from_job,
|
||||
_send_job_delivery_context,
|
||||
_single_job_validation_allowed,
|
||||
_synchronous_smtp_batch_manager,
|
||||
)
|
||||
from govoplan_core.audit.logging import audit_event
|
||||
|
||||
|
||||
RECURRENCE_KINDS = frozenset({"once", "daily", "weekly", "monthly"})
|
||||
SCHEDULE_SOURCE_SCHEMA = "govoplan.campaign.schedule-source.v1"
|
||||
SCHEDULE_DELIVERY_MODES = frozenset({"manual", "autonomous"})
|
||||
|
||||
|
||||
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))
|
||||
refreshed = refresh_autonomous_schedule_outcomes(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
now=observed_at,
|
||||
)
|
||||
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,
|
||||
"autonomous_prepared": 0,
|
||||
"failed": 0,
|
||||
"completed": 0,
|
||||
"coalesced": 0,
|
||||
"duplicates": 0,
|
||||
"deferred": 0,
|
||||
"campaign_ids": [],
|
||||
"operator_actions": [],
|
||||
"refreshed": refreshed,
|
||||
}
|
||||
for schedule in schedules:
|
||||
scheduled_for = _as_utc(schedule.next_fire_at or observed_at)
|
||||
if schedule.delivery_mode == "autonomous" and _has_open_occurrence(
|
||||
session, schedule_id=schedule.id
|
||||
):
|
||||
result["deferred"] = int(result["deferred"]) + 1
|
||||
continue
|
||||
try:
|
||||
with session.begin_nested():
|
||||
if schedule.delivery_mode == "autonomous":
|
||||
_occurrence, skipped = _prepare_autonomous_occurrence(
|
||||
session,
|
||||
schedule=schedule,
|
||||
scheduled_for=scheduled_for,
|
||||
observed_at=observed_at,
|
||||
)
|
||||
campaign_id = schedule.campaign_id
|
||||
result["autonomous_prepared"] = (
|
||||
int(result["autonomous_prepared"]) + 1
|
||||
)
|
||||
else:
|
||||
campaign, _version, skipped = _prepare_occurrence(
|
||||
session,
|
||||
schedule=schedule,
|
||||
scheduled_for=scheduled_for,
|
||||
observed_at=observed_at,
|
||||
)
|
||||
campaign_id = campaign.id
|
||||
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.expire_all()
|
||||
recorded = (
|
||||
session.query(CampaignScheduleOccurrence)
|
||||
.filter(
|
||||
CampaignScheduleOccurrence.schedule_id == schedule.id,
|
||||
CampaignScheduleOccurrence.scheduled_for == scheduled_for,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if recorded is not None:
|
||||
result["duplicates"] = int(result["duplicates"]) + 1
|
||||
if (
|
||||
schedule.active
|
||||
and schedule.next_fire_at is not None
|
||||
and _as_utc(schedule.next_fire_at) == scheduled_for
|
||||
and recorded.status not in {"failed", "uncertain"}
|
||||
):
|
||||
_advance_schedule(
|
||||
session,
|
||||
schedule=schedule,
|
||||
occurrence=recorded,
|
||||
scheduled_for=scheduled_for,
|
||||
observed_at=observed_at,
|
||||
sequence=schedule.occurrence_count + 1,
|
||||
)
|
||||
continue
|
||||
session.add(
|
||||
CampaignScheduleOccurrence(
|
||||
tenant_id=schedule.tenant_id,
|
||||
schedule_id=schedule.id,
|
||||
scheduled_for=scheduled_for,
|
||||
status="failed",
|
||||
idempotency_key=_occurrence_idempotency_key(
|
||||
schedule.id, scheduled_for
|
||||
),
|
||||
error=str(exc)[:4000],
|
||||
recovery_state="failed",
|
||||
evidence={"delivery_mode": schedule.delivery_mode},
|
||||
last_checked_at=observed_at,
|
||||
)
|
||||
)
|
||||
schedule.active = False
|
||||
schedule.last_error = str(exc)[:4000]
|
||||
schedule.last_outcome = "failed"
|
||||
schedule.last_recovery_state = "operator_required"
|
||||
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",
|
||||
"delivery_mode": schedule.delivery_mode,
|
||||
}
|
||||
)
|
||||
_notify_schedule_operator(
|
||||
session,
|
||||
schedule=schedule,
|
||||
reason="policy_or_systemic_preflight_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",
|
||||
idempotency_key=_occurrence_idempotency_key(schedule.id, scheduled_for),
|
||||
generated_campaign_id=generated_campaign.id,
|
||||
generated_version_id=generated_version.id,
|
||||
recovery_state="none",
|
||||
evidence={"delivery_mode": "manual"},
|
||||
last_checked_at=observed_at,
|
||||
)
|
||||
session.add(occurrence)
|
||||
session.flush()
|
||||
schedule.last_campaign_id = generated_campaign.id
|
||||
schedule.last_outcome = "prepared"
|
||||
schedule.last_recovery_state = "none"
|
||||
coalesced = _advance_schedule(
|
||||
session,
|
||||
schedule=schedule,
|
||||
occurrence=occurrence,
|
||||
scheduled_for=scheduled_for,
|
||||
observed_at=observed_at,
|
||||
sequence=sequence,
|
||||
)
|
||||
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 validate_autonomous_schedule_source(
|
||||
session: Session,
|
||||
*,
|
||||
campaign: Campaign,
|
||||
version: CampaignVersion,
|
||||
) -> dict[str, object]:
|
||||
"""Validate the exact immutable execution that an autonomous schedule reuses."""
|
||||
|
||||
gate = campaign_approval_gate(version)
|
||||
if gate is None:
|
||||
raise RuntimeError(
|
||||
"Autonomous delivery requires an explicit Approval request for the built source version."
|
||||
)
|
||||
assert_campaign_approval(session, tenant_id=campaign.tenant_id, version=version)
|
||||
snapshot = ensure_execution_snapshot(session, version)
|
||||
snapshot_hash = str(version.execution_snapshot_hash or "")
|
||||
if len(snapshot_hash) != 64:
|
||||
raise RuntimeError("The approved Campaign execution snapshot is incomplete.")
|
||||
jobs = _autonomous_source_jobs(
|
||||
session,
|
||||
tenant_id=campaign.tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
version=version,
|
||||
)
|
||||
mail = mail_integration()
|
||||
if not mail.durable_delivery_available:
|
||||
raise RuntimeError(
|
||||
"Autonomous delivery requires Mail's durable delivery-command outbox."
|
||||
)
|
||||
if not snapshot.mail_profile_id or not snapshot.smtp_transport_revision:
|
||||
raise RuntimeError(
|
||||
"The approved Campaign execution has no immutable Mail transport evidence."
|
||||
)
|
||||
summary = mail.campaign_profile_delivery_summary(
|
||||
session,
|
||||
tenant_id=campaign.tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
profile_id=snapshot.mail_profile_id,
|
||||
smtp_server_id=snapshot.smtp_server_id,
|
||||
smtp_credential_id=snapshot.smtp_credential_id,
|
||||
)
|
||||
if not summary.get("smtp_available"):
|
||||
raise RuntimeError("The approved Campaign Mail transport is unavailable.")
|
||||
if summary.get("smtp_transport_revision") != snapshot.smtp_transport_revision:
|
||||
raise RuntimeError(
|
||||
"The Campaign Mail transport changed after approval; rebuild and approve a new source version."
|
||||
)
|
||||
return {
|
||||
"execution_snapshot_hash": snapshot_hash,
|
||||
"approval_request_id": str(gate.get("request_id") or ""),
|
||||
"approval_subject_digest": str(gate.get("subject_digest") or ""),
|
||||
"job_count": len(jobs),
|
||||
"job_manifest_sha256": canonical_configuration_hash(
|
||||
{"jobs": [{"id": job.id, "eml_sha256": job.eml_sha256} for job in jobs]}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _autonomous_source_jobs(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
version: CampaignVersion,
|
||||
) -> list[CampaignJob]:
|
||||
jobs = (
|
||||
session.query(CampaignJob)
|
||||
.filter(
|
||||
CampaignJob.tenant_id == tenant_id,
|
||||
CampaignJob.campaign_id == campaign_id,
|
||||
CampaignJob.campaign_version_id == version.id,
|
||||
)
|
||||
.order_by(CampaignJob.entry_index.asc(), CampaignJob.id.asc())
|
||||
.all()
|
||||
)
|
||||
if not jobs:
|
||||
raise RuntimeError(
|
||||
"Autonomous delivery requires a built source version with recipient jobs."
|
||||
)
|
||||
for job in jobs:
|
||||
if job.build_status != JobBuildStatus.BUILT.value:
|
||||
raise RuntimeError(
|
||||
"Autonomous delivery requires every source message to be built."
|
||||
)
|
||||
if not _single_job_validation_allowed(version, job, include_warnings=True):
|
||||
raise RuntimeError(
|
||||
"Autonomous delivery requires every source message to pass its reviewed recipient and attachment gates."
|
||||
)
|
||||
if DeliveryChannelPolicy(job.delivery_channel_policy) != DeliveryChannelPolicy.MAIL:
|
||||
raise RuntimeError(
|
||||
"Autonomous schedules currently support Mail-only delivery; use manual mode for hybrid, Postbox, or print delivery."
|
||||
)
|
||||
return jobs
|
||||
|
||||
|
||||
def _prepare_autonomous_occurrence(
|
||||
session: Session,
|
||||
*,
|
||||
schedule: CampaignSchedule,
|
||||
scheduled_for: datetime,
|
||||
observed_at: datetime,
|
||||
) -> tuple[CampaignScheduleOccurrence, 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")
|
||||
if canonical_configuration_hash(schedule.source_snapshot) != schedule.source_snapshot_hash:
|
||||
raise RuntimeError("Campaign schedule source snapshot integrity check failed")
|
||||
campaign = session.get(Campaign, schedule.campaign_id)
|
||||
version = session.get(CampaignVersion, schedule.source_version_id)
|
||||
if campaign is None or campaign.tenant_id != schedule.tenant_id:
|
||||
raise RuntimeError("Campaign schedule source is no longer available")
|
||||
if version is None or version.campaign_id != campaign.id:
|
||||
raise RuntimeError("Campaign schedule source version is no longer available")
|
||||
validation = validate_autonomous_schedule_source(
|
||||
session,
|
||||
campaign=campaign,
|
||||
version=version,
|
||||
)
|
||||
if (
|
||||
not schedule.approved_execution_snapshot_hash
|
||||
or validation["execution_snapshot_hash"]
|
||||
!= schedule.approved_execution_snapshot_hash
|
||||
):
|
||||
raise RuntimeError(
|
||||
"The approved Campaign execution changed after the autonomous schedule was created."
|
||||
)
|
||||
jobs = _autonomous_source_jobs(
|
||||
session,
|
||||
tenant_id=schedule.tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
version=version,
|
||||
)
|
||||
occurrence_key = _occurrence_idempotency_key(schedule.id, scheduled_for)
|
||||
occurrence = CampaignScheduleOccurrence(
|
||||
tenant_id=schedule.tenant_id,
|
||||
schedule_id=schedule.id,
|
||||
scheduled_for=scheduled_for,
|
||||
status="preparing",
|
||||
idempotency_key=occurrence_key,
|
||||
recovery_state="prepared",
|
||||
evidence={
|
||||
"delivery_mode": "autonomous",
|
||||
"source_campaign_id": campaign.id,
|
||||
"source_version_id": version.id,
|
||||
"source_snapshot_hash": schedule.source_snapshot_hash,
|
||||
**validation,
|
||||
},
|
||||
last_checked_at=observed_at,
|
||||
)
|
||||
session.add(occurrence)
|
||||
session.flush()
|
||||
|
||||
contexts = {job.id: _send_job_delivery_context(session, job) for job in jobs}
|
||||
with _synchronous_smtp_batch_manager(session, jobs=jobs, contexts=contexts):
|
||||
pass
|
||||
|
||||
mail = mail_integration()
|
||||
commands: list[dict[str, object]] = []
|
||||
for job in jobs:
|
||||
context = contexts[job.id]
|
||||
if context.envelope_from is None or not context.envelope_recipients:
|
||||
raise RuntimeError("A frozen Campaign message has no delivery envelope.")
|
||||
message = BytesParser(policy=policy.default).parsebytes(context.message_bytes)
|
||||
commands.append(
|
||||
mail.submit_delivery_command(
|
||||
session,
|
||||
tenant_id=schedule.tenant_id,
|
||||
command_type="campaign_schedule_occurrence",
|
||||
source_module="campaigns",
|
||||
source_resource_type="campaign",
|
||||
source_resource_id=campaign.id,
|
||||
source_version_id=version.id,
|
||||
idempotency_key=f"{occurrence_key}:{job.id}",
|
||||
profile_id=context.snapshot.mail_profile_id,
|
||||
message_bytes=context.message_bytes,
|
||||
envelope_from=context.envelope_from,
|
||||
envelope_recipients=context.envelope_recipients,
|
||||
from_header=_from_header_from_job(job) or str(message.get("From") or ""),
|
||||
expected_smtp_transport_revision=(
|
||||
context.snapshot.smtp_transport_revision or ""
|
||||
),
|
||||
smtp_server_id=context.snapshot.smtp_server_id,
|
||||
smtp_credential_id=context.snapshot.smtp_credential_id,
|
||||
created_by_user_id=schedule.created_by_user_id,
|
||||
)
|
||||
)
|
||||
occurrence.delivery_command_ids = [str(item["id"]) for item in commands]
|
||||
occurrence.status = "prepared"
|
||||
occurrence.recovery_state = "pending"
|
||||
occurrence.evidence = {
|
||||
**occurrence.evidence,
|
||||
"command_count": len(commands),
|
||||
"duplicate_command_count": sum(bool(item.get("duplicate")) for item in commands),
|
||||
"command_status_counts": _status_counts(commands),
|
||||
}
|
||||
occurrence.last_checked_at = observed_at
|
||||
sequence = schedule.occurrence_count + 1
|
||||
schedule.last_campaign_id = campaign.id
|
||||
schedule.last_outcome = "prepared"
|
||||
schedule.last_recovery_state = "pending"
|
||||
coalesced = _advance_schedule(
|
||||
session,
|
||||
schedule=schedule,
|
||||
occurrence=occurrence,
|
||||
scheduled_for=scheduled_for,
|
||||
observed_at=observed_at,
|
||||
sequence=sequence,
|
||||
)
|
||||
audit_event(
|
||||
session,
|
||||
tenant_id=schedule.tenant_id,
|
||||
user_id=schedule.created_by_user_id,
|
||||
action="campaign.schedule.delivery_prepared",
|
||||
object_type="campaign_schedule_occurrence",
|
||||
object_id=occurrence.id,
|
||||
details={
|
||||
"schedule_id": schedule.id,
|
||||
"campaign_id": campaign.id,
|
||||
"source_version_id": version.id,
|
||||
"scheduled_for": scheduled_for.isoformat(),
|
||||
"occurrence_idempotency_key": occurrence_key,
|
||||
"delivery_command_count": len(commands),
|
||||
"execution_snapshot_hash": validation["execution_snapshot_hash"],
|
||||
"approval_request_id": validation["approval_request_id"],
|
||||
"coalesced_missed_intervals": coalesced,
|
||||
},
|
||||
commit=False,
|
||||
)
|
||||
return occurrence, coalesced
|
||||
|
||||
|
||||
def _advance_schedule(
|
||||
session: Session,
|
||||
*,
|
||||
schedule: CampaignSchedule,
|
||||
occurrence: CampaignScheduleOccurrence,
|
||||
scheduled_for: datetime,
|
||||
observed_at: datetime,
|
||||
sequence: int,
|
||||
) -> int:
|
||||
schedule.occurrence_count = sequence
|
||||
schedule.last_fired_at = scheduled_for
|
||||
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:
|
||||
session.add(
|
||||
CampaignScheduleOccurrence(
|
||||
tenant_id=schedule.tenant_id,
|
||||
schedule_id=schedule.id,
|
||||
scheduled_for=next_fire,
|
||||
status="superseded",
|
||||
idempotency_key=_occurrence_idempotency_key(schedule.id, next_fire),
|
||||
recovery_state="superseded",
|
||||
evidence={
|
||||
"delivery_mode": schedule.delivery_mode,
|
||||
"reason": "coalesced_missed_interval",
|
||||
"superseded_by_occurrence_id": occurrence.id,
|
||||
},
|
||||
last_checked_at=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)
|
||||
return coalesced
|
||||
|
||||
|
||||
def refresh_autonomous_schedule_outcomes(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, int]:
|
||||
observed_at = _as_utc(now or datetime.now(UTC))
|
||||
query = session.query(CampaignScheduleOccurrence).filter(
|
||||
CampaignScheduleOccurrence.status.in_(("prepared", "uncertain")),
|
||||
)
|
||||
if tenant_id is not None:
|
||||
query = query.filter(CampaignScheduleOccurrence.tenant_id == tenant_id)
|
||||
counts = {
|
||||
"checked": 0,
|
||||
"accepted": 0,
|
||||
"uncertain": 0,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
}
|
||||
mail = mail_integration()
|
||||
if not mail.durable_delivery_available:
|
||||
for occurrence in query.order_by(
|
||||
CampaignScheduleOccurrence.created_at
|
||||
).limit(250):
|
||||
if not occurrence.delivery_command_ids:
|
||||
continue
|
||||
counts["checked"] += 1
|
||||
counts["uncertain"] += 1
|
||||
_mark_occurrence_uncertain(
|
||||
session,
|
||||
occurrence=occurrence,
|
||||
observed_at=observed_at,
|
||||
reason="mail_delivery_outbox_unavailable",
|
||||
)
|
||||
return counts
|
||||
for occurrence in query.order_by(CampaignScheduleOccurrence.created_at).limit(250):
|
||||
if not occurrence.delivery_command_ids:
|
||||
continue
|
||||
summaries: list[dict[str, object]] = []
|
||||
try:
|
||||
summaries = [
|
||||
mail.delivery_command_summary(
|
||||
session,
|
||||
tenant_id=occurrence.tenant_id,
|
||||
command_id=command_id,
|
||||
)
|
||||
for command_id in occurrence.delivery_command_ids
|
||||
]
|
||||
except Exception:
|
||||
counts["checked"] += 1
|
||||
counts["uncertain"] += 1
|
||||
_mark_occurrence_uncertain(
|
||||
session,
|
||||
occurrence=occurrence,
|
||||
observed_at=observed_at,
|
||||
reason="mail_delivery_status_unavailable",
|
||||
)
|
||||
continue
|
||||
counts["checked"] += 1
|
||||
outcome, recovery_state = _aggregate_command_outcome(summaries)
|
||||
previous_outcome = occurrence.status
|
||||
previous_recovery_state = occurrence.recovery_state
|
||||
occurrence.status = outcome
|
||||
occurrence.recovery_state = recovery_state
|
||||
occurrence.last_checked_at = observed_at
|
||||
occurrence.evidence = {
|
||||
**(occurrence.evidence or {}),
|
||||
"command_status_counts": _status_counts(summaries),
|
||||
"accepted_recipient_count": sum(
|
||||
int(item.get("accepted_count") or 0) for item in summaries
|
||||
),
|
||||
"refused_recipient_count": sum(
|
||||
int(item.get("refused_count") or 0) for item in summaries
|
||||
),
|
||||
"failure_codes": sorted(
|
||||
{
|
||||
str(item["failure_code"])
|
||||
for item in summaries
|
||||
if item.get("failure_code")
|
||||
}
|
||||
),
|
||||
}
|
||||
schedule = session.get(CampaignSchedule, occurrence.schedule_id)
|
||||
if schedule is not None:
|
||||
schedule.last_outcome = outcome
|
||||
schedule.last_recovery_state = recovery_state
|
||||
transitioned_to_operator_required = (
|
||||
outcome in {"uncertain", "failed"}
|
||||
and (
|
||||
previous_outcome != outcome
|
||||
or previous_recovery_state != recovery_state
|
||||
or schedule.active
|
||||
)
|
||||
)
|
||||
if transitioned_to_operator_required:
|
||||
schedule.active = False
|
||||
schedule.last_error = (
|
||||
"Autonomous delivery needs operator review; automatic recurrence is paused."
|
||||
)
|
||||
schedule.resource_revision += 1
|
||||
_notify_schedule_operator(
|
||||
session,
|
||||
schedule=schedule,
|
||||
reason=f"delivery_{outcome}",
|
||||
)
|
||||
session.add(schedule)
|
||||
session.add(occurrence)
|
||||
if outcome in counts:
|
||||
counts[outcome] += 1
|
||||
return counts
|
||||
|
||||
|
||||
def _mark_occurrence_uncertain(
|
||||
session: Session,
|
||||
*,
|
||||
occurrence: CampaignScheduleOccurrence,
|
||||
observed_at: datetime,
|
||||
reason: str,
|
||||
) -> None:
|
||||
previous_outcome = occurrence.status
|
||||
previous_recovery_state = occurrence.recovery_state
|
||||
occurrence.status = "uncertain"
|
||||
occurrence.recovery_state = "operator_required"
|
||||
occurrence.last_checked_at = observed_at
|
||||
occurrence.evidence = {
|
||||
**(occurrence.evidence or {}),
|
||||
"recovery_reason": reason,
|
||||
}
|
||||
schedule = session.get(CampaignSchedule, occurrence.schedule_id)
|
||||
if schedule is not None:
|
||||
transitioned = (
|
||||
previous_outcome != "uncertain"
|
||||
or previous_recovery_state != "operator_required"
|
||||
or schedule.active
|
||||
)
|
||||
schedule.active = False
|
||||
schedule.last_outcome = "uncertain"
|
||||
schedule.last_recovery_state = "operator_required"
|
||||
schedule.last_error = (
|
||||
"Autonomous delivery status is unavailable; automatic recurrence is paused."
|
||||
)
|
||||
if transitioned:
|
||||
schedule.resource_revision += 1
|
||||
_notify_schedule_operator(
|
||||
session,
|
||||
schedule=schedule,
|
||||
reason=reason,
|
||||
)
|
||||
session.add(schedule)
|
||||
session.add(occurrence)
|
||||
|
||||
|
||||
def _has_open_occurrence(session: Session, *, schedule_id: str) -> bool:
|
||||
rows = (
|
||||
session.query(CampaignScheduleOccurrence.delivery_command_ids)
|
||||
.filter(
|
||||
CampaignScheduleOccurrence.schedule_id == schedule_id,
|
||||
CampaignScheduleOccurrence.status == "prepared",
|
||||
)
|
||||
.limit(1000)
|
||||
.all()
|
||||
)
|
||||
return any(bool(command_ids) for (command_ids,) in rows)
|
||||
|
||||
|
||||
def _aggregate_command_outcome(
|
||||
summaries: list[dict[str, object]],
|
||||
) -> tuple[str, str]:
|
||||
statuses = {str(item.get("status") or "") for item in summaries}
|
||||
if statuses and statuses <= {"accepted", "reconciled_accepted"}:
|
||||
return "accepted", "complete"
|
||||
if statuses and statuses <= {"reconciled_not_accepted"}:
|
||||
return "skipped", "reconciled"
|
||||
if statuses & {"outcome_unknown", "in_progress"}:
|
||||
return "uncertain", "operator_required"
|
||||
if statuses & {"permanent_failure", "partially_refused", "reconciled_not_accepted"}:
|
||||
return "failed", "operator_required"
|
||||
return "prepared", "pending"
|
||||
|
||||
|
||||
def _status_counts(items: list[dict[str, object]]) -> dict[str, int]:
|
||||
result: dict[str, int] = {}
|
||||
for item in items:
|
||||
status = str(item.get("status") or "unknown")
|
||||
result[status] = result.get(status, 0) + 1
|
||||
return result
|
||||
|
||||
|
||||
def _occurrence_idempotency_key(schedule_id: str, scheduled_for: datetime) -> str:
|
||||
return f"campaign-schedule:{schedule_id}:{_as_utc(scheduled_for).isoformat()}"
|
||||
|
||||
|
||||
def _notify_schedule_operator(
|
||||
session: Session,
|
||||
*,
|
||||
schedule: CampaignSchedule,
|
||||
reason: str,
|
||||
) -> None:
|
||||
from govoplan_core.core.notifications import (
|
||||
NotificationDispatchRequest,
|
||||
notification_dispatch_provider,
|
||||
)
|
||||
from govoplan_campaign.backend.runtime import get_registry
|
||||
|
||||
provider = notification_dispatch_provider(get_registry())
|
||||
if provider is None:
|
||||
return
|
||||
try:
|
||||
provider.enqueue_notification(
|
||||
session,
|
||||
NotificationDispatchRequest(
|
||||
tenant_id=schedule.tenant_id,
|
||||
source_module="campaigns",
|
||||
source_resource_type="campaign_schedule",
|
||||
source_resource_id=schedule.id,
|
||||
event_kind="campaign.schedule.operator_required",
|
||||
channel="inbox",
|
||||
recipient_type="user" if schedule.created_by_user_id else None,
|
||||
recipient_id=schedule.created_by_user_id,
|
||||
subject=f"Campaign schedule paused: {schedule.name}",
|
||||
body_text=(
|
||||
"Autonomous Campaign delivery was paused before another occurrence. "
|
||||
"Review its recovery evidence before resuming."
|
||||
),
|
||||
action_url=f"/campaigns/{schedule.campaign_id}",
|
||||
priority=2,
|
||||
payload={"schedule_id": schedule.id, "reason": reason},
|
||||
),
|
||||
enqueue_delivery=False,
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
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",
|
||||
"refresh_autonomous_schedule_outcomes",
|
||||
"validate_autonomous_schedule_source",
|
||||
]
|
||||
@@ -0,0 +1,730 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
from collections import Counter
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from govoplan_campaign.backend.campaign.loader import validate_against_schema
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignIssue,
|
||||
CampaignJob,
|
||||
CampaignVersion,
|
||||
)
|
||||
from govoplan_campaign.backend.persistence.versions import minimal_campaign_json
|
||||
from govoplan_campaign.backend.response_security import (
|
||||
public_campaign_configuration,
|
||||
public_campaign_payload,
|
||||
)
|
||||
|
||||
|
||||
PORTABLE_CAMPAIGN_FORMAT = "govoplan.campaign-portable"
|
||||
PORTABLE_CAMPAIGN_FORMAT_VERSION = "1.0"
|
||||
PORTABLE_CAMPAIGN_SCOPE_ORDER = (
|
||||
"metadata",
|
||||
"template_config",
|
||||
"recipients",
|
||||
"attachments",
|
||||
"review_state",
|
||||
"delivery_history",
|
||||
)
|
||||
DEFAULT_PORTABLE_CAMPAIGN_SCOPES = ("metadata", "template_config")
|
||||
OPERATIONAL_EVIDENCE_SCOPES = frozenset(("review_state", "delivery_history"))
|
||||
_CONFIG_STRUCTURAL_KEYS = frozenset(
|
||||
("version", "campaign", "recipients", "entries", "attachments")
|
||||
)
|
||||
_SENSITIVE_SETTING_FRAGMENTS = (
|
||||
"api_key",
|
||||
"credential",
|
||||
"password",
|
||||
"private_key",
|
||||
"secret",
|
||||
"token",
|
||||
)
|
||||
|
||||
|
||||
class CampaignTransferError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CampaignImportInspection:
|
||||
preview: dict[str, Any]
|
||||
configuration: dict[str, Any] | None
|
||||
portable_settings: dict[str, Any]
|
||||
|
||||
|
||||
def canonical_sha256(value: object) -> str:
|
||||
encoded = json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
def normalize_transfer_scopes(scopes: Iterable[str]) -> tuple[str, ...]:
|
||||
selected = set(scopes)
|
||||
invalid = sorted(selected.difference(PORTABLE_CAMPAIGN_SCOPE_ORDER))
|
||||
if invalid:
|
||||
raise CampaignTransferError(
|
||||
f"Unsupported campaign transfer scope(s): {', '.join(invalid)}"
|
||||
)
|
||||
if not selected:
|
||||
raise CampaignTransferError("Select at least one campaign transfer scope.")
|
||||
return tuple(scope for scope in PORTABLE_CAMPAIGN_SCOPE_ORDER if scope in selected)
|
||||
|
||||
|
||||
def build_campaign_portable_package(
|
||||
*,
|
||||
campaign: Campaign,
|
||||
version: CampaignVersion,
|
||||
scopes: Iterable[str],
|
||||
jobs: Iterable[CampaignJob] = (),
|
||||
issues: Iterable[CampaignIssue] = (),
|
||||
module_version: str,
|
||||
) -> dict[str, Any]:
|
||||
selected = normalize_transfer_scopes(scopes)
|
||||
configuration = public_campaign_configuration(version.raw_json)
|
||||
if not isinstance(configuration, dict):
|
||||
raise CampaignTransferError("The campaign configuration is not portable JSON.")
|
||||
configuration, password_redactions = _redact_password_field_values(configuration)
|
||||
payload: dict[str, Any] = {}
|
||||
item_counts: dict[str, int] = {}
|
||||
redactions: Counter[str] = Counter(password_redactions)
|
||||
|
||||
if "metadata" in selected:
|
||||
payload["metadata"] = {
|
||||
"external_id": campaign.external_id,
|
||||
"name": campaign.name,
|
||||
"description": campaign.description,
|
||||
"source_status": campaign.status,
|
||||
}
|
||||
item_counts["metadata"] = 1
|
||||
|
||||
if "template_config" in selected:
|
||||
settings, setting_redactions = _redact_sensitive_settings(
|
||||
campaign.settings or {}
|
||||
)
|
||||
mail_policy, mail_policy_redactions = _redact_sensitive_settings(
|
||||
campaign.mail_profile_policy or {}
|
||||
)
|
||||
template_configuration = {
|
||||
key: copy.deepcopy(value)
|
||||
for key, value in configuration.items()
|
||||
if key not in _CONFIG_STRUCTURAL_KEYS
|
||||
}
|
||||
server = template_configuration.get("server")
|
||||
if isinstance(server, dict):
|
||||
for key in ("smtp_credential_id", "imap_credential_id"):
|
||||
if server.pop(key, None) is not None:
|
||||
redactions["deployment_credential_reference"] += 1
|
||||
payload["template_config"] = {
|
||||
"schema_version": version.schema_version,
|
||||
"configuration": template_configuration,
|
||||
"campaign_settings": settings,
|
||||
"mail_profile_policy": mail_policy,
|
||||
}
|
||||
redactions.update(setting_redactions)
|
||||
redactions.update(mail_policy_redactions)
|
||||
item_counts["template_config"] = len(template_configuration)
|
||||
|
||||
if "recipients" in selected:
|
||||
entries = copy.deepcopy(configuration.get("entries") or {})
|
||||
_remove_entry_attachments(entries)
|
||||
payload["recipients"] = {
|
||||
"recipients": copy.deepcopy(configuration.get("recipients") or {}),
|
||||
"entries": entries,
|
||||
}
|
||||
item_counts["recipients"] = _recipient_entry_count(entries)
|
||||
|
||||
if "attachments" in selected:
|
||||
entry_attachments = _entry_attachment_projection(
|
||||
configuration.get("entries")
|
||||
)
|
||||
payload["attachments"] = {
|
||||
"configuration": copy.deepcopy(configuration.get("attachments") or {}),
|
||||
"entry_attachments": entry_attachments,
|
||||
"content_included": False,
|
||||
}
|
||||
item_counts["attachments"] = _attachment_rule_count(
|
||||
payload["attachments"]
|
||||
)
|
||||
|
||||
issue_rows = tuple(issues)
|
||||
if "review_state" in selected:
|
||||
review_state = _review_state_projection(version, issue_rows)
|
||||
payload["review_state"] = review_state
|
||||
item_counts["review_state"] = int(review_state["decision_count"])
|
||||
|
||||
job_rows = tuple(jobs)
|
||||
if "delivery_history" in selected:
|
||||
payload["delivery_history"] = {
|
||||
"jobs": [_delivery_job_projection(job) for job in job_rows],
|
||||
"counts": _delivery_counts(job_rows),
|
||||
}
|
||||
item_counts["delivery_history"] = len(job_rows)
|
||||
|
||||
exported_at = datetime.now(UTC)
|
||||
package: dict[str, Any] = {
|
||||
"format": PORTABLE_CAMPAIGN_FORMAT,
|
||||
"format_version": PORTABLE_CAMPAIGN_FORMAT_VERSION,
|
||||
"package_id": str(uuid4()),
|
||||
"exported_at": exported_at.isoformat(),
|
||||
"source": {
|
||||
"module": "campaigns",
|
||||
"module_version": module_version,
|
||||
"tenant_ref_sha256": hashlib.sha256(
|
||||
campaign.tenant_id.encode("utf-8")
|
||||
).hexdigest(),
|
||||
"campaign_id": campaign.id,
|
||||
"campaign_external_id": campaign.external_id,
|
||||
"campaign_name": campaign.name,
|
||||
"version_id": version.id,
|
||||
"version_number": version.version_number,
|
||||
"campaign_schema_version": version.schema_version,
|
||||
},
|
||||
"scopes": list(selected),
|
||||
"manifest": {
|
||||
"item_counts": item_counts,
|
||||
"redactions": dict(sorted(redactions.items())),
|
||||
"privacy_default_scopes": list(DEFAULT_PORTABLE_CAMPAIGN_SCOPES),
|
||||
"attachments_are_references_only": True,
|
||||
"operational_evidence_is_not_replayed": True,
|
||||
"secrets_included": False,
|
||||
},
|
||||
"payload": payload,
|
||||
}
|
||||
package["integrity"] = {
|
||||
"algorithm": "sha256",
|
||||
"package_sha256": canonical_sha256(package),
|
||||
}
|
||||
return package
|
||||
|
||||
|
||||
def inspect_campaign_portable_package(
|
||||
package: Mapping[str, Any],
|
||||
*,
|
||||
selected_scopes: Iterable[str] | None,
|
||||
external_id: str,
|
||||
name: str,
|
||||
) -> CampaignImportInspection:
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
package_dict = copy.deepcopy(dict(package))
|
||||
package_id = _optional_text(package_dict.get("package_id"))
|
||||
format_version = _optional_text(package_dict.get("format_version"))
|
||||
source = package_dict.get("source")
|
||||
source_dict = copy.deepcopy(source) if isinstance(source, dict) else {}
|
||||
integrity = package_dict.get("integrity")
|
||||
expected_hash = (
|
||||
_optional_text(integrity.get("package_sha256"))
|
||||
if isinstance(integrity, dict)
|
||||
else None
|
||||
)
|
||||
hash_input = copy.deepcopy(package_dict)
|
||||
hash_input.pop("integrity", None)
|
||||
actual_hash = canonical_sha256(hash_input)
|
||||
|
||||
if package_dict.get("format") != PORTABLE_CAMPAIGN_FORMAT:
|
||||
errors.append("The file is not a GovOPlaN portable Campaign package.")
|
||||
if format_version != PORTABLE_CAMPAIGN_FORMAT_VERSION:
|
||||
errors.append(
|
||||
"The Campaign package format version is not supported by this installation."
|
||||
)
|
||||
if not package_id:
|
||||
errors.append("The Campaign package has no package identifier.")
|
||||
if not expected_hash or expected_hash != actual_hash:
|
||||
errors.append("The Campaign package integrity checksum does not match its content.")
|
||||
if not isinstance(integrity, dict) or integrity.get("algorithm") != "sha256":
|
||||
errors.append("The Campaign package does not use the supported SHA-256 integrity algorithm.")
|
||||
if not source_dict:
|
||||
errors.append("The Campaign package has no source provenance.")
|
||||
elif source_dict.get("campaign_schema_version") != "1.0":
|
||||
errors.append("The Campaign configuration schema version is not supported by this installation.")
|
||||
|
||||
available: tuple[str, ...] = ()
|
||||
try:
|
||||
raw_scopes = package_dict.get("scopes")
|
||||
if not isinstance(raw_scopes, list):
|
||||
raise CampaignTransferError("The Campaign package has no valid scope list.")
|
||||
available = normalize_transfer_scopes(str(item) for item in raw_scopes)
|
||||
except CampaignTransferError as exc:
|
||||
errors.append(str(exc))
|
||||
|
||||
try:
|
||||
selected = normalize_transfer_scopes(
|
||||
available if selected_scopes is None else selected_scopes
|
||||
)
|
||||
except CampaignTransferError as exc:
|
||||
errors.append(str(exc))
|
||||
selected = ()
|
||||
unavailable = sorted(set(selected).difference(available))
|
||||
if unavailable:
|
||||
errors.append(
|
||||
f"Selected scope(s) are absent from the package: {', '.join(unavailable)}"
|
||||
)
|
||||
|
||||
payload = package_dict.get("payload")
|
||||
payload_dict = payload if isinstance(payload, dict) else {}
|
||||
if not isinstance(payload, dict):
|
||||
errors.append("The Campaign package has no valid payload object.")
|
||||
if not isinstance(package_dict.get("manifest"), dict):
|
||||
errors.append("The Campaign package has no valid manifest.")
|
||||
for scope in available:
|
||||
if scope not in payload_dict:
|
||||
errors.append(f"The Campaign package payload is missing scope '{scope}'.")
|
||||
elif not isinstance(payload_dict[scope], dict):
|
||||
errors.append(f"The Campaign package scope '{scope}' is not a valid object.")
|
||||
|
||||
template_scope = payload_dict.get("template_config")
|
||||
if (
|
||||
"template_config" in available
|
||||
and isinstance(template_scope, dict)
|
||||
and template_scope.get("schema_version") != "1.0"
|
||||
):
|
||||
errors.append("The portable template/configuration schema version is not supported.")
|
||||
|
||||
configuration: dict[str, Any] | None = None
|
||||
portable_settings: dict[str, Any] = {}
|
||||
will_create: list[dict[str, Any]] = []
|
||||
will_skip: list[dict[str, Any]] = []
|
||||
if not errors:
|
||||
configuration, portable_settings, created, skipped, materialize_warnings = (
|
||||
_materialize_import(
|
||||
payload_dict,
|
||||
available=available,
|
||||
selected=selected,
|
||||
external_id=external_id,
|
||||
name=name,
|
||||
)
|
||||
)
|
||||
will_create.extend(created)
|
||||
will_skip.extend(skipped)
|
||||
warnings.extend(materialize_warnings)
|
||||
try:
|
||||
validate_against_schema(configuration)
|
||||
except Exception as exc:
|
||||
errors.append(f"The imported Campaign configuration is incompatible: {exc}")
|
||||
configuration = None
|
||||
|
||||
manifest = package_dict.get("manifest")
|
||||
if isinstance(manifest, dict) and manifest.get("redactions"):
|
||||
warnings.append(
|
||||
"The source export redacted sensitive or deployment-bound values; review the package manifest and reconfigure them locally."
|
||||
)
|
||||
|
||||
preview = {
|
||||
"compatible": not errors,
|
||||
"package_id": package_id,
|
||||
"package_sha256": actual_hash,
|
||||
"format_version": format_version,
|
||||
"source": source_dict,
|
||||
"available_scopes": list(available),
|
||||
"selected_scopes": list(selected),
|
||||
"destination": {
|
||||
"external_id": external_id,
|
||||
"name": name,
|
||||
"status": "draft",
|
||||
},
|
||||
"will_create": will_create,
|
||||
"will_skip": will_skip,
|
||||
"warnings": list(dict.fromkeys(warnings)),
|
||||
"errors": list(dict.fromkeys(errors)),
|
||||
}
|
||||
return CampaignImportInspection(
|
||||
preview=preview,
|
||||
configuration=configuration,
|
||||
portable_settings=portable_settings,
|
||||
)
|
||||
|
||||
|
||||
def _materialize_import(
|
||||
payload: Mapping[str, Any],
|
||||
*,
|
||||
available: tuple[str, ...],
|
||||
selected: tuple[str, ...],
|
||||
external_id: str,
|
||||
name: str,
|
||||
) -> tuple[
|
||||
dict[str, Any],
|
||||
dict[str, Any],
|
||||
list[dict[str, Any]],
|
||||
list[dict[str, Any]],
|
||||
list[str],
|
||||
]:
|
||||
selected_set = set(selected)
|
||||
configuration = minimal_campaign_json(external_id=external_id, name=name)
|
||||
portable_settings: dict[str, Any] = {}
|
||||
created: list[dict[str, Any]] = [
|
||||
_plan_item("metadata", "campaign_draft", "A new Campaign draft and editable version will be created.", 1)
|
||||
]
|
||||
skipped: list[dict[str, Any]] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
metadata = payload.get("metadata")
|
||||
if "metadata" in selected_set and isinstance(metadata, dict):
|
||||
description = metadata.get("description")
|
||||
if isinstance(description, str):
|
||||
configuration["campaign"]["description"] = description
|
||||
|
||||
template_payload = payload.get("template_config")
|
||||
if "template_config" in selected_set and isinstance(template_payload, dict):
|
||||
source_configuration = template_payload.get("configuration")
|
||||
if isinstance(source_configuration, dict):
|
||||
for key, value in source_configuration.items():
|
||||
if key in _CONFIG_STRUCTURAL_KEYS:
|
||||
continue
|
||||
configuration[key] = copy.deepcopy(value)
|
||||
source_server = configuration.get("server")
|
||||
if isinstance(source_server, dict) and source_server:
|
||||
configuration["server"] = {}
|
||||
skipped.append(
|
||||
_plan_item(
|
||||
"template_config",
|
||||
"deployment_bound_mail_profile",
|
||||
"Mail profile and server references are not applied across installations; select local Mail resources after import.",
|
||||
len(source_server),
|
||||
)
|
||||
)
|
||||
settings = template_payload.get("campaign_settings")
|
||||
if isinstance(settings, dict):
|
||||
portable_settings = copy.deepcopy(settings)
|
||||
created.append(
|
||||
_plan_item(
|
||||
"template_config",
|
||||
"editable_configuration",
|
||||
"Portable fields, template, delivery settings, and validation policy will be applied to the draft.",
|
||||
len(source_configuration),
|
||||
)
|
||||
)
|
||||
|
||||
recipients_payload = payload.get("recipients")
|
||||
if "recipients" in selected_set and isinstance(recipients_payload, dict):
|
||||
recipients = recipients_payload.get("recipients")
|
||||
entries = recipients_payload.get("entries")
|
||||
if isinstance(recipients, dict):
|
||||
configuration["recipients"] = copy.deepcopy(recipients)
|
||||
if isinstance(entries, dict):
|
||||
configuration["entries"] = copy.deepcopy(entries)
|
||||
created.append(
|
||||
_plan_item(
|
||||
"recipients",
|
||||
"recipient_rows",
|
||||
"Campaign-local recipient rows and source provenance will be copied into the draft.",
|
||||
_recipient_entry_count(entries),
|
||||
)
|
||||
)
|
||||
|
||||
attachments_payload = payload.get("attachments")
|
||||
if "attachments" in selected_set and isinstance(attachments_payload, dict):
|
||||
attachment_configuration = attachments_payload.get("configuration")
|
||||
if isinstance(attachment_configuration, dict):
|
||||
configuration["attachments"] = copy.deepcopy(attachment_configuration)
|
||||
per_entry = attachments_payload.get("entry_attachments")
|
||||
applied_entry_rules = 0
|
||||
if "recipients" in selected_set and isinstance(per_entry, list):
|
||||
inline = configuration.get("entries", {}).get("inline", [])
|
||||
if isinstance(inline, list):
|
||||
for item in per_entry:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
index = item.get("entry_index")
|
||||
rules = item.get("attachments")
|
||||
if (
|
||||
isinstance(index, int)
|
||||
and 0 <= index < len(inline)
|
||||
and isinstance(inline[index], dict)
|
||||
and isinstance(rules, list)
|
||||
):
|
||||
inline[index]["attachments"] = copy.deepcopy(rules)
|
||||
applied_entry_rules += len(rules)
|
||||
elif isinstance(per_entry, list) and per_entry:
|
||||
skipped.append(
|
||||
_plan_item(
|
||||
"attachments",
|
||||
"recipient_scope_required",
|
||||
"Per-recipient attachment rules are skipped unless recipient rows are also imported.",
|
||||
sum(
|
||||
len(item.get("attachments") or [])
|
||||
for item in per_entry
|
||||
if isinstance(item, dict)
|
||||
),
|
||||
)
|
||||
)
|
||||
created.append(
|
||||
_plan_item(
|
||||
"attachments",
|
||||
"attachment_references",
|
||||
"Portable attachment rules will be applied; file content is never embedded in the package.",
|
||||
_attachment_rule_count(attachments_payload) - max(0, _entry_rule_count(per_entry) - applied_entry_rules),
|
||||
)
|
||||
)
|
||||
warnings.append(
|
||||
"Attachment rules contain references only. Reconnect or upload the required files and validate the draft before use."
|
||||
)
|
||||
|
||||
for scope in PORTABLE_CAMPAIGN_SCOPE_ORDER:
|
||||
if scope not in OPERATIONAL_EVIDENCE_SCOPES:
|
||||
continue
|
||||
if scope in selected_set:
|
||||
item_count = _manifest_scope_count(payload.get(scope))
|
||||
skipped.append(
|
||||
_plan_item(
|
||||
scope,
|
||||
"operational_evidence_not_replayed",
|
||||
"Historical review or delivery evidence remains in the source package and import receipt but is never replayed as live Campaign state.",
|
||||
item_count,
|
||||
)
|
||||
)
|
||||
|
||||
for scope in available:
|
||||
if scope not in selected_set:
|
||||
skipped.append(
|
||||
_plan_item(
|
||||
scope,
|
||||
"scope_not_selected",
|
||||
"This available package scope was not selected for import.",
|
||||
_manifest_scope_count(payload.get(scope)),
|
||||
)
|
||||
)
|
||||
|
||||
campaign_metadata = configuration.get("campaign")
|
||||
if not isinstance(campaign_metadata, dict):
|
||||
raise CampaignTransferError("The imported Campaign metadata is invalid.")
|
||||
campaign_metadata.update({"id": external_id, "name": name, "mode": "draft"})
|
||||
return configuration, portable_settings, created, skipped, warnings
|
||||
|
||||
|
||||
def _review_state_projection(
|
||||
version: CampaignVersion, issues: tuple[CampaignIssue, ...]
|
||||
) -> dict[str, Any]:
|
||||
editor_state = version.editor_state if isinstance(version.editor_state, dict) else {}
|
||||
review = editor_state.get("review_send")
|
||||
review = review if isinstance(review, dict) else {}
|
||||
decisions = [
|
||||
item
|
||||
for item in (review.get("issue_decisions") or [])
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
decision_evidence = [
|
||||
{
|
||||
"decision": item.get("decision"),
|
||||
"issue_codes": sorted(str(code) for code in item.get("issue_codes") or []),
|
||||
"issue_fingerprint": item.get("issue_fingerprint"),
|
||||
"message_sha256": item.get("message_sha256"),
|
||||
"reason_recorded": bool(str(item.get("reason") or "").strip()),
|
||||
}
|
||||
for item in decisions
|
||||
]
|
||||
issue_counts = Counter(str(issue.severity) for issue in issues)
|
||||
return {
|
||||
"workflow_state": version.workflow_state,
|
||||
"inspection_complete": bool(review.get("inspection_complete")),
|
||||
"reviewed_message_count": len(review.get("reviewed_message_keys") or []),
|
||||
"decision_count": len(decisions),
|
||||
"decision_evidence_sha256": canonical_sha256(decision_evidence),
|
||||
"issue_counts": dict(sorted(issue_counts.items())),
|
||||
"validation_summary": public_campaign_payload(version.validation_summary or {}),
|
||||
"build_summary": public_campaign_payload(version.build_summary or {}),
|
||||
}
|
||||
|
||||
|
||||
def _delivery_job_projection(job: CampaignJob) -> dict[str, Any]:
|
||||
return {
|
||||
"job_id": job.id,
|
||||
"entry_index": job.entry_index,
|
||||
"entry_id": job.entry_id,
|
||||
"recipient_email": job.recipient_email,
|
||||
"message_id_header": job.message_id_header,
|
||||
"message_sha256": job.eml_sha256,
|
||||
"build_status": job.build_status,
|
||||
"validation_status": job.validation_status,
|
||||
"queue_status": job.queue_status,
|
||||
"send_status": job.send_status,
|
||||
"postbox_status": job.postbox_status,
|
||||
"print_status": job.print_status,
|
||||
"imap_status": job.imap_status,
|
||||
"attempt_count": job.attempt_count,
|
||||
"sent_at": _isoformat(job.sent_at),
|
||||
"outcome_unknown_at": _isoformat(job.outcome_unknown_at),
|
||||
"delivery_provenance": public_campaign_payload(job.delivery_provenance or {}),
|
||||
}
|
||||
|
||||
|
||||
def _delivery_counts(jobs: tuple[CampaignJob, ...]) -> dict[str, dict[str, int]]:
|
||||
return {
|
||||
field: dict(
|
||||
sorted(Counter(str(getattr(job, field) or "unknown") for job in jobs).items())
|
||||
)
|
||||
for field in ("validation_status", "queue_status", "send_status")
|
||||
}
|
||||
|
||||
|
||||
def _redact_sensitive_settings(
|
||||
value: Mapping[str, Any],
|
||||
) -> tuple[dict[str, Any], Counter[str]]:
|
||||
redactions: Counter[str] = Counter()
|
||||
|
||||
def visit(item: Any) -> Any:
|
||||
if isinstance(item, dict):
|
||||
result: dict[str, Any] = {}
|
||||
for raw_key, child in item.items():
|
||||
key = str(raw_key)
|
||||
normalized = key.lower().replace("-", "_")
|
||||
if any(fragment in normalized for fragment in _SENSITIVE_SETTING_FRAGMENTS):
|
||||
redactions["sensitive_setting"] += 1
|
||||
continue
|
||||
result[key] = visit(child)
|
||||
return result
|
||||
if isinstance(item, list):
|
||||
return [visit(child) for child in item]
|
||||
return copy.deepcopy(item)
|
||||
|
||||
return visit(dict(value)), redactions
|
||||
|
||||
|
||||
def _redact_password_field_values(
|
||||
configuration: dict[str, Any],
|
||||
) -> tuple[dict[str, Any], Counter[str]]:
|
||||
result = copy.deepcopy(configuration)
|
||||
password_fields = {
|
||||
str(field.get("name"))
|
||||
for field in result.get("fields") or []
|
||||
if isinstance(field, dict)
|
||||
and field.get("type") == "password"
|
||||
and field.get("name")
|
||||
}
|
||||
redactions: Counter[str] = Counter()
|
||||
if not password_fields:
|
||||
return result, redactions
|
||||
global_values = result.get("global_values")
|
||||
if isinstance(global_values, dict):
|
||||
for key in password_fields:
|
||||
if global_values.pop(key, None) is not None:
|
||||
redactions["password_field_value"] += 1
|
||||
entries = result.get("entries")
|
||||
if isinstance(entries, dict):
|
||||
for entry in entries.get("inline") or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
fields = entry.get("fields")
|
||||
if not isinstance(fields, dict):
|
||||
continue
|
||||
for key in password_fields:
|
||||
if fields.pop(key, None) is not None:
|
||||
redactions["password_field_value"] += 1
|
||||
return result, redactions
|
||||
|
||||
|
||||
def _remove_entry_attachments(entries: Any) -> None:
|
||||
if not isinstance(entries, dict):
|
||||
return
|
||||
for entry in entries.get("inline") or []:
|
||||
if isinstance(entry, dict):
|
||||
entry["attachments"] = []
|
||||
defaults = entries.get("defaults")
|
||||
if isinstance(defaults, dict):
|
||||
defaults["attachments"] = []
|
||||
|
||||
|
||||
def _entry_attachment_projection(entries: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(entries, dict):
|
||||
return []
|
||||
result: list[dict[str, Any]] = []
|
||||
for index, entry in enumerate(entries.get("inline") or []):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
rules = entry.get("attachments")
|
||||
if isinstance(rules, list) and rules:
|
||||
result.append(
|
||||
{
|
||||
"entry_index": index,
|
||||
"attachments": copy.deepcopy(rules),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _recipient_entry_count(entries: Any) -> int:
|
||||
if not isinstance(entries, dict):
|
||||
return 0
|
||||
inline = entries.get("inline")
|
||||
return len(inline) if isinstance(inline, list) else 0
|
||||
|
||||
|
||||
def _attachment_rule_count(value: Any) -> int:
|
||||
if not isinstance(value, dict):
|
||||
return 0
|
||||
configuration = value.get("configuration")
|
||||
global_rules = (
|
||||
configuration.get("global") if isinstance(configuration, dict) else []
|
||||
)
|
||||
return (len(global_rules) if isinstance(global_rules, list) else 0) + _entry_rule_count(
|
||||
value.get("entry_attachments")
|
||||
)
|
||||
|
||||
|
||||
def _entry_rule_count(value: Any) -> int:
|
||||
if not isinstance(value, list):
|
||||
return 0
|
||||
return sum(
|
||||
len(item.get("attachments") or [])
|
||||
for item in value
|
||||
if isinstance(item, dict)
|
||||
)
|
||||
|
||||
|
||||
def _manifest_scope_count(value: Any) -> int:
|
||||
if not isinstance(value, dict):
|
||||
return 0
|
||||
if isinstance(value.get("jobs"), list):
|
||||
return len(value["jobs"])
|
||||
if "decision_count" in value:
|
||||
return int(value.get("decision_count") or 0)
|
||||
if "entries" in value:
|
||||
return _recipient_entry_count(value.get("entries"))
|
||||
return 1
|
||||
|
||||
|
||||
def _plan_item(
|
||||
scope: str, code: str, summary: str, item_count: int | None
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"scope": scope,
|
||||
"code": code,
|
||||
"summary": summary,
|
||||
"item_count": item_count,
|
||||
}
|
||||
|
||||
|
||||
def _optional_text(value: object) -> str | None:
|
||||
text = str(value or "").strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _isoformat(value: datetime | None) -> str | None:
|
||||
return value.isoformat() if value is not None else None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CampaignImportInspection",
|
||||
"CampaignTransferError",
|
||||
"DEFAULT_PORTABLE_CAMPAIGN_SCOPES",
|
||||
"OPERATIONAL_EVIDENCE_SCOPES",
|
||||
"PORTABLE_CAMPAIGN_FORMAT",
|
||||
"PORTABLE_CAMPAIGN_FORMAT_VERSION",
|
||||
"PORTABLE_CAMPAIGN_SCOPE_ORDER",
|
||||
"build_campaign_portable_package",
|
||||
"canonical_sha256",
|
||||
"inspect_campaign_portable_package",
|
||||
"normalize_transfer_scopes",
|
||||
]
|
||||
@@ -27,7 +27,6 @@ from .models import (
|
||||
effective_delivery_channel_policy,
|
||||
effective_postbox_targets,
|
||||
)
|
||||
from ..attachments.resolver import resolve_campaign_attachments
|
||||
|
||||
|
||||
class Severity(StrEnum):
|
||||
@@ -877,6 +876,10 @@ def _attachment_file_check_issues(config: CampaignConfig, campaign_path: Path) -
|
||||
|
||||
|
||||
def _attachment_resolution_check_issues(config: CampaignConfig, campaign_path: Path) -> list[SemanticIssue]:
|
||||
# The resolver consumes campaign models/entries. Import it only when file
|
||||
# validation is requested so either public entry point can initialize first.
|
||||
from ..attachments.resolver import resolve_campaign_attachments
|
||||
|
||||
try:
|
||||
report = resolve_campaign_attachments(config, campaign_file=campaign_path)
|
||||
except Exception as exc:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -168,6 +168,282 @@ class CampaignShare(Base, TimestampMixin):
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
|
||||
|
||||
class CampaignCollaborationEntry(Base, TimestampMixin):
|
||||
__tablename__ = "campaign_collaboration_entries"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_campaign_collaboration_entries_thread",
|
||||
"tenant_id",
|
||||
"campaign_id",
|
||||
"created_at",
|
||||
"id",
|
||||
),
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
campaign_version_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("campaign_versions.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
reference_kind: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True)
|
||||
reference_id: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
reference_label: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
actor_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("access_users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
actor_label_snapshot: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
visibility: Mapped[str] = mapped_column(
|
||||
String(30),
|
||||
default="collaborators",
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
content: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
content_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
mention_user_ids: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||
withdrawn_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
withdrawn_by_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("access_users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
redacted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
redacted_by_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("access_users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
tombstone_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
|
||||
class CampaignWorkAssignment(Base, TimestampMixin):
|
||||
__tablename__ = "campaign_work_assignments"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"orchestration_idempotency_key",
|
||||
name="uq_campaign_work_assignment_orchestration_key",
|
||||
),
|
||||
Index(
|
||||
"ix_campaign_work_assignments_campaign_status",
|
||||
"tenant_id",
|
||||
"campaign_id",
|
||||
"status",
|
||||
"due_at",
|
||||
"id",
|
||||
),
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
campaign_version_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("campaign_versions.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
reference_kind: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True)
|
||||
reference_id: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
reference_label: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
purpose: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(30), default="open", nullable=False, index=True)
|
||||
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
assignee_type: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||
assignee_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
assignee_label_snapshot: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
assignee_current_label: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
assignee_resolution_state: Mapped[str] = mapped_column(
|
||||
String(30), default="resolved", nullable=False, index=True
|
||||
)
|
||||
resolution_provenance: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
resolution_checked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
assigned_by_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
assigned_by_label_snapshot: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
task_mirror_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
task_mirror_status: Mapped[str] = mapped_column(
|
||||
String(30), default="not_configured", nullable=False
|
||||
)
|
||||
task_mirror_error: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
task_mirrored_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
orchestration_idempotency_key: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, index=True
|
||||
)
|
||||
orchestration_request_sha256: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True
|
||||
)
|
||||
orchestration_correlation_id: Mapped[str | None] = mapped_column(
|
||||
String(128), nullable=True, index=True
|
||||
)
|
||||
workflow_instance_id: Mapped[str | None] = mapped_column(
|
||||
String(36), nullable=True, index=True
|
||||
)
|
||||
workflow_step_id: Mapped[str | None] = mapped_column(
|
||||
String(36), nullable=True, index=True
|
||||
)
|
||||
resource_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
|
||||
|
||||
class CampaignWorkAssignmentEvent(Base, TimestampMixin):
|
||||
__tablename__ = "campaign_work_assignment_events"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_campaign_work_assignment_events_history",
|
||||
"tenant_id",
|
||||
"assignment_id",
|
||||
"created_at",
|
||||
"id",
|
||||
),
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
assignment_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("campaign_work_assignments.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
event_kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||
actor_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
actor_label_snapshot: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
status_snapshot: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
assignee_type_snapshot: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
assignee_id_snapshot: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
assignee_label_snapshot: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
resolution_state_snapshot: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
details: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
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)
|
||||
delivery_mode: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default="manual",
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
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)
|
||||
approved_execution_snapshot_hash: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True, 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)
|
||||
last_outcome: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
last_recovery_state: Mapped[str | None] = mapped_column(
|
||||
String(30), 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)
|
||||
idempotency_key: Mapped[str | None] = mapped_column(
|
||||
String(200), nullable=True, 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)
|
||||
delivery_command_ids: Mapped[list[str]] = mapped_column(
|
||||
JSON, default=list, nullable=False
|
||||
)
|
||||
recovery_state: Mapped[str] = mapped_column(
|
||||
String(30), default="none", nullable=False, index=True
|
||||
)
|
||||
evidence: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
last_checked_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
|
||||
class RecipientImportMappingProfile(Base, TimestampMixin):
|
||||
__tablename__ = "campaign_recipient_import_mapping_profiles"
|
||||
__table_args__ = (
|
||||
@@ -264,6 +540,12 @@ class CampaignVersion(Base, TimestampMixin):
|
||||
"version_id_col": edit_revision,
|
||||
}
|
||||
|
||||
@property
|
||||
def review_build_token(self) -> str | None:
|
||||
from govoplan_campaign.backend.campaign.mail_profile_boundary import campaign_review_reference
|
||||
summary = self.build_summary if isinstance(self.build_summary, dict) else {}
|
||||
return campaign_review_reference(self.id, summary.get("build_token") or summary.get("built_at"))
|
||||
|
||||
@property
|
||||
def strong_etag(self) -> str:
|
||||
return strong_resource_etag(
|
||||
@@ -690,6 +972,8 @@ __all__ = [
|
||||
"CampaignVersion",
|
||||
"CampaignVersionFlow",
|
||||
"CampaignVersionWorkflowState",
|
||||
"CampaignWorkAssignment",
|
||||
"CampaignWorkAssignmentEvent",
|
||||
"ImapAppendAttempt",
|
||||
"IssueSeverity",
|
||||
"JobBuildStatus",
|
||||
|
||||
@@ -6,6 +6,8 @@ from typing import Any, Mapping
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.admin.models import SystemSettings
|
||||
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID
|
||||
from govoplan_core.tenancy.scope import Tenant
|
||||
|
||||
|
||||
@@ -26,6 +28,8 @@ class SynchronousSendPolicy:
|
||||
source: str
|
||||
deployment_max_recipient_jobs: int
|
||||
tenant_max_recipient_jobs: int | None = None
|
||||
system_max_recipient_jobs: int | None = None
|
||||
deployment_ceiling_explicit: bool = False
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
@@ -33,6 +37,9 @@ class SynchronousSendPolicy:
|
||||
"source": self.source,
|
||||
"deployment_max_recipient_jobs": self.deployment_max_recipient_jobs,
|
||||
"tenant_max_recipient_jobs": self.tenant_max_recipient_jobs,
|
||||
"system_max_recipient_jobs": self.system_max_recipient_jobs,
|
||||
"deployment_ceiling_explicit": self.deployment_ceiling_explicit,
|
||||
"system_setting": f"system.settings.{CAMPAIGN_DELIVERY_POLICY_SETTINGS_KEY}.{SYNCHRONOUS_SEND_MAX_SETTINGS_KEY}",
|
||||
"deployment_setting": SYNCHRONOUS_SEND_MAX_ENV,
|
||||
"tenant_setting": (
|
||||
f"tenant.settings.{CAMPAIGN_DELIVERY_POLICY_SETTINGS_KEY}."
|
||||
@@ -46,20 +53,36 @@ def effective_synchronous_send_policy(
|
||||
*,
|
||||
tenant_id: str,
|
||||
environ: Mapping[str, str] | None = None,
|
||||
apply_tenant_override: bool = True,
|
||||
) -> SynchronousSendPolicy:
|
||||
env = os.environ if environ is None else environ
|
||||
deployment_raw = env.get(SYNCHRONOUS_SEND_MAX_ENV)
|
||||
deployment_explicit = deployment_raw is not None and (not isinstance(deployment_raw, str) or bool(deployment_raw.strip()))
|
||||
deployment_value = _configured_limit(
|
||||
env.get(SYNCHRONOUS_SEND_MAX_ENV),
|
||||
source=SYNCHRONOUS_SEND_MAX_ENV,
|
||||
default=DEFAULT_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS,
|
||||
default=ABSOLUTE_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS,
|
||||
)
|
||||
tenant = session.get(Tenant, tenant_id)
|
||||
system = session.get(SystemSettings, SYSTEM_SETTINGS_ID)
|
||||
system_raw = _tenant_limit_value(system.settings if system is not None else None)
|
||||
system_value = _configured_limit(system_raw, source="system campaign delivery policy") if system_raw is not None else None
|
||||
# Preserve explicit deployment configuration, but an implicit default is not
|
||||
# an administrator ceiling. No override still retains the conservative 25.
|
||||
inherited = min(deployment_value, system_value) if system_value is not None else (
|
||||
deployment_value if deployment_explicit else DEFAULT_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS
|
||||
)
|
||||
inherited_source = ("system" if system_value <= deployment_value else "deployment_ceiling") if system_value is not None else (
|
||||
"deployment" if deployment_explicit else "deployment_default"
|
||||
)
|
||||
tenant = session.get(Tenant, tenant_id) if apply_tenant_override else None
|
||||
tenant_raw = _tenant_limit_value(tenant.settings if tenant is not None else None)
|
||||
if tenant_raw is None:
|
||||
return SynchronousSendPolicy(
|
||||
max_recipient_jobs=deployment_value,
|
||||
source=("deployment" if env.get(SYNCHRONOUS_SEND_MAX_ENV) not in (None, "") else "deployment_default"),
|
||||
max_recipient_jobs=inherited,
|
||||
source=inherited_source,
|
||||
deployment_max_recipient_jobs=deployment_value,
|
||||
system_max_recipient_jobs=system_value,
|
||||
deployment_ceiling_explicit=deployment_explicit,
|
||||
)
|
||||
|
||||
tenant_value = _configured_limit(
|
||||
@@ -69,12 +92,14 @@ def effective_synchronous_send_policy(
|
||||
f"{SYNCHRONOUS_SEND_MAX_SETTINGS_KEY}"
|
||||
),
|
||||
)
|
||||
effective_value = min(deployment_value, tenant_value)
|
||||
effective_value = min(inherited, tenant_value)
|
||||
return SynchronousSendPolicy(
|
||||
max_recipient_jobs=effective_value,
|
||||
source="tenant" if tenant_value <= deployment_value else "deployment_ceiling",
|
||||
source="tenant" if tenant_value <= inherited else ("system_ceiling" if inherited_source == "system" else "deployment_ceiling"),
|
||||
deployment_max_recipient_jobs=deployment_value,
|
||||
tenant_max_recipient_jobs=tenant_value,
|
||||
system_max_recipient_jobs=system_value,
|
||||
deployment_ceiling_explicit=deployment_explicit,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -3,16 +3,19 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from email import policy
|
||||
from email.message import EmailMessage
|
||||
from email.parser import BytesParser
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.db.models import Campaign, CampaignVersion
|
||||
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion
|
||||
from govoplan_campaign.backend.campaign.loader import load_campaign_json
|
||||
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
|
||||
from govoplan_campaign.backend.campaign.models import DeliveryChannelPolicy
|
||||
from govoplan_campaign.backend.campaign.validation import SemanticReport, validate_campaign_config
|
||||
from govoplan_campaign.backend.persistence.campaigns import load_campaign_config_from_json
|
||||
from govoplan_campaign.backend.messages.builder import build_campaign_messages
|
||||
from govoplan_campaign.backend.messages.models import MessageAddress, MessageDraft, MessageValidationStatus
|
||||
from govoplan_campaign.backend.messages.builder import BuiltMessage, CampaignBuildResult, build_campaign_messages
|
||||
from govoplan_campaign.backend.messages.models import CampaignBuildReport, MessageAddress, MessageAttachmentSummary, MessageDraft, MessageValidationStatus
|
||||
from govoplan_campaign.backend.integrations import files_integration, mail_integration
|
||||
from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths
|
||||
|
||||
@@ -151,6 +154,7 @@ def _mock_send_batch(
|
||||
include_warnings: bool,
|
||||
include_needs_review: bool,
|
||||
append_sent: bool,
|
||||
reviewed_keys: set[str] | None = None,
|
||||
) -> _MockSendBatch:
|
||||
batch = _MockSendBatch(results=[])
|
||||
for built in built_messages:
|
||||
@@ -160,7 +164,7 @@ def _mock_send_batch(
|
||||
mailbox=mailbox,
|
||||
send=send,
|
||||
include_warnings=include_warnings,
|
||||
include_needs_review=include_needs_review,
|
||||
include_needs_review=include_needs_review or str(built.draft.entry_id or built.draft.entry_index) in (reviewed_keys or set()),
|
||||
append_sent=append_sent,
|
||||
)
|
||||
batch.results.append(outcome.row)
|
||||
@@ -391,6 +395,96 @@ def _build_mock_campaign_run(
|
||||
return validation_report, build_result, send_batch
|
||||
|
||||
|
||||
def _build_reviewed_mock_run(
|
||||
session: Session, *, tenant_id: str, campaign: Campaign, version: CampaignVersion,
|
||||
mailbox: Any | None, send: bool, include_warnings: bool, append_sent: bool,
|
||||
clear_mailbox: bool = False,
|
||||
) -> tuple[Any, Any, _MockSendBatch]:
|
||||
"""Mock the sealed EML, never approve a new transient rendering by entry ID."""
|
||||
from govoplan_campaign.backend.persistence.versions import _complete_campaign_review
|
||||
from govoplan_campaign.backend.sending.execution import ensure_execution_snapshot, profile_delivery_summary
|
||||
from govoplan_campaign.backend.sending.jobs import _load_eml_bytes_for_job
|
||||
|
||||
if not isinstance(version.execution_snapshot, dict) or not version.execution_snapshot_hash:
|
||||
raise MockCampaignSendError("Build the campaign with frozen execution evidence before testing reviewed messages.")
|
||||
# Do not invoke the legacy snapshot-creation fallback: this test must not
|
||||
# alter Campaign state or create approval evidence as a side effect.
|
||||
snapshot = ensure_execution_snapshot(session, version)
|
||||
build = version.build_summary if isinstance(version.build_summary, dict) else {}
|
||||
token = str(build.get("build_token") or build.get("built_at") or "")
|
||||
if not token or token != str(snapshot.build_token or snapshot.built_at or ""):
|
||||
raise MockCampaignSendError("The frozen execution no longer matches the current message build. Rebuild and review again.")
|
||||
jobs = session.query(CampaignJob).filter(
|
||||
CampaignJob.tenant_id == tenant_id, CampaignJob.campaign_version_id == version.id,
|
||||
).order_by(CampaignJob.entry_index.asc()).all()
|
||||
if not jobs:
|
||||
raise MockCampaignSendError("The reviewed build contains no messages.")
|
||||
state = (version.editor_state or {}).get("review_send", {})
|
||||
complete = isinstance(state, dict) and state.get("inspection_complete") is True and state.get("build_token") == token
|
||||
if any(job.validation_status in {"needs_review", "warning"} for job in jobs) and not complete:
|
||||
raise MockCampaignSendError("Complete review for the exact current build before testing accepted exceptions.")
|
||||
reviewed_keys = list(state.get("reviewed_message_keys", [])) if complete else []
|
||||
decisions = list(state.get("issue_decisions", [])) if complete else []
|
||||
_, normalized = _complete_campaign_review(session, version, reviewed_keys, decisions, user_id=None, build_token=token)
|
||||
by_id = {item.get("job_id"): item for item in decisions}
|
||||
for item in normalized:
|
||||
prior = by_id.get(item["job_id"], {})
|
||||
if any(prior.get(key) != item.get(key) for key in ("build_token", "message_sha256", "issue_fingerprint")):
|
||||
raise MockCampaignSendError("Review evidence no longer matches the frozen message issues. Rebuild and review again.")
|
||||
if snapshot.uses_mail:
|
||||
current = profile_delivery_summary(session, version)
|
||||
if current.get("smtp_transport_revision") != snapshot.smtp_transport_revision or (
|
||||
append_sent and current.get("imap_transport_revision") != snapshot.imap_transport_revision
|
||||
):
|
||||
raise MockCampaignSendError("The selected Mail transport changed after build. Rebuild and review before testing these messages.")
|
||||
|
||||
built_messages = []
|
||||
for job in jobs:
|
||||
recipients = job.resolved_recipients or {}
|
||||
attachments = [MessageAttachmentSummary.model_validate({
|
||||
"status": "missing", "required": False, "allow_multiple": False, "zip_enabled": False,
|
||||
"file_filter": "", "directory": "",
|
||||
**{key: value for key, value in item.items() if key in MessageAttachmentSummary.model_fields},
|
||||
}) for item in (job.resolved_attachments or []) if isinstance(item, dict)]
|
||||
inactive = job.validation_status == "inactive"
|
||||
excluded = job.validation_status == "excluded" or inactive
|
||||
draft = MessageDraft(
|
||||
entry_index=job.entry_index, entry_id=job.entry_id, active=not inactive,
|
||||
build_status="built" if job.build_status == "built" else "build_failed",
|
||||
validation_status=job.validation_status, send_status="skipped" if excluded else "draft",
|
||||
imap_status="skipped" if excluded else "not_requested", subject=job.subject,
|
||||
delivery_channel_policy=job.delivery_channel_policy,
|
||||
**{key: recipients.get(key) for key in ("from",) if recipients.get(key)},
|
||||
**{key: recipients.get(key) or [] for key in ("from_all", "to", "cc", "bcc", "reply_to", "bounce_to", "disposition_notification_to")},
|
||||
issues=job.issues_snapshot or [], attachments=attachments,
|
||||
attachment_count=sum(len(item.managed_matches or item.matches) for item in attachments),
|
||||
eml_size_bytes=job.eml_size_bytes,
|
||||
)
|
||||
mime = None
|
||||
if not excluded and DeliveryChannelPolicy(job.delivery_channel_policy).uses_mail:
|
||||
if not job.eml_sha256:
|
||||
raise MockCampaignSendError("Frozen EML checksum is missing; rebuild before testing reviewed messages.")
|
||||
# The shared reader checks byte length, digest and Message-ID before
|
||||
# any mock capture occurs. All messages preflight before the batch.
|
||||
try:
|
||||
mime = BytesParser(policy=policy.default).parsebytes(_load_eml_bytes_for_job(job))
|
||||
except Exception as exc:
|
||||
raise MockCampaignSendError("Frozen message bytes are unavailable or no longer match their integrity evidence. Rebuild and review before testing.") from exc
|
||||
built_messages.append(BuiltMessage(draft=draft, mime=mime))
|
||||
report = CampaignBuildReport(campaign_id=campaign.external_id, campaign_name=campaign.name,
|
||||
campaign_file="", entries_count=len(jobs), messages=[item.draft for item in built_messages])
|
||||
validation = SemanticReport(campaign_id=campaign.external_id, campaign_name=campaign.name,
|
||||
entries_mode="frozen_build", entries_count=len(jobs), attachments_base_path="", rate_limit="frozen",
|
||||
imap_append_enabled=snapshot.delivery.imap_append_sent.enabled)
|
||||
config = SimpleNamespace(delivery=snapshot.delivery, server=SimpleNamespace(imap=None))
|
||||
if clear_mailbox and mailbox is not None:
|
||||
mailbox.clear_records()
|
||||
batch = _mock_send_batch(config=config, built_messages=built_messages, mailbox=mailbox, send=send,
|
||||
include_warnings=include_warnings, include_needs_review=False, append_sent=append_sent,
|
||||
reviewed_keys=set(reviewed_keys))
|
||||
return validation, CampaignBuildResult(report=report, built_messages=built_messages), batch
|
||||
|
||||
|
||||
def _mock_validation_payload(validation_report: Any) -> dict[str, Any]:
|
||||
payload = validation_report.model_dump(mode="json")
|
||||
payload.update(
|
||||
@@ -542,6 +636,7 @@ def run_mock_campaign_send(
|
||||
append_sent: bool = True,
|
||||
clear_mailbox: bool = False,
|
||||
check_files: bool = False,
|
||||
use_reviewed_build: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate, build and optionally mock-send a version without mutating it.
|
||||
|
||||
@@ -557,22 +652,29 @@ def run_mock_campaign_send(
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
)
|
||||
mailbox = _mock_mailbox_for_run(send=send, clear_mailbox=clear_mailbox)
|
||||
validation_report, build_result, send_batch = _build_mock_campaign_run(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign=campaign,
|
||||
version=version,
|
||||
mailbox=mailbox,
|
||||
send=send,
|
||||
include_warnings=include_warnings,
|
||||
include_needs_review=include_needs_review,
|
||||
append_sent=append_sent,
|
||||
check_files=check_files,
|
||||
)
|
||||
mailbox = _mock_mailbox_for_run(send=send, clear_mailbox=clear_mailbox and not use_reviewed_build)
|
||||
if use_reviewed_build:
|
||||
validation_report, build_result, send_batch = _build_reviewed_mock_run(
|
||||
session, tenant_id=tenant_id, campaign=campaign, version=version,
|
||||
mailbox=mailbox, send=send, include_warnings=include_warnings, append_sent=append_sent,
|
||||
clear_mailbox=clear_mailbox,
|
||||
)
|
||||
else:
|
||||
validation_report, build_result, send_batch = _build_mock_campaign_run(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign=campaign,
|
||||
version=version,
|
||||
mailbox=mailbox,
|
||||
send=send,
|
||||
include_warnings=include_warnings,
|
||||
include_needs_review=include_needs_review,
|
||||
append_sent=append_sent,
|
||||
check_files=check_files,
|
||||
)
|
||||
validation_payload = _mock_validation_payload(validation_report)
|
||||
build_payload = _mock_build_payload(build_result)
|
||||
return _mock_campaign_send_response(
|
||||
result = _mock_campaign_send_response(
|
||||
campaign=campaign,
|
||||
version=version,
|
||||
mailbox=mailbox,
|
||||
@@ -586,3 +688,9 @@ def run_mock_campaign_send(
|
||||
include_needs_review=include_needs_review,
|
||||
append_sent=append_sent,
|
||||
)
|
||||
result["use_reviewed_build"] = use_reviewed_build
|
||||
if use_reviewed_build:
|
||||
result["steps"][0].update(label="Verify frozen execution inputs", status="ok")
|
||||
result["steps"][1].update(label="Use reviewed frozen messages", status="ok")
|
||||
result["build"]["review_satisfied"] = True
|
||||
return result
|
||||
|
||||
@@ -6,6 +6,9 @@ from govoplan_campaign.backend.delivery_policy import (
|
||||
CampaignDeliveryPolicyError,
|
||||
effective_synchronous_send_policy,
|
||||
)
|
||||
from govoplan_campaign.backend.german_documentation import (
|
||||
localize_documentation_topics,
|
||||
)
|
||||
|
||||
|
||||
_CAMPAIGN_USER_SCOPES = (
|
||||
@@ -13,6 +16,8 @@ _CAMPAIGN_USER_SCOPES = (
|
||||
"campaigns:campaign:create",
|
||||
"campaigns:campaign:update",
|
||||
"campaigns:campaign:copy",
|
||||
"campaigns:campaign:export",
|
||||
"campaigns:campaign:import",
|
||||
"campaigns:campaign:archive",
|
||||
"campaigns:campaign:delete",
|
||||
"campaigns:campaign:share",
|
||||
@@ -25,6 +30,12 @@ _CAMPAIGN_USER_SCOPES = (
|
||||
"campaigns:campaign:send",
|
||||
"campaigns:campaign:retry",
|
||||
"campaigns:campaign:reconcile",
|
||||
"campaigns:discussion:read",
|
||||
"campaigns:discussion:post",
|
||||
"campaigns:discussion:moderate",
|
||||
"campaigns:assignment:read",
|
||||
"campaigns:assignment:manage",
|
||||
"campaigns:assignment:complete",
|
||||
"campaigns:recipient:read",
|
||||
"campaigns:recipient:write",
|
||||
"campaigns:recipient:import",
|
||||
@@ -46,9 +57,13 @@ _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"
|
||||
_TASKS_INTEGRATION = "tasks.commands"
|
||||
_ORGANIZATIONS_INTEGRATION = "organizations.directory"
|
||||
_IDM_FUNCTION_ASSIGNMENTS_INTEGRATION = "idm.function_assignments"
|
||||
|
||||
|
||||
def _workflow_topic(
|
||||
@@ -73,6 +88,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,17 +122,18 @@ def _workflow_topic(
|
||||
links=links,
|
||||
related_modules=related_modules,
|
||||
unlocks=(outcome,),
|
||||
translations=translations or {},
|
||||
source_module_id="campaigns",
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
CAMPAIGN_USER_DOCUMENTATION = (
|
||||
CAMPAIGN_USER_DOCUMENTATION = localize_documentation_topics((
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.create-campaign",
|
||||
title="Create a campaign",
|
||||
summary="Start a governed campaign as an editable draft and complete its purpose and ownership before adding delivery data.",
|
||||
body="A new campaign starts with one editable working version. Creating it does not grant access to Mail profiles, managed files, address sources, or delivery actions; those remain separately authorized.",
|
||||
body="A new campaign starts with one editable working version. Campaign editors report saved, unsaved, and saving state in the page action bar; Discard remains immediately before Save, and leaving a dirty draft invokes the shared save-or-discard guard. Destructive campaign lifecycle actions are visually separated from ordinary actions. Creating a campaign does not grant access to Mail profiles, managed files, address sources, or delivery actions; those remain separately authorized. Saved changes are acknowledged separately from the follow-up refresh. A failed write or cancelled conflict keeps the draft; repeated Save clicks share one pending operation. Typing during a save keeps newer work unsaved instead of overwriting it with the older acknowledgement. A failed or superseded Discard refresh retains the draft. Unchanged ZIP configuration does not disable saving unrelated attachment source or rule edits. Shared attachment Add actions remain compact. If the Files chooser is temporarily unavailable, the editor explains this instead of silently treating a managed path as manual text; missing rule sources must be selected again. Keyboard Enter or Space opens chooser-backed path fields.",
|
||||
order=30,
|
||||
audience=("campaign_manager", "campaign_author"),
|
||||
required_scopes=("campaigns:campaign:read", "campaigns:campaign:create"),
|
||||
@@ -128,7 +145,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
||||
"Open Campaigns and select New campaign.",
|
||||
"Use the creation wizard to enter a clear name, identifier, and purpose.",
|
||||
"Open the new campaign and confirm its owner before adding recipient or delivery data.",
|
||||
"Continue through the preparation sections and save the editable working version.",
|
||||
"Continue through the preparation sections; use the stable Discard and Save actions while the bar reports the draft state.",
|
||||
),
|
||||
outcome="An owned campaign draft with an editable working version.",
|
||||
verification="The Campaign overview shows the new campaign as a draft and identifies its current working version.",
|
||||
@@ -163,26 +180,236 @@ 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.transfer-campaign-package",
|
||||
title="Export and import a portable Campaign package",
|
||||
summary="Move selected Campaign configuration into a separately owned draft with an integrity check, compatibility preview, and explicit privacy scopes.",
|
||||
body="Portable Campaign packages are versioned JSON envelopes. Export defaults to metadata plus template/configuration and excludes recipients, attachments, review state, and delivery history until they are explicitly selected. Recipient and delivery scopes require their existing fine-grained export permissions. Transport secrets, credential references, password-field values, local storage paths, and attachment bytes are not exported. Import verifies the SHA-256 package integrity, previews every scope that will be created or skipped, and always creates a new editable draft. Deployment-bound Mail references must be selected locally. Review, approval, and delivery evidence remains historical package provenance and is never replayed as live state.",
|
||||
order=33,
|
||||
audience=("campaign_manager", "campaign_configurator", "campaign_migration_operator"),
|
||||
required_scopes=("campaigns:campaign:read", "campaigns:campaign:export"),
|
||||
route="/campaigns/{campaign_id}",
|
||||
screen="Campaign overview and Campaign list",
|
||||
help_contexts=("campaign.overview", "campaigns.action.export-package", "campaigns.action.import-package"),
|
||||
prerequisites=(
|
||||
"You may export the source Campaign; importing additionally requires Campaign create and portable-import authority.",
|
||||
"Recipient and delivery scopes have an approved purpose and destination and the corresponding recipient/report export permissions.",
|
||||
),
|
||||
steps=(
|
||||
"Open the source Campaign overview, select Export package, and keep the privacy-safe metadata plus template/configuration default unless more data is necessary.",
|
||||
"Select any additional recipient, attachment, review, or delivery scopes explicitly and download the integrity-protected JSON package to an approved location.",
|
||||
"On the destination Campaign list select Import package, choose the file, and review compatibility, redactions, destination identity, created scopes, and skipped evidence.",
|
||||
"Change the destination identity or selected scopes as needed, refresh the preview, and create the draft only when the preview is current and compatible.",
|
||||
"Open the draft, reconnect local Mail and file resources, validate recipients and attachments, and complete ordinary review before any delivery.",
|
||||
),
|
||||
outcome="A separately owned Campaign draft containing only the selected portable configuration, with source/package provenance and no replayed operational state.",
|
||||
verification="The destination is a new draft with a distinct ID; its settings retain the package ID, SHA-256, source and created/skipped receipt, while Audit records the matching export/import hashes without storing package content.",
|
||||
related_topic_ids=("campaigns.workflow.copy-campaign", "campaigns.workflow.prepare-validate-and-build", "campaigns.workflow.export-delivery-report"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Portables Campaign-Paket exportieren und importieren",
|
||||
"summary": "Ausgewaehlte Campaign-Konfiguration mit Integritaetspruefung, Kompatibilitaetsvorschau und expliziten Datenschutzumfaengen in einen eigenstaendigen Entwurf uebernehmen.",
|
||||
"body": "Portable Campaign-Pakete sind versionierte JSON-Umschlaege. Der Export umfasst standardmaessig nur Metadaten sowie Vorlage und Konfiguration. Empfaenger, Anlagen, Pruefstatus und Zustellhistorie werden erst nach expliziter Auswahl aufgenommen und bleiben getrennt berechtigt. Transportgeheimnisse, Zugangsdatenverweise, Passwortfeldwerte, lokale Speicherpfade und Dateiinhalte werden nicht exportiert. Der Import prueft die SHA-256-Integritaet, zeigt alle erzeugten und uebersprungenen Umfaenge und erstellt immer einen neuen bearbeitbaren Entwurf. Mail-Verweise muessen lokal neu gewaehlt werden; historische Pruef-, Freigabe- und Zustellnachweise werden nie als aktiver Zustand wiedergegeben.",
|
||||
}
|
||||
},
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.collaborate-on-campaign",
|
||||
title="Discuss campaign work without changing its evidence",
|
||||
summary="Use the governed collaboration thread for human discussion linked to stable Campaign evidence.",
|
||||
body="Collaboration is governed independently from Campaign editing. A discussion reader still needs access to the parent Campaign; posting and moderation use separate permissions. Posted text is append-only. Authors can withdraw their own entry and moderators can redact an entry, but both actions leave the actor, timestamp, reference, evidence hash, tombstone, and audit record. A comment may reference a Campaign version, recipient import batch, attachment rule, delivery job, or report. The reference never edits the historical version. Mentions create an in-app notification only when Notifications is available and only for active users who already have Campaign access. Human discussion is not system state and never replaces Audit evidence.",
|
||||
order=33,
|
||||
audience=("campaign_manager", "campaign_reviewer", "campaign_sender"),
|
||||
required_scopes=("campaigns:campaign:read", "campaigns:discussion:read"),
|
||||
route="/campaigns/{campaign_id}/activity",
|
||||
screen="Campaign collaboration",
|
||||
help_contexts=(
|
||||
"campaign.activity",
|
||||
"campaign.activity.composer",
|
||||
"campaign.activity.action.post",
|
||||
"campaign.activity.action.withdraw",
|
||||
"campaign.activity.action.redact",
|
||||
),
|
||||
prerequisites=(
|
||||
"You can read the Campaign and its discussion.",
|
||||
"Posting requires the separate campaign discussion-post permission.",
|
||||
),
|
||||
steps=(
|
||||
"Open Collaboration in the selected Campaign workspace.",
|
||||
"Optionally select a stable version or enter the stable ID of another supported Campaign evidence reference.",
|
||||
"Mention only collaborators who already have access, then post the bounded comment.",
|
||||
"Withdraw your own mistaken entry or ask an authorized moderator to redact content that must no longer be displayed.",
|
||||
"Use Tenant audit for system events and durable action evidence; do not treat discussion as workflow state.",
|
||||
),
|
||||
outcome="An attributable human discussion entry that does not mutate Campaign versions or impersonate audit evidence.",
|
||||
verification="Reload Collaboration, follow the typed reference, and confirm any withdrawal or redaction appears as a tombstone while the referenced version remains unchanged.",
|
||||
required_capabilities=(),
|
||||
related_modules=("notifications", "audit"),
|
||||
limitations=(
|
||||
"Notifications is optional; the discussion remains available when mention delivery is not configured.",
|
||||
"Comments do not approve, validate, build, queue, send, or otherwise transition a Campaign.",
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Kampagnenarbeit besprechen, ohne Nachweise zu verändern",
|
||||
"summary": "Den geregelten Diskussionsverlauf für menschliche Abstimmung mit stabilen Kampagnennachweisen verwenden.",
|
||||
"body": "Die Zusammenarbeit wird unabhängig von der Kampagnenbearbeitung berechtigt. Lesende benötigen weiterhin Zugriff auf die übergeordnete Kampagne; Veröffentlichung und Moderation verwenden eigene Berechtigungen. Veröffentlichter Text ist unveränderlich. Verfassende können eigene Einträge zurücknehmen, Moderierende können Einträge schwärzen. Dabei bleiben Person, Zeitstempel, Referenz, Nachweis-Hash, Platzhalter und Auditnachweis erhalten. Kommentare können Kampagnenversionen, Empfänger-Importläufe, Anlagenregeln, Sendeaufträge oder Berichte referenzieren, ohne historische Versionen zu verändern. Erwähnungen erzeugen nur bei verfügbarem Benachrichtigungsmodul eine interne Benachrichtigung und nur für aktive Personen mit bestehendem Kampagnenzugriff. Diskussion ist kein Systemzustand und ersetzt keinen Auditnachweis.",
|
||||
}
|
||||
},
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.assign-accountable-work",
|
||||
title="Assign accountable Campaign work without granting access",
|
||||
summary="Record bounded work for an account, group, or organization function while keeping authorization and Campaign ownership separate.",
|
||||
body="Campaign work assignments record responsibility, not authority. Every reader and actor must still pass the parent Campaign access check, and a new account, group, or organization-function target is accepted only when it already resolves to active principals with Campaign access. Each assignment retains its purpose, optional due date, assigner, typed assignee reference, human-readable snapshot, current resolution state, stable Campaign or child reference, optimistic revision, and append-only transition history. Assignees with the separate completion permission can accept, complete, or reject their own work; rejection is distinct from manager cancellation. Managers can also reassign or cancel it. Workflow-opened work additionally retains the correlation, idempotency, Workflow instance and step, exact Campaign version, and emits a common revision-bearing lifecycle event for assignment, acceptance, start, reassignment, completion, rejection, or cancellation. Workflow rechecks Campaign access before it resumes; the assignment itself never grants access. Reconciliation records vacancy, deactivation, or restored resolution without deleting history or transferring ownership. Notifications and Tasks mirroring are optional and cannot make the Campaign transaction fail.",
|
||||
order=34,
|
||||
audience=("campaign_manager", "campaign_reviewer", "campaign_sender"),
|
||||
required_scopes=("campaigns:campaign:read", "campaigns:assignment:read"),
|
||||
route="/campaigns/{campaign_id}/work",
|
||||
screen="Campaign work",
|
||||
help_contexts=(
|
||||
"campaign.work",
|
||||
"campaign.work.create",
|
||||
"campaign.work.action.start",
|
||||
"campaign.work.action.complete",
|
||||
"campaign.work.action.reject",
|
||||
"campaign.work.action.reassign",
|
||||
"campaign.work.action.cancel",
|
||||
"campaign.work.history",
|
||||
),
|
||||
prerequisites=(
|
||||
"You can read the Campaign and its work assignments.",
|
||||
"Creating, reassigning, cancelling, or reconciling requires the assignment-manage permission.",
|
||||
"The target account, group, or all current function incumbents already have Campaign access.",
|
||||
),
|
||||
steps=(
|
||||
"Open Work in the selected Campaign workspace and choose Add assignment.",
|
||||
"Enter a bounded purpose, optional due date, typed target, and optional stable Campaign evidence reference.",
|
||||
"Resolve any authorization-neutral rejection by granting access through the separate Campaign sharing workflow or choosing another assignee; creating the assignment itself never grants access.",
|
||||
"Accept and complete your own assignment, reject it explicitly when it cannot be taken on, or use manager actions to reassign or cancel open work.",
|
||||
"Reload and reconcile assignments after account, group, organization-function, or incumbency changes; inspect the retained history before acting on unavailable work.",
|
||||
),
|
||||
outcome="A durable accountability record whose lifecycle is independent from Campaign ownership, authorization, and delivery state.",
|
||||
verification="Reload Work, inspect the typed target, resolution provenance, revision and history, and confirm Campaign shares and ownership did not change. For Workflow-opened work, follow the focused assignment link and verify the exact terminal event and revision resume only the pinned Workflow step. When Tasks is installed, confirm the optional mirror links back to this Campaign assignment.",
|
||||
related_modules=("access", "organizations", "idm", "tasks", "notifications", "audit", "policy"),
|
||||
limitations=(
|
||||
"Organizations and IDM are optional; organization-function assignment is unavailable until both directory and incumbency capabilities are active.",
|
||||
"Tasks mirroring is a convenience projection. Campaign remains the authoritative assignment and history owner.",
|
||||
"Ownership transfer continues to use its separate two-party governance protocol.",
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Verantwortliche Kampagnenarbeit zuweisen, ohne Zugriff zu vergeben",
|
||||
"summary": "Begrenzte Arbeit für Konto, Gruppe oder Organisationsfunktion erfassen und Berechtigung sowie Kampagneneigentum getrennt halten.",
|
||||
"body": "Kampagnenzuweisungen dokumentieren Verantwortung, nicht Berechtigung. Lesende und Handelnde müssen weiterhin den Zugriff auf die übergeordnete Kampagne nachweisen. Neue Ziele werden nur angenommen, wenn Konto, Gruppe oder alle aktuellen Funktionsinhabenden bereits Kampagnenzugriff besitzen. Zweck, optionale Fälligkeit, zuweisende Person, typisierte Referenz, lesbarer Schnappschuss, aktueller Auflösungszustand, Revision und unveränderliche Übergangshistorie bleiben erhalten. Zugewiesene Personen können Arbeit annehmen, abschließen oder ausdrücklich ablehnen; Ablehnung bleibt von einer administrativen Stornierung getrennt. Durch Workflow eröffnete Arbeit bewahrt Korrelation, Idempotenz, Workflow-Instanz und -Schritt sowie die genaue Kampagnenversion und erzeugt revisionsgebundene Lebenszyklusereignisse. Workflow prüft den Kampagnenzugriff vor der Fortsetzung erneut. Deaktivierung oder Vakanz wird beim Abgleich als nicht verfügbar dokumentiert. Optionale Benachrichtigungen und Tasks-Spiegelungen dürfen die Kampagnentransaktion nicht blockieren.",
|
||||
}
|
||||
},
|
||||
),
|
||||
_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 only after explicit confirmation. Required fields are compared with the current Campaign fields and shown before content is applied. Saving from Campaign creates a personal or tenant Templates draft, retains placeholder requirements as its data contract, 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.",
|
||||
"Review any missing or incompatible required fields, then insert a fragment into its declared subject, text, or HTML target, or explicitly confirm replacement by 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 erst nach ausdrücklicher Bestätigung. Pflichtfelder werden vor dem Anwenden mit den aktuellen Kampagnenfeldern verglichen und angezeigt. Das Speichern aus Campaign legt einen persönlichen oder mandantenweiten Templates-Entwurf an, bewahrt Platzhalteranforderungen als Datenvertrag 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 manual or autonomous campaigns",
|
||||
summary="Prepare fresh drafts or explicitly opt in to governed delivery of an exact approved build.",
|
||||
body="Every schedule is bounded, timezone-aware, and fixed to either manual or autonomous mode. Manual mode stores an integrity-sealed configuration snapshot and prepares a separately owned draft per due occurrence without requiring Mail. Autonomous mode never rebuilds or silently changes approved content: it requires a built Mail-only source version with an explicit valid Approval request, seals its execution-snapshot hash, and rechecks approval, policy, credential selection, SMTP transport revision and live transport health, recipient gates, attachment evidence, and snapshot integrity before every occurrence. It then creates one Mail-owned durable command per frozen message with occurrence-scoped idempotency before delivery. Mail never automatically retries accepted or outcome-unknown effects. Campaign records prepared, accepted, uncertain, failed, skipped, and superseded recovery evidence; uncertain, policy, configuration, and systemic failures pause the recurrence and notify the accountable operator. Missed intervals are coalesced instead of causing a catch-up storm, and pause/resume rejects 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.",
|
||||
"Autonomous mode additionally requires campaigns:campaign:queue, campaigns:campaign:send, mail:profile:use, Mail's durable outbox, and an explicitly approved built source version.",
|
||||
),
|
||||
steps=(
|
||||
"Open the campaign overview and choose Schedule.",
|
||||
"Set the first occurrence, timezone, recurrence, and bounded maximum occurrence count.",
|
||||
"Choose manual draft preparation or autonomous approved delivery; mode cannot be changed in place.",
|
||||
"For manual mode, select which configuration domains may be copied and review each generated draft independently.",
|
||||
"For autonomous mode, confirm that the selected version is built, Mail-only, and explicitly approved; the API rejects missing or stale evidence.",
|
||||
"Review next occurrence, last outcome, and recovery state. Resolve uncertain or failed Mail commands explicitly before creating or resuming a replacement schedule.",
|
||||
),
|
||||
outcome="A bounded sequence of manual drafts or at-most-once autonomous Mail commands with durable occurrence and recovery evidence.",
|
||||
verification="The Schedules section shows mode, next occurrence, last outcome, recovery state, and any automatic pause; manual occurrences link to distinct drafts while autonomous occurrences retain Mail command identifiers and non-secret outcome totals.",
|
||||
related_topic_ids=("campaigns.workflow.copy-campaign", "campaigns.workflow.prepare-validate-and-build"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Begrenzte manuelle oder autonome Kampagnen planen",
|
||||
"summary": "Neue Entwürfe vorbereiten oder den Versand eines exakt freigegebenen Builds ausdrücklich autonom ausführen.",
|
||||
"body": "Jeder Zeitplan ist begrenzt, zeitzonenfest und dauerhaft manuell oder autonom. Der manuelle Modus erzeugt eigenständige Entwürfe und funktioniert ohne Mail. Der autonome Modus verlangt eine gebaute, ausschließlich per Mail versendete und ausdrücklich freigegebene Quellversion. Vor jeder Ausführung werden Freigabe, Richtlinie, Zugangsdatenauswahl, Transportrevision und -erreichbarkeit, Empfänger, Anlagen und Snapshot-Integrität erneut geprüft. Pro eingefrorener Nachricht entsteht vor dem Versand ein dauerhafter Mail-Auftrag mit ausführungsspezifischem Idempotenzschlüssel. Angenommene oder unklare Ergebnisse werden nie automatisch wiederholt; unklare oder systemische Fehler pausieren den Zeitplan und benachrichtigen Verantwortliche. Verpasste Intervalle werden zusammengefasst und als Nachweis erhalten.",
|
||||
"outcome": "Eine begrenzte Folge manueller Entwürfe oder höchstens einmal angenommener autonomer Mail-Aufträge mit dauerhaftem Wiederherstellungsnachweis.",
|
||||
"verification": "Der Abschnitt Zeitpläne zeigt Modus, nächste Ausführung, letztes Ergebnis, Wiederherstellungsstatus und automatische Pausen.",
|
||||
}
|
||||
},
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.import-recipients",
|
||||
@@ -331,7 +558,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
||||
topic_id="campaigns.workflow.use-managed-attachments",
|
||||
title="Use managed files as campaign attachments",
|
||||
summary="Select governed file versions, preview rule matches, and preserve exactly which files were used for the campaign build.",
|
||||
body="Managed attachments remain owned by Files. Campaign stores governed references and frozen build evidence; it does not copy Files administration authority or accept arbitrary server paths.",
|
||||
body="Managed attachments remain owned by Files. Campaign stores governed references and frozen build evidence; it does not copy Files administration authority or accept arbitrary server paths. Link required files before locking. Review and send explains why a locked version cannot change its file links. Locking is unavailable while the preview is loading and rechecks current matches at the action boundary. Newly unlinked matches require explicit Link and lock confirmation; a failed check leaves the version unlocked. Link missing files in an editable version, then validate, build and review again.",
|
||||
order=34,
|
||||
audience=("campaign_manager", "campaign_author"),
|
||||
required_modules=("campaigns", "files"),
|
||||
@@ -366,6 +593,75 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
||||
),
|
||||
related_modules=("files",),
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.control-attachment-reuse",
|
||||
title="Control repeated campaign attachment use",
|
||||
summary="Choose whether one resolved file may be reused, produces a warning, requires a reasoned review decision, or blocks delivery.",
|
||||
body="Attachment reuse is a campaign-owned policy. The action can allow and record every repeated file, warn, require explicit review, or block affected messages. An optional exception permits reuse confined to one recipient or one built message. Every repeated-file finding is represented in the build protocol by a path-safe fingerprint, display filename, use count, message count, disposition, and explanation. Review decisions require a reason, bind to the exact message and build fingerprint, and remain available in campaign protocol and audit evidence. Changing the policy requires a new validation and build; it never rewrites historical evidence.",
|
||||
order=35,
|
||||
audience=("campaign_manager", "campaign_author", "campaign_reviewer", "administrator"),
|
||||
required_scopes=("campaigns:campaign:read", "campaigns:campaign:update", "campaigns:campaign:build", "campaigns:campaign:review"),
|
||||
route="/campaigns/{campaign_id}/files",
|
||||
screen="Attachments",
|
||||
help_contexts=("campaign.attachments", "campaign.attachments.reuse-policy"),
|
||||
prerequisites=(
|
||||
"Decide which repeated use is acceptable for the campaign's purpose and recipients.",
|
||||
"The current campaign version is editable when changing the policy.",
|
||||
),
|
||||
steps=(
|
||||
"Open Attachments and choose Allow, Warn, Require explicit review, or Block delivery.",
|
||||
"Optionally allow reuse only within the same recipient or the same built message.",
|
||||
"Save, validate, and build the campaign, then inspect the repeated-file summary and affected messages.",
|
||||
"For Review findings, open every affected message and record an explicit reason; for Block findings, correct the rules or policy and rebuild.",
|
||||
),
|
||||
outcome="A campaign build whose repeated attachment use is governed and reviewable under an explicit policy.",
|
||||
verification="Review and send shows the configured action and boundary, repeated-file counts and dispositions; affected messages show warning, review, or blocked state as configured.",
|
||||
related_topic_ids=("campaigns.workflow.use-managed-attachments", "campaigns.workflow.prepare-validate-and-build"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Wiederholte Verwendung von Kampagnenanhängen steuern",
|
||||
"summary": "Festlegen, ob dieselbe aufgelöste Datei wiederverwendet werden darf, eine Warnung erzeugt, eine begründete Prüfentscheidung erfordert oder den Versand sperrt.",
|
||||
"body": "Die Wiederverwendung von Anhängen wird durch eine kampagneneigene Richtlinie gesteuert. Die Aktion kann jede Wiederverwendung zulassen und protokollieren, warnen, eine ausdrückliche Prüfung verlangen oder betroffene Nachrichten sperren. Optional darf die Wiederverwendung innerhalb desselben Empfängers oder derselben erzeugten Nachricht ausgenommen werden. Jeder Befund erscheint mit pfadsicherem Fingerabdruck, Anzeigename, Verwendungs- und Nachrichtenanzahl, Ergebnis und Begründung im Build-Protokoll. Prüfentscheidungen benötigen eine Begründung, sind an Nachricht und Build-Fingerabdruck gebunden und bleiben in Protokoll und Auditnachweis erhalten. Eine Richtlinienänderung erfordert eine neue Validierung und einen neuen Build und verändert keine historischen Nachweise.",
|
||||
"outcome": "Ein Kampagnen-Build, dessen wiederholte Anhangsverwendung durch eine ausdrückliche Richtlinie gesteuert und prüfbar ist.",
|
||||
"verification": "Prüfen und senden zeigt Aktion, Ausnahmegrenze, Anzahlen und Ergebnisse; betroffene Nachrichten tragen entsprechend Warn-, Prüf- oder Sperrstatus.",
|
||||
}
|
||||
},
|
||||
),
|
||||
_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 compares watched attachment sources with the exact files assigned to built recipient messages. The configurable validation behavior can block, require review, or explicitly ignore the remaining set. An optional residual-file disposition instead turns it into one additional Campaign row addressed to a configured mailbox. Report mode lists the files; attach mode also includes them. The normalized action, observed file and source counts, routing mode, and configured recipient are visible in build review and retained in the campaign protocol; the audit event records the same policy and counts without copying the recipient address. A routed 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 mit den Dateien, die den erzeugten Empfängernachrichten tatsächlich zugeordnet sind. Das konfigurierbare Validierungsverhalten kann die Restmenge sperren, zur Prüfung vorlegen oder ausdrücklich ignorieren. Eine optionale Restdatei-Behandlung erzeugt stattdessen eine zusätzliche Kampagnenzeile an ein konfiguriertes Postfach. Der Berichtsmodus listet die Dateien auf; der Anhangsmodus fügt sie zusätzlich bei. Die normalisierte Aktion, Datei- und Quellenanzahl, Routingart und der konfigurierte Empfänger sind in der Build-Prüfung sichtbar und bleiben im Kampagnenprotokoll erhalten; das Audit-Ereignis speichert Richtlinie und Anzahlen ohne die Empfängeradresse zu kopieren. 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",
|
||||
@@ -401,7 +697,9 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
||||
verification="The Campaign job shows accepted delivery, a mirrored Calendar event ID, and the current attendee status; repeated mailbox ingestion does not duplicate the response effect.",
|
||||
related_topic_ids=("campaigns.workflow.prepare-validate-and-build", "campaigns.workflow.view-delivery-report"),
|
||||
related_modules=("mail", "calendar"),
|
||||
limitations=("Recurring Campaign invitation series require a separate series workflow; this slice creates individual VEVENT requests."),
|
||||
limitations=(
|
||||
"Recurring Campaign invitation series require a separate series workflow; this slice creates individual VEVENT requests.",
|
||||
),
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.queue-delivery",
|
||||
@@ -445,7 +743,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
||||
topic_id="campaigns.workflow.send-small-controlled-run",
|
||||
title="Send a campaign immediately",
|
||||
summary="Run the eligible jobs synchronously only after deliberately confirming that the reviewed campaign is small enough for an interactive request.",
|
||||
body="Send now is protected by an effective deployment/tenant recipient-job maximum. The server counts the exact persisted eligible build, rejects an oversized or empty run before SMTP, and preflights every message and the Mail profile revision before the first provider effect.",
|
||||
body="Send now is protected by an effective deployment/system/tenant recipient-job maximum, not a campaign-size limit. The default is 25. Administration → SYSTEM → Campaign delivery can configure 0–500 within any explicit deployment ceiling; TENANT may only narrow the inherited limit. The server counts the exact persisted eligible build, rejects an oversized or empty run before SMTP, and preflights every message and the Mail profile revision before the first provider effect. Worker delivery remains independent and requires working background infrastructure. A successful Mail server test proves that connection only, not the campaign's resource selection or sender/recipient authorization. Preflight distinguishes Mail profile/credential policy, SMTP configuration, authentication, and connectivity failures without exposing secret or raw provider details. SMTP checks its own credential selection; IMAP independently checks its selection when appending to Sent. Full campaign validation still checks all required selections.",
|
||||
order=36,
|
||||
audience=("campaign_sender", "campaign_operator"),
|
||||
required_modules=("campaigns", "mail"),
|
||||
@@ -502,7 +800,10 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
||||
topic_id="campaigns.workflow.view-delivery-report",
|
||||
title="Review campaign delivery details",
|
||||
summary="Inspect delivery totals and recipient-level job evidence in the current Campaign Report UI.",
|
||||
body="The recipient-aware Campaign Report requires campaign-read, report-read, and recipient-read authority. Infrastructure diagnostics remain separately authorized, and the server checks every direct detail route independently of the interface.",
|
||||
body=("The recipient-aware Campaign Report requires campaign-read, report-read, and recipient-read authority. Infrastructure diagnostics remain separately authorized, and the server checks every direct detail route independently of the interface. "
|
||||
"Each job shows all frozen To, Cc and Bcc addresses in their authored order; legacy jobs without a recipient snapshot fall back to the primary recipient. A primary address is a row identity, not proof that only one addressee was sent. The compact list includes recipient data only with recipient-read authority; the separate aggregate report remains address-free. SMTP and IMAP status columns use selectable lists with shared labels. "
|
||||
"Send now, inline retry/continue and inline append-to-Sent share a blocking progress dialog. It polls only a small, campaign-read and object-authorized version-scoped counter endpoint, not the workspace, recipients, attachments or full summary. Processed includes accepted/appended, failed, uncertain and cancelled outcomes; active work is separate from pending so no message disappears between counts. Excluded or non-requested channel work is outside its denominator. A failed progress read retains the last counters and does not imply a failed operation. A disconnected request may still be running: do not repeat it blindly. Acknowledged results survive later refresh failures. "
|
||||
"Append Sent acts on the selected version, never silently on every historical version. Mail reuses a bounded authenticated connection and folder lookup across sequential APPEND commands, with current policy, references, credentials and recovery fences checked per message. Default bounds are 100 APPENDs or 300 seconds per connection; this is connection reuse, not an all-or-nothing mailbox transaction. Unknown APPEND results require evidence-backed reconciliation, never automatic replay."),
|
||||
order=38,
|
||||
audience=("campaign_reader", "campaign_manager", "campaign_reviewer", "campaign_sender"),
|
||||
required_scopes=("campaigns:campaign:read", "campaigns:report:read", "campaigns:recipient:read"),
|
||||
@@ -656,7 +957,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
||||
verification="Show archived displays the same version number and original workflow state with its archival timestamp.",
|
||||
related_topic_ids=("campaigns.workflow.archive-campaign", "campaigns.workflow.view-delivery-report"),
|
||||
),
|
||||
)
|
||||
))
|
||||
|
||||
|
||||
def documentation_topics(context: DocumentationContext) -> tuple[DocumentationTopic, ...]:
|
||||
@@ -723,12 +1024,17 @@ def _actor_capabilities(principal: object, *, mail_available: bool) -> tuple[str
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:create",), "Create new campaigns.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:update",), "Edit eligible working campaign versions.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:copy",), "Create an editable successor from an eligible existing version.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:export",), "Export privacy-scoped portable Campaign packages.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:import", "campaigns:campaign:create"), "Preview and import compatible portable Campaign packages as new drafts.", require_all=True)
|
||||
_append_if(capabilities, principal, ("campaigns:recipient:read",), "Inspect recipients and recipient-specific campaign data.")
|
||||
_append_if(capabilities, principal, ("campaigns:recipient:write",), "Add and edit recipient rows.")
|
||||
_append_if(capabilities, principal, ("campaigns:recipient:import",), "Import recipient snapshots.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:validate",), "Validate campaign inputs and resolve blocking issues.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:build",), "Build exact recipient messages for review.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:review",), "Record review completion for an exact build.")
|
||||
_append_if(capabilities, principal, ("campaigns:assignment:read",), "Read accountable work attached to campaigns you can already access.")
|
||||
_append_if(capabilities, principal, ("campaigns:assignment:manage",), "Create, reassign, cancel, and reconcile authorization-neutral Campaign work.")
|
||||
_append_if(capabilities, principal, ("campaigns:assignment:complete",), "Start and complete Campaign work assigned to your account, group, or organization function.")
|
||||
if mail_available:
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:send_test",), "Run authorized delivery verification tools.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:queue",), "Queue an eligible reviewed campaign for controlled delivery.")
|
||||
@@ -836,6 +1142,17 @@ def _integration_summary(registry: object, principal: object) -> tuple[tuple[str
|
||||
else:
|
||||
limitations.append("Automatic in-app Campaign status notifications are not configured.")
|
||||
|
||||
if _integration_available(registry, _TASKS_INTEGRATION):
|
||||
configured.append("Installed composition: Campaign work assignments may be mirrored into Tasks while Campaign remains the authoritative lifecycle and access boundary.")
|
||||
else:
|
||||
limitations.append("Campaign work remains available, but optional Tasks mirroring is not configured.")
|
||||
if _integration_available(registry, _ORGANIZATIONS_INTEGRATION) and _integration_available(
|
||||
registry, _IDM_FUNCTION_ASSIGNMENTS_INTEGRATION
|
||||
):
|
||||
configured.append("Installed composition: Organization-function assignees can be resolved against active functions and their current IDM incumbencies.")
|
||||
else:
|
||||
limitations.append("Organization-function work assignment requires both Organizations directory and IDM incumbency capabilities; account and group assignment remain available.")
|
||||
|
||||
calendar_available = _integration_available(
|
||||
registry,
|
||||
_CALENDAR_INVITATION_INTEGRATION,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,260 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable
|
||||
|
||||
from govoplan_core.core.modules import DocumentationTopic, localize_documentation_topics as _localize_topics
|
||||
|
||||
|
||||
_TRANSLATIONS = {
|
||||
"campaigns.admin.delivery-policy": {
|
||||
"title": "Die Grenze für interaktiven Campaign-Versand konfigurieren",
|
||||
"summary": "Eine auditierte Systemgrenze für „Jetzt senden“ und engere Mandantengrenzen festlegen, ohne Kampagnen zu ändern oder Nachrichten zu senden.",
|
||||
"body": "Administration → SYSTEM → Campaign-Versand erlaubt mit system:settings:read das Lesen und mit system:settings:write das Speichern der Empfängerauftragsgrenze. Der unveränderte Standard bleibt 25; Systemadministrierende dürfen ausdrücklich 0–500 wählen, etwa 200 für einen Lauf mit 183 Aufträgen. Administration → TENANT → Campaign-Versand benötigt admin:policies:read/write und darf die geerbte Systemgrenze nur einschränken. Das Entfernen einer Überschreibung stellt Vererbung wieder her. Eine ausdrücklich gesetzte GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS bleibt einschließlich null eine verbindliche Bereitstellungsgrenze. Ohne diesen Wert verhindert der implizite Standard keine autorisierte Systemüberschreibung. Größere interaktive Läufe dauern länger und können Proxy- oder Anfragezeitlimits erreichen; für große Kampagnen bleiben Hintergrund-Worker mit CELERY_ENABLED und funktionierender Redis-/Celery-Infrastruktur die bevorzugte getrennte Betriebsart. Diese Einstellung begrenzt genau einen gespeicherten geeigneten Lauf für „Jetzt senden“, nicht die Kampagnengröße oder Worker-Verteilung. Speichern ändert ausschließlich die gewählte Einstellung mit Revisionskonfliktschutz, Vorher-/Nachher-Konfigurationshistorie und Audit. Es versendet nichts, ändert keine gespeicherten Prüfungen und umgeht weder Mail-, Prüf-, Freigabe- noch Ausführungsintegritätsprüfungen. Bei Fehlern bleibt der Entwurf erhalten. Neuladen verwendet den zentralen Schutz ungespeicherter Änderungen und liest die frische gespeicherte Richtlinie.",
|
||||
},
|
||||
"campaigns.workflow.create-campaign": {
|
||||
"title": "Eine Kampagne anlegen",
|
||||
"summary": "Eine gesteuerte Kampagne als bearbeitbaren Entwurf beginnen und Zweck sowie Eigentum vor Zustelldaten festlegen.",
|
||||
"body": (
|
||||
"Eine neue Kampagne beginnt mit einer bearbeitbaren Arbeitsversion. Die Aktionsleiste zeigt gespeichert, ungespeichert oder speichernd; Verwerfen steht direkt vor Speichern, und beim Verlassen eines geänderten Entwurfs greift der zentrale Speichern-oder-Verwerfen-Schutz. Destruktive Lebenszyklusaktionen sind von gewöhnlichen Aktionen getrennt. Das Anlegen gewährt keinen Zugriff auf Mail-Profile, verwaltete Dateien, Adressquellen oder Zustellaktionen; diese bleiben eigenständig autorisiert. Bestätigtes Speichern und anschließendes Neuladen sind getrennte Ergebnisse. Ein fehlgeschlagener Schreibvorgang oder abgebrochener Konflikt erhält den Entwurf; wiederholtes Speichern teilt sich einen laufenden Vorgang. Eingaben während des Speicherns bleiben als neuere ungespeicherte Arbeit erhalten. Fehlgeschlagenes oder durch neuere Änderungen überholtes Neuladen beim Verwerfen erhält den Entwurf. Unveränderte ZIP-Konfiguration sperrt nicht das Speichern unabhängiger Anlagenquellen oder Regeln. Hinzufügen-Aktionen bleiben kompakt. Eine vorübergehend fehlende Dateiauswahl wird erklärt und verwandelt verwaltete Pfade nicht stillschweigend in Texteingaben; fehlende Regelquellen müssen neu gewählt werden. Eingabe oder Leertaste öffnet die Auswahl am fokussierten Pfadfeld."
|
||||
),
|
||||
},
|
||||
"campaigns.workflow.create-editable-successor": {
|
||||
"title": "Eine bearbeitbare Nachfolgeversion anlegen",
|
||||
"summary": "Nach einer dauerhaften oder zustellungsbedingten Sperre weiterarbeiten, ohne die bewahrte Version umzuschreiben.",
|
||||
"body": (
|
||||
"„Bearbeitbare Kopie anlegen“ erzeugt die nächste Arbeitsversion der Kampagne. Validierungssperren und vorübergehende Benutzersperren werden dagegen an der bestehenden Version aufgehoben und dürfen keine parallelen Entwürfe erzeugen."
|
||||
),
|
||||
},
|
||||
"campaigns.workflow.import-address-source": {
|
||||
"title": "Eine Adressquelle importieren",
|
||||
"summary": "Ein erlaubtes wiederverwendbares Adressbuch oder eine Liste als nachvollziehbaren versionierten Snapshot in die Kampagne kopieren.",
|
||||
"body": (
|
||||
"Campaign folgt der Adressquelle nicht live. Es speichert die ausgewählte Quellrevision und warnt bei einer neueren Revision. Eine erneute Übernahme ist deshalb immer eine ausdrückliche Aktion der verfassenden Person."
|
||||
),
|
||||
},
|
||||
"campaigns.workflow.import-distribution-list": {
|
||||
"title": "Eine Verteilerliste übernehmen",
|
||||
"summary": "Eine wiederverwendbare Zielgruppe auflösen, Kanal- und Policy-Entscheidungen prüfen und einen unveränderlichen Snapshot in die aktuelle Version kopieren.",
|
||||
"body": (
|
||||
"Eine Verteilerliste bleibt in ihrem verantwortlichen Modul live und versioniert. Campaign friert genau eine Auflösung ein; spätere Listen- oder Provideränderungen erzeugen nur eine Driftwarnung und schreiben gespeicherte Empfänger niemals um."
|
||||
),
|
||||
},
|
||||
"campaigns.workflow.import-recipients": {
|
||||
"title": "Empfänger importieren",
|
||||
"summary": "Text-, CSV- oder Tabellendaten mit Quellprovenienz in geprüfte kampagnenlokale Empfängerzeilen überführen.",
|
||||
"body": (
|
||||
"Der Import kopiert gültige Zeilen in die bearbeitbare Kampagnenversion. Ungültige Zeilen bleiben in der Vorschau sichtbar, statt still zu verschwinden. Spätere Änderungen der Quelldatei ändern die gespeicherte Kampagne nicht automatisch."
|
||||
),
|
||||
},
|
||||
"campaigns.workflow.prepare-printable-delivery": {
|
||||
"title": "Eine druckbare Zustellung vorbereiten",
|
||||
"summary": "Eine veröffentlichte Ausgabevorlage wählen, ein deterministisches Artefakt bauen und Route sowie Hash-Nachweis vor Post- oder Hauspostzustellung prüfen.",
|
||||
"body": (
|
||||
"Druckbare Zustellung ist optional und anbieterneutral. Campaign friert die Routenentscheidungen je Empfänger ein, während Templates Kompatibilität und Rendering verantwortet; Files kann das erzeugte Artefakt verwalten. Eine geordnete Ausweichroute wird nur nach bestätigter Ablehnung vor Annahme verwendet, niemals nach einer angenommenen oder im Ergebnis unbekannten digitalen Wirkung."
|
||||
),
|
||||
},
|
||||
"campaigns.workflow.use-managed-attachments": {
|
||||
"title": "Verwaltete Dateien als Kampagnenanhänge verwenden",
|
||||
"summary": "Gesteuerte Dateiversionen wählen, Regelzuordnungen prüfen und exakt verwendete Dateien im Build-Nachweis bewahren.",
|
||||
"body": (
|
||||
"Verwaltete Anhänge bleiben Eigentum von Files. Campaign speichert gesteuerte Referenzen und eingefrorene Build-Nachweise; es übernimmt keine Files-Administrationsbefugnis und akzeptiert keine beliebigen Serverpfade."
|
||||
" Verknüpfen Sie benötigte Dateien vor dem Sperren. Prüfen und Senden erklärt die Reihenfolge und warum eine gesperrte Version keine Dateiverknüpfungen mehr ändern darf. Während die Anhangsvorschau lädt, ist Sperren nicht verfügbar. Vor der Sperraktion werden Treffer frisch geprüft; neue unverbundene Treffer benötigen die ausdrückliche Bestätigung Verknüpfen und sperren. Bei fehlgeschlagener Prüfung bleibt die Version ungesperrt. Verknüpfen Sie fehlende Dateien in einer bearbeitbaren Version und validieren, bauen und prüfen Sie erneut."
|
||||
),
|
||||
},
|
||||
"campaigns.workflow.queue-delivery": {
|
||||
"title": "Eine Zustellung einreihen",
|
||||
"summary": "Einen exakt geprüften Build in die Worker-Warteschlange stellen und Empfängerzustände sowie Wiederholungsschutz bewahren.",
|
||||
"body": (
|
||||
"Das Einreihen ist eine kontrollierte Zustandsänderung, kein Zustellnachweis. Gewöhnliche Stapel sollen Hintergrund-Worker verwenden. Angenommene und im Ergebnis unbekannte Wirkungen bleiben vor blinder Wiederholung geschützt."
|
||||
),
|
||||
},
|
||||
"campaigns.workflow.send-calendar-invitations": {
|
||||
"title": "Personalisierte Kalendereinladungen senden",
|
||||
"summary": "Je Empfänger eine iCalendar-Anfrage einfrieren, über Mail zustellen und aktuelle Antworten aus Calendar prüfen.",
|
||||
"body": (
|
||||
"Campaign verantwortet Empfängerauflösung, exakte Einladungsanfrage, Zustellnachweis und Bericht. Calendar verantwortet gespiegeltes VEVENT und Antwortstatus. Der Spiegel entsteht erst, nachdem ein Kanal die Nachricht angenommen hat; ein Calendar-Fehler schreibt angenommenen Mail-Nachweis nie um. Mail kann METHOD:REPLY-Teile aus einer konfigurierten IMAP-Quelle für Zustellstatus weiterreichen."
|
||||
),
|
||||
},
|
||||
"campaigns.workflow.send-small-controlled-run": {
|
||||
"title": "Einen kleinen kontrollierten Lauf sofort senden",
|
||||
"summary": "Geeignete Aufträge nur nach bewusster Bestätigung synchron ausführen, dass die geprüfte Kampagne klein genug ist.",
|
||||
"body": (
|
||||
"„Jetzt senden“ ist durch die wirksame maximale Anzahl von Empfängeraufträgen aus Bereitstellung, System und Mandant geschützt, nicht durch eine Grenze der Kampagnengröße. Standard ist 25. Administration → SYSTEM → Campaign-Versand erlaubt 0–500 innerhalb einer ausdrücklichen Bereitstellungsgrenze; TENANT darf die geerbte Grenze nur einschränken. Der Server zählt den exakt gespeicherten geeigneten Build, lehnt einen zu großen oder leeren Lauf vor SMTP ab und prüft jede Nachricht sowie die Mail-Profilrevision vor der ersten Providerwirkung. Worker-Versand bleibt unabhängig und benötigt funktionierende Hintergrundinfrastruktur."
|
||||
" Ein erfolgreicher Mail-Servertest belegt nur diese Verbindung, nicht die Ressourcenauswahl oder Absender-/Empfängerberechtigung der Kampagne. Die Vorprüfung unterscheidet Mail-Profil-/Zugangsdatenrichtlinien, SMTP-Konfiguration, Authentifizierung und Verbindung, ohne Geheimnisse oder rohe Providerdetails anzuzeigen. SMTP prüft seine eigene Zugangsdatenwahl; IMAP prüft seine Auswahl getrennt beim Ablegen in Gesendet. Die vollständige Kampagnenvalidierung prüft weiterhin alle erforderlichen Auswahlen."
|
||||
),
|
||||
},
|
||||
"campaigns.workflow.view-aggregate-delivery-report": {
|
||||
"title": "Aggregierte Kampagnenergebnisse prüfen",
|
||||
"summary": "Datenschutzgeschützte Summen ohne Empfängerzeilen, Nachrichteninhalte, Zustelldiagnosen oder Exportbefugnis einsehen.",
|
||||
"body": (
|
||||
"Die aggregierte Berichtssicht zeigt nur freigegebene fachliche Kampagnenergebnisse. Positive Zellen unterhalb des konfigurierten Schwellwerts werden zusammen mit einem ergänzenden Wert oder erforderlichenfalls dem Nenner unterdrückt, damit kleine Gruppen nicht durch Subtraktion rekonstruiert werden können."
|
||||
),
|
||||
},
|
||||
"campaigns.workflow.view-delivery-report": {
|
||||
"title": "Detaillierte Zustellergebnisse prüfen",
|
||||
"summary": "Zustellsummen und empfängerbezogene Auftragsnachweise in der aktuellen Campaign-Berichtsoberfläche einsehen.",
|
||||
"body": (
|
||||
"Der empfängerbezogene Bericht erfordert Lesezugriff auf Kampagne, Bericht und Empfänger. Infrastrukturdiagnosen bleiben getrennt autorisiert; der Server prüft jede direkte Detailroute unabhängig von der Oberfläche."
|
||||
" Jeder Auftrag zeigt alle eingefrorenen An-, Cc- und Bcc-Adressen in gespeicherter Reihenfolge; ältere Aufträge ohne Empfängersnapshot verwenden die primäre Adresse. Diese identifiziert die Zeile und beweist nicht, dass nur eine Adresse angeschrieben wurde. Die kompakte Liste erfordert weiterhin Empfänger-Leseberechtigung; der getrennte aggregierte Bericht bleibt ohne Adressen. SMTP- und IMAP-Zustände verwenden Auswahllisten mit gemeinsamen Bezeichnungen."
|
||||
" Jetzt senden, synchrone Wiederholung/Fortsetzung und Kopieren nach Gesendet verwenden denselben sperrenden Fortschrittsdialog. Er liest ausschließlich kleine versionsbezogene Zähler mit Kampagnen-Lese- und Objektberechtigung, nicht Arbeitsbereich, Empfänger, Anhänge oder vollständige Zusammenfassung. Verarbeitet umfasst angenommene/kopierte, fehlgeschlagene, ungewisse und abgebrochene Ergebnisse. Laufende Aufträge werden getrennt von ausstehenden gezählt; ausgeschlossene oder nicht angeforderte Kanäle gehören nicht zum Nenner. Bei Lesefehlern bleiben die letzten Zähler erhalten; dies bedeutet keinen fehlgeschlagenen Versand. Nach einer getrennten Anfrage kann die Verarbeitung weiterlaufen: Wiederholen Sie sie nicht blind. Bestätigte Ergebnisse bleiben bei späteren Aktualisierungsfehlern erhalten."
|
||||
" Kopieren nach Gesendet betrifft nur die ausgewählte Version, nicht stillschweigend historische Versionen. Mail verwendet eine begrenzte authentifizierte Verbindung und Ordnerauflösung für nacheinander ausgeführte APPEND-Befehle; Richtlinie, Referenzen, Zugangsdaten und Wiederherstellungsschutz werden je Nachricht neu geprüft. Standardgrenzen sind 100 APPENDs oder 300 Sekunden pro Verbindung. Dies ist Verbindungswiederverwendung, keine atomare Postfachtransaktion. Ungewisse APPEND-Ergebnisse benötigen nachweisgestützten Abgleich und werden niemals automatisch wiederholt."
|
||||
),
|
||||
},
|
||||
"campaigns.workflow.export-delivery-report": {
|
||||
"title": "Zustellergebnisse exportieren",
|
||||
"summary": "Einen autorisierten CSV-Snapshot empfängerbezogener Zustellergebnisse für kontrollierte Weiterverwendung herunterladen.",
|
||||
"body": (
|
||||
"Ein Berichtsexport enthält personenbezogene Daten und Zustellnachweise. Er ist entsprechend dem Kampagnenzweck sowie den geltenden Export- und Aufbewahrungsrichtlinien zu speichern, zu übertragen, aufzubewahren und zu löschen."
|
||||
),
|
||||
},
|
||||
"campaigns.workflow.share-campaign": {
|
||||
"title": "Eine Kampagne freigeben",
|
||||
"summary": "Einer Person oder Gruppe ausdrücklichen Lese- oder Schreibzugriff auf eine Kampagne geben, ohne Plattformberechtigungen auszuweiten.",
|
||||
"body": (
|
||||
"Eine Freigabe kann den Zugriff nur innerhalb der bestehenden Rolle auf die ausgewählte Kampagne eingrenzen. Sie gewährt niemals Mail-Profilnutzung, Files-Befugnisse, mandantenweiten Empfängerzugriff oder eine fehlende Campaign-Aktion."
|
||||
),
|
||||
},
|
||||
"campaigns.workflow.archive-campaign": {
|
||||
"title": "Eine Kampagne archivieren",
|
||||
"summary": "Eine abgeschlossene Kampagne aus der aktiven Arbeit entfernen und Versionen, Ergebnisse sowie Audit-Nachweise bewahren.",
|
||||
"body": (
|
||||
"Eine Kampagne darf erst archiviert werden, nachdem eingereihte, sendende und im Ergebnis unbekannte Arbeiten geklärt sind. Archivierung bewahrt Nachweise und ist für jede Kampagne mit Build-, Sperr- oder Zustellhistorie die richtige Lebenszyklusaktion."
|
||||
),
|
||||
},
|
||||
"campaigns.admin.collaboration-governance": {
|
||||
"title": "Campaign-Zusammenarbeit und Aufbewahrung steuern",
|
||||
"summary": "Diskussionszugriff getrennt von Kampagnenbearbeitung konfigurieren und auditierbare Moderations-Tombstones bewahren.",
|
||||
"body": (
|
||||
"Campaign-Zusammenarbeit verwendet neben dem Lesezugriff auf die Kampagne getrennte Berechtigungen zum Lesen, Schreiben und Moderieren. Die integrierte Managerrolle darf moderieren; Prüf- und Senderollen dürfen lesen und schreiben, ohne Bearbeitungsrechte zu erhalten. Eine Lesefreigabe genügt als übergeordnete Ressourcengewährung; Kommentare werten sie nicht auf. Nur für Moderationen sichtbare Inhalte werden serverseitig gefiltert. Beiträge besitzen keine Bearbeitungs-API. Rückzug und Schwärzung entfernen die Anzeige, erhalten jedoch stabilen Eintrag, SHA-256-Nachweis, Akteursnapshot, Zeitpunkt, typisierte Referenz, Tombstone und begrenztes Audit-Ereignis. Erwähnt werden dürfen nur aktive Personen mit Eigentums- oder Freigabezugriff. Optionale Notifications erhalten inhaltsfreie Hinweise; Providerfehler macht Notifications nicht zur Pflichtabhängigkeit. Institutionelle Aufbewahrungs- und Datenschutzrichtlinien müssen Kollaborationszeilen und Audit-Nachweise gemeinsam behandeln. Kommentare sind weder Freigaben noch Workflow-Übergänge oder Systemereignisse."
|
||||
),
|
||||
},
|
||||
"campaigns.workflow.delete-untouched-draft": {
|
||||
"title": "Einen unberührten Kampagnenentwurf löschen",
|
||||
"summary": "Einen Entwurf ohne geschützte Build-, Sperr-, Veröffentlichungs-, Snapshot- oder Zustellnachweise sofort entfernen.",
|
||||
"body": (
|
||||
"Löschen ist bewusst enger als Archivieren. Es markiert einen geeigneten Entwurf als gelöscht und erzeugt einen Audit-Eintrag. Eine Kampagne mit bereits aufbewahrungspflichtigen Nachweisen kann dadurch nicht entfernt werden."
|
||||
),
|
||||
},
|
||||
"campaigns.privacy.data-subject-requests": {
|
||||
"title": "Campaign-Daten in einer Datenschutzanfrage prüfen",
|
||||
"summary": "Empfänger-, Kollaborations-, Versions-, Zustell-, Berichts- und Artefaktmetadaten ermitteln, ohne unveränderliche Nachweise umzuschreiben.",
|
||||
"body": (
|
||||
"Der Campaign-DSAR-Anbieter sucht im wirksamen Mandanten nach normalisierter Empfänger-E-Mail, direkten Mitgliedschaftsreferenzen und namensraumbezogenen Campaign-Kennungen. Er isoliert passende Inline-Empfängerfelder und Auftragsmetadaten und meldet gebaute Versionen, Zustellversuche, Postbox- und Druckergebnisse, Korrekturen, empfängerbezogene Berichte, Nachrichtendigests, Anhangsmetadaten und betroffene Kollaboration. Eigener Beitragstext wird ausgegeben; fremder Text nicht allein wegen einer Erwähnung. EML-Bytes, Objekt- oder lokale Pfade, Providerziele, Worker-Claims, Idempotenzdaten, Geheimnisse, Zugangsdaten und fremde Empfängeradressen bleiben ausgeschlossen. Gebaute, gesperrte, veröffentlichte, abgeschlossene, zugestellte, korrigierte, zurückgezogene oder geschwärzte Datensätze bleiben begründet erhalten. Tombstones, Hashwerte und Audit-Nachweise sind unveränderlich. Entwurfsempfänger und benutzereigene Anhänge benötigen koordinierte manuelle Prüfung. Der Provider kann ein persönliches Import-Mappingprofil idempotent löschen und eine aktive Freigabe für die betroffene Person widerrufen; zugestellte Nachweise und erzeugte Artefakte werden nie direkt gelöscht."
|
||||
),
|
||||
},
|
||||
"campaigns.workflow.archive-historical-version": {
|
||||
"title": "Eine historische Kampagnenversion archivieren",
|
||||
"summary": "Eine nicht aktuelle Version aus der Standardhistorie ausblenden, ohne aufbewahrte Nachweise zu ändern oder zu löschen.",
|
||||
"body": (
|
||||
"Die Archivierung einer historischen Version betrifft nur ihre Darstellung. Ursprünglicher Workflow-Zustand, Konfiguration, Berichte, Zustellergebnisse und Audit-Nachweise bleiben für autorisierte Personen lesbar und werden bei eingeblendeten archivierten Versionen mitgeführt."
|
||||
),
|
||||
},
|
||||
"campaigns.search.campaigns": {
|
||||
"title": "Autorisierte Kampagnen durchsuchen",
|
||||
"summary": "Kampagnenidentität und Lebenszyklusmetadaten für die berechtigungsbewusste Plattformsuche bereitstellen.",
|
||||
"body": (
|
||||
"Wenn Search installiert ist, trägt Campaign aktuelle Namen, externe Kennungen, Beschreibungen und Lebenszykluszustände bei. Vor einem Ergebnis werden Mandant, Eigentum, Gruppeneigentum, ausdrückliche Freigaben, Widerruf, Löschung und Campaign-Leseberechtigung erneut geprüft. Bestätigte Kampagnen- und Freigabeänderungen aktualisieren den abgeleiteten Index über den dauerhaften Plattform-Ereignispfad; ein Neuaufbau verändert keine Campaign-Nachweise."
|
||||
),
|
||||
},
|
||||
"campaigns.postbox-delivery": {
|
||||
"title": "Campaign-Nachrichten an Postboxen zustellen",
|
||||
"summary": "Je Empfängerzeile eine oder mehrere exakte oder organisationsabgeleitete Postboxen allein oder neben Mail adressieren.",
|
||||
"body": (
|
||||
"Konfiguriert werden kampagnenweite Ziele und optionale Ergänzungen oder Ersetzungen je Zeile. Abgeleitete Ziele lösen eine veröffentlichte Postbox-Vorlage mit Organisationseinheit, Funktion und optionalen Kontextwerten auf; Werte dürfen aus Campaign-Feldern stammen. Ziele werden beim Build eingefroren. Ein Ausweichen zum zweiten Kanal erfolgt nur nach bestätigter Ablehnung vor Annahme; angenommene oder im Ergebnis unbekannte Wirkungen lösen kein Fallback aus."
|
||||
),
|
||||
},
|
||||
"campaigns.mail-profile-user-journey": {
|
||||
"title": "Ein Mail-Profil für die Kampagnenzustellung wählen",
|
||||
"summary": "Campaign referenziert ein autorisiertes Mail-Profil und speichert niemals SMTP-/IMAP-Einstellungen oder Zugangsdaten.",
|
||||
"body": (
|
||||
"Mail-Einstellungen → Wiederverwendbares Mail-Profil → SMTP-Zugangsdaten (bei Nutzung auch IMAP-Zugangsdaten) speichert eine ausdrückliche Zugangsdatenkennung. Eine leere Auswahl bedeutet Vererbung nur soweit die Mail-Richtlinie dies erlaubt; ein Profilstandard ist keine gespeicherte Kampagnenauswahl. Fehlende oder inaktive gespeicherte Profile, Server und Zugangsdaten bleiben sichtbar nicht verfügbar und werden nicht stillschweigend ersetzt. "
|
||||
"In den Mail-Einstellungen der Kampagne wird ein verfügbares Profil ausgewählt, über Mail getestet und gespeichert. Bei gemeldeten kampagnenlokalen Alt-Transportdaten wählen Sie nach der autorisierten Profilauswahl „Ausgewähltes Mail-Profil migrieren“. Diese ausdrückliche Aktion funktioniert auch bei unveränderter Profilauswahl und unberührtem Entwurf. Gesperrte historische Nachweise bleiben erhalten; verwenden Sie zuvor die angebotene Entsperrung oder bearbeitbare Nachfolgeversion. „Prüfen und Senden“ zeigt den Migrationsblocker mit Rückweg zu den Mail-Einstellungen, statt wiederholt eine ungültige Anhangsvorschau anzufordern. Die Profilauswahl lädt nur für die Kampagne autorisierte Profile; ein Fehler beim getrennten administrativen Richtlinienkatalog leert sie nicht. Validierung und Zustellung prüfen die Profilberechtigung erneut. Migration speichert Konfiguration, versendet aber keine E-Mail. Validieren, bauen und prüfen Sie die resultierende Version vor der Zustellung erneut."
|
||||
" Mail-Migration und ZIP-Richtlinienkorrekturen lassen sich in beliebiger Reihenfolge speichern. Unveränderte ZIP-Einstellungen blockieren die Migration nicht und erhalten keinen neuen Zustimmungsnachweis. Eine Archiv- oder Inhaltskorrektur mit unveränderten Mail-Referenzen erhält den alten Transport serverseitig und zeigt weiterhin den Migrationshinweis; es erfolgt keine stillschweigende Migration. Ein bestätigtes Speichern und das anschließende Neuladen des Arbeitsbereichs sind getrennte Ergebnisse: Bei fehlgeschlagenem Neuladen bleiben die letzten nutzbaren Daten derselben Kampagne und Version sichtbar, ergänzt um den Fehler. Wiederholen Sie Neuladen; veraltete Antworten einer anderen Kampagne, Version, Identität oder früheren Aktualisierung dürfen den aktuellen Arbeitsbereich nicht ersetzen."
|
||||
),
|
||||
},
|
||||
"campaigns.mail-profile-governance": {
|
||||
"title": "Campaign-zu-Mail-Profilreferenzen steuern",
|
||||
"summary": "Mail besitzt Transportdefinitionen und verschlüsselte Zugangsdaten; Campaign nur die Profilreferenz und Zustellnachweise.",
|
||||
"body": (
|
||||
"Kampagnenverfassende erhalten mail:profile:use; verfügbare Profile werden über Mail-Policy begrenzt und es wird ausdrücklich festgelegt, ob Profil-Zugangsdaten geerbt werden dürfen oder eine Kampagne Mail-eigene Zugangsdaten auswählen muss. Die Mail-Richtlinienseite zeigt die SMTP-/IMAP-Zugangsdatenvererbung mit lokalen, geerbten und wirksamen Werten sowie übergeordneten Sperren. Inline-Transportfelder werden abgelehnt und niemals samt Zugangsdaten an den Browser zurückgegeben. Altbestände bleiben unverändert, bis eine ausdrückliche auditierte Profilmigration eine bearbeitbare Version erzeugt oder aktualisiert. Dafür genügt auch das schon referenzierte Profil, wenn „Ausgewähltes Mail-Profil migrieren“ verwendet wird. Mail-Einstellungen laden nur die für diese Kampagne nutzbaren Profile; der administrative Richtlinienkatalog wird getrennt auf der Mail-Richtlinienseite angefordert und Fehler bleiben dort sichtbar. Diese Trennung verleiht keine Profiladministration und umgeht weder Eigentümer-, Mandanten- noch Mail-Autorisierung. Migration versendet keine E-Mail und stellt keinen alten Ausführungssnapshot wieder her."
|
||||
" Unabhängige Entwurfskorrekturen dürfen das exakt gespeicherte alte Serverobjekt nur bei unveränderten öffentlichen Mail-Referenzen erhalten; Inline-Transport darf nicht mitgesendet werden. Dabei wird Inhalt gespeichert, kein Profil ausgewählt oder genutzt, auch nach Entzug seiner Berechtigung. Ausdrückliche Migration benötigt weiterhin mail:profile:use und aktuelle Mail-Policy. Der Versions-Auditnachweis unterscheidet legacy_mail_settings_preserved und legacy_mail_settings_migrated. Unveränderte ZIP-Konfiguration wird nicht erneut bestätigt; geänderte ZIP-Einstellungen unterliegen allen Richtlinienprüfungen. Erfolgreiche Korrekturen entwerten bisherige Build- und Ausführungsnachweise und lockern weder Validierung noch Prüfung oder Versand."
|
||||
" Bereits migrierte Entwürfe folgen derselben Regel für unveränderte Referenzen: Eine spätere Pflicht zur ausdrücklichen SMTP-/IMAP-Zugangsdatenwahl verhindert keine unabhängige Archiv- oder Inhaltskorrektur. Jede geänderte Mail-Ressourcenauswahl benötigt weiterhin Mail-Berechtigung und aktuelle Richtlinie; verbindliche Validierung und Zustellung prüfen stets die vollständige Auswahl erneut."
|
||||
),
|
||||
},
|
||||
"campaigns.mail-profile-operations": {
|
||||
"title": "Profilbasierte Kampagnenzustellung betreiben",
|
||||
"summary": "Worker autorisieren und lösen Mail-Profile bei Ausführung neu auf; Campaign bewahrt nur undurchsichtige Mail-Revisionen und Ergebnisse.",
|
||||
"body": (
|
||||
"SMTP- und IMAP-Laufzeitaktionen prüfen die Zugangsdatenpflicht getrennt je Protokoll; ein SMTP-Aufruf benötigt keine IMAP-Parameter und umgekehrt. Vollständige Kampagnenvalidierung und Build-Zusammenfassung prüfen weiterhin beide erforderlichen Auswahlen. Die Vorprüfung unterscheidet Mail-Profil-/Zugangsdatenrichtlinienfehler von SMTP-Konfigurations-, Authentifizierungs- und Verbindungsfehlern. Ein erfolgreicher Servertest ersetzt keine kampagnenspezifische Autorisierung. "
|
||||
"Ein Altsnapshot, unautorisiertes oder inaktives Profil, Referenzkonflikt oder eine geänderte SMTP-/IMAP-Revision stoppt die Zustellung. Synchrone Stapel prüfen DNS, Verbindung, TLS und Authentifizierung vor der ersten Wirkung, verwenden eine begrenzte gesunde SMTP-Verbindung wieder und verbinden bei Alterung neu. Oberfläche und Bericht zeigen Stapel-, Verbindungs-, Wiederverbindungs-, Fehler- und Pausenzahlen. Systemische Authentifizierungs-, Absender- oder Verbindungsfehler pausieren übrige Aufträge mit stabilem Grund; das Profil ist zu korrigieren und zu testen, bevor ausdrücklich fortgesetzt wird. Verbindungsverlust nach Beginn von DATA bleibt ergebnisoffen und wird nicht automatisch wiederholt. Der Datensatz wird bewahrt, Profilwahl korrigiert, erneut validiert und gebaut und erst dann neu eingereiht. Reine Passwortrotation kopiert keine Geheimnisse nach Campaign. Unsichere SMTP-/IMAP-Wirkungen bleiben bis zum evidenzbasierten Betriebsabgleich blockiert. Wird Campaign nach Annahme eines Auftrags für den Mandanten unzugänglich, bleibt er unangetastet und wird als Betriebsaktion gemeldet."
|
||||
),
|
||||
},
|
||||
"campaigns.workflow.prepare-validate-and-build": {
|
||||
"title": "Eine Kampagne vorbereiten, validieren und bauen",
|
||||
"summary": "Gesteuerte Empfänger-, Vorlagen-, Anhangs- und Mail-Profil-Eingaben in exakte Nachrichten zur Prüfung überführen.",
|
||||
"body": (
|
||||
"Jede Eingabe wird in ihrer verantwortlichen Oberfläche vorbereitet, alle blockierenden Validierungsprobleme werden gelöst und exakte Empfängernachrichten vor der Prüfung gebaut. Empfängerzeilen können als eine ausdrücklich bestätigte Entwurfsänderung gesammelt aktiviert oder deaktiviert werden; Speichern erzeugt normale Versionsnachweise und verwirft veraltete Validierungs-, Build- und Prüfzustände. Passwortfelder verwenden den zentralen sicheren Generator, dessen Vorschlag erst nach „Passwort verwenden“ übernommen wird. Campaign friert Empfänger- und Anhangsnachweise für die ausgewählte Version ein; spätere Quelländerungen ändern den Build nicht. Kennzahlen bieten nur dann einen benannten Drill-down, wenn eine autorisierte Quellsammlung, gefilterte Prüftabelle, Anhangsvorschau oder ein Bericht eine Handlung ermöglicht. Datenschutzunterdrückte Aggregate bleiben nicht interaktiv. Ist Templates installiert, besitzt dessen einziger Navigationseintrag die wiederverwendbare Bibliothek; kampagnenspezifische Komposition bleibt im Arbeitsbereich."
|
||||
" In individuellen und globalen Adressdialogen bestimmen die Auf-/Ab-Aktionen die gespeicherte Adressreihenfolge. Speichern im Dialog übernimmt diese Reihenfolge ohne alphabetische Neusortierung in den Kampagnenentwurf; doppelte E-Mail-Adressen behalten ihre erste Position. Eingefügte Adressen werden in Eingabereihenfolge angehängt, ohne vorhandene Adressen umzuordnen. Die erste individuelle An-Adresse bleibt der primäre Name und die E-Mail-Adresse der Empfängerzeile. Speichern Sie anschließend die Kampagnenseite, um den Entwurf dauerhaft zu übernehmen; bei einem Fehler bleibt die Reihenfolge für einen ausdrücklichen neuen Speicherversuch erhalten. Abbrechen verwirft gezielt nur die noch unbestätigten Dialogänderungen."
|
||||
),
|
||||
},
|
||||
"campaigns.workflow.complete-review": {
|
||||
"title": "Die Kampagnenprüfung abschließen",
|
||||
"summary": "Kritische Blocker lösen, einzelne Nachrichten entscheiden und unkritische Punkte für genau einen Build bestätigen.",
|
||||
"body": (
|
||||
"Das Öffnen der Vorlage ohne Bearbeitung, Änderungen des Schreibschutzes und der Wechsel zwischen visueller Ansicht und Quelltext erhalten das gespeicherte HTML unverändert und erfordern beim Verlassen kein Speichern. Der Prüfabschluss bleibt an aktuellen Build-Token, geprüfte Nachrichtenschlüssel, dokumentierte Problementscheidungen und Nachrichtennachweise gebunden. Normales Speichern sendet nur die clientverantworteten Metadaten created_from, field_overrides und opt_ins; review_send und approval_gate sind lesbare Servernachweise, aber keine schreibbaren Editorfelder. Werden sie bei einem Metadaten-Speichern ausgelassen, bleiben sie serverseitig erhalten. Die vorgesehenen Regeln für Entsperrung, Nachfolgeversionen und Build-Invalidierung entfernen veraltete Nachweise weiterhin. Bei notwendiger Mail-Altdatenmigration bleibt „Prüfen und Senden“ schreibgeschützt, unterdrückt inkompatible Anhangsvorschau-Anfragen und bietet „Mail-Einstellungen öffnen“ für genau die ausgewählte Version. Migrieren, validieren, bauen und prüfen Sie vor dem Senden erneut. Ausdrückliche Aktionen auf handlungsfähigen Empfänger-, Anhangs-, Validierungs- und Prüfkennzahlen öffnen Quellseite, Nachweisvorschau oder gefilterte Nachrichtentabelle. Reine Information und datenschutzunterdrückte Werte werden nicht zu versteckten Klickzielen. Änderungen an Empfängern, Inhalt, Anhängen, Eigentümerkontext oder nicht geheimer Transportidentität erfordern erneut Validierung, Build und Prüfung."
|
||||
" Gleichartige Prüfbedingungen bestätigen gruppiert ausschließlich vom Server zugelassene, ungeprüfte Nachrichten aus der aktuell geladenen passenden Auswahl. Wählen Sie eine verständlich benannte Kategorie, prüfen Sie die gezählte Empfängerauswahl und geben Sie bei Anhangsausnahmen eine gemeinsame Begründung an. Jede Anfrage benennt höchstens 200 konkrete Nachrichten und prüft aktuellen Build und Kategorie; wiederholen Sie dies für verbleibende Nachrichten, statt andere Kategorien oder nicht geladene Nachrichten als mitbestätigt anzusehen. Jede ausgewählte Nachricht erhält einen eigenen eingefrorenen, zuordenbaren Entscheidungsnachweis. Bei fehlgeschlagenem Speichern bleiben Begründung und Auswahl für einen ausdrücklichen Wiederholungsversuch erhalten; ein geänderter Build verhindert veraltete Bestätigungen. Die Gruppenbestätigung sendet keine Nachrichten und schließt die abschließende Prüfung nicht ab. Harte Blocker können nicht übergangen werden. Beabsichtigte richtlinienbedingte Ausschlüsse und ausdrücklich erlaubte Anhangsregeln ohne Treffer bleiben informativ und benötigen keine Prüfentscheidung."
|
||||
" Eine einzelne Annahme speichert Begründung und Prüfstatus sofort, schon vor dem vollständigen Prüfabschluss; Neuladen setzt den bestätigten Fortschritt desselben Builds fort. Bei fehlgeschlagenem Speichern oder einem Konflikt bleibt die Begründung für einen ausdrücklichen neuen Versuch erhalten; die Nachricht gilt noch nicht als geprüft. Jeder Speichervorgang ergänzt nur ausgewählte Nachrichten und erhält fremde Prüfnachweise, ohne Nachrichten neu zu bauen, Anhangsdateien zu prüfen oder den gesamten Arbeitsbereich neu zu laden. Teilfortschritt erlaubt keinen Versand; der abschließende Prüfabschluss kontrolliert weiterhin alle erforderlichen Entscheidungen und harten Blocker. Pflichtanhänge und harte Sperrrichtlinien bleiben gegenüber optional erlaubten leeren Treffern vorrangig; auch die getrennte Kampagnenrichtlinie für vollständig anhangslose Nachrichten gilt weiterhin."
|
||||
" Speichern benötigt die Campaign-Prüfberechtigung, die aktuelle Versionsrevision und den sicheren operativen Bezug review_build_token; Diagnoseberechtigung ist nicht erforderlich. Veraltete Builds oder gleichzeitige Änderungen führen zu einem Konflikt ohne Überschreiben gespeicherten Fortschritts."
|
||||
" Bestätigte oder erwartete Anhangsbedingungen bleiben für denselben Build auch bei „Bestätigen und senden“ erfüllt; fehlende oder mehrdeutige Quelltreffer bleiben als Kontext sichtbar, erzeugen aber keine zweite Bestätigungspflicht. Der Mock-Test nach der Prüfung verwendet verifizierte eingefrorene Nachrichten und abgeschlossene Entscheidungen statt einer Neuerstellung. Geänderte Eingaben, Problemnachweise, Nachrichtenbytes oder Mail-Transport stoppen den Test vor der Mock-Aufzeichnung; Entwurfsvorschauen behalten ihren getrennten vorläufigen Build."
|
||||
" Validierungsdetails und Listen mehrfach verwendeter Dateien zeigen alle Einträge über die zentrale DataGrid-Seitensteuerung. Zusammengehörige fehlende Regeltreffer und die Richtlinienfolge einer anhangslosen Nachricht werden gemeinsam erklärt; aufklappbare technische Nachweise bleiben erhalten. Nachrichtentabelle und Filter verwenden vier operative Zustände: Bereit, Prüfung erforderlich, Blockiert und Ausgeschlossen. Angenommene ausdrückliche Entscheidungen werden Bereit; noch unbestätigte Warnungen bleiben Prüfung erforderlich. Eine zweite Spalte erklärt den Zustand. Diese Darstellung löscht oder verändert keine eingefrorenen Probleme oder Auditnachweise."
|
||||
),
|
||||
},
|
||||
"campaigns.workflow.retry-and-reconcile": {
|
||||
"title": "Fehler wiederholen und unsichere Wirkungen abgleichen",
|
||||
"summary": "Sicher wiederholbare Fehler von Mail-, Postbox- oder IMAP-Wirkungen mit unbekanntem Ergebnis trennen.",
|
||||
"body": (
|
||||
"Eine Wiederholung erzeugt neuen Versuchsnachweis und ist nur für ausdrücklich geeignete Zustände zulässig. Unbekannte Mail-, Postbox- oder IMAP-Wirkungen dürfen nie blind wiederholt werden. Externe Nachweise sind zu prüfen und der betroffene Kanal vor dem Fortsetzen abzugleichen. Angenommene Mail-Versuche und Postbox-Ziele bleiben bei Teilwiederholungen unveränderlich; die Reparatur von „Gesendet“ versendet angenommene Mail nicht erneut."
|
||||
" Ohne Worker bietet der Bericht ausdrücklich bestätigte, begrenzte Wiederholung und Fortsetzung über dieselben unveränderlichen Aufträge, Ausführungsprüfungen, Prüfnachweise, Freigaben, Mail-Berechtigungen, Ratenbegrenzungen und Wiederherstellungsnachweise wie Jetzt senden. Jede Anfrage bleibt innerhalb der wirksamen synchronen Grenze und meldet verbleibende Arbeit; bereits angenommene, ausgeschlossene, aktive und ungewisse Aufträge werden nicht erneut gesendet. Wiederholung benötigt campaigns:campaign:retry und synchron zusätzlich campaigns:campaign:send; Fortsetzen benötigt campaigns:campaign:queue und campaigns:campaign:send. Abgleich benötigt campaigns:campaign:reconcile und eine sachliche Nachweisnotiz; er sendet nichts."
|
||||
" Ein festhängender übernommener, sendender oder kopierender Auftrag ist nicht allein durch Zeitablauf sicher. Der Bericht bietet die Wiederherstellung eines unterbrochenen Auftrags nur bei abgelaufener dauerhafter Sperre und nachweislich gestoppter oder ersetzter ursprünglicher Laufzeit. Die mitgesendete sichere Revision muss noch passen, und ursprüngliche Wiederherstellungsnachweise müssen gültig sein. Die Aktion setzt das Ergebnis ausschließlich auf ungewiss. Prüfen Sie Provider- beziehungsweise Postfachnachweise und gleichen Sie angenommen/nicht gesendet oder kopiert/nicht kopiert getrennt ab, bevor Sie ausdrücklich wiederholen. Fehlende Sperr- oder Versuchsnachweise und unbestätigte Besitzer bleiben zur betrieblichen Untersuchung gesperrt. Ein doppelter Worker-Aufruf verändert aktive Zustände nicht."
|
||||
),
|
||||
},
|
||||
"campaigns.reference.composition-assurance": {
|
||||
"title": "Die Campaign-Referenzkomposition absichern",
|
||||
"summary": "Campaign nur mit abgestimmten Verträgen, rollensicheren Oberflächen, dauerhaften Wirkungsnachweisen, optionaler Modultrennung und wiederherstellbaren Daten freigeben.",
|
||||
"body": (
|
||||
"Freigabeprüfungen müssen Campaign-Validierung und Anhangsauflösung unabhängig in frischen Prozessen initialisieren, ohne einen früheren Seiten- oder Testimport vorauszusetzen. Diese lokalen Einstiegspunkte bleiben ohne installiertes Mail oder Files nutzbar; ihr Import startet keinen Versand und lockert keine Pfadberechtigungen für verwaltete Dateien. "
|
||||
"Campaign ist nur dann Referenzkomposition, wenn Core, Mail, Files, Addresses, Worker, Speicher, Policies und Dokumentation in genau der installierten Kombination geprüft sind. Gewöhnliche Lesende sehen Fachzustand statt Pfaden, Speicherschlüsseln, Worker-Claims oder rohen Providerdiagnosen; Diagnose- und Exportbefugnis bleiben getrennt."
|
||||
" Prüfen Sie, dass einzelne Begründungen und Prüfzustände schon vor dem vollständigen Abschluss Neuladen überstehen. Teilfortschritt benötigt campaigns:campaign:review und Schreibzugriff, ergänzt genau ausgewählte Nachrichten des aktuellen Builds mit Revisionsprüfung und protokolliert Annahmen ohne fremde Prüfnachweise zu ersetzen. Der operative review_build_token legt keine rohen Diagnosetoken offen. Teilfortschritt erlaubt keinen Versand; harte Blocker sind nicht bestätigbar. Richtlinienbedingte Ausschlüsse und ausdrücklich erlaubte leere optionale Anhangsregeln erzeugen keine neue Prüfpflicht; Pflichtanhänge und globale harte Sperren bleiben wirksam. Diese Auflösungsänderungen gelten nur für neue Builds: Eine bewusste Neuerstellung klassifiziert vorhandene Nachrichten neu und entwertet frühere Prüf- und Freigabenachweise. Eingefrorene historische Auftragsprobleme dürfen nicht aus veränderlichen Richtlinien umgeschrieben werden."
|
||||
" Der ausdrückliche Mock-Modus use_reviewed_build benötigt einen vorhandenen versiegelten Ausführungsnachweis und bei prüfpflichtigen Nachrichten den Abschluss desselben Builds. Vor jeder Mock-Aufzeichnung oder angeforderten Leerung des Mock-Postfachs prüft er Auftrags- und Problemnachweise, EML-Länge, Digest, Message-ID und aktuellen Mail-Transport. Er sendet kein SMTP, verändert keinen Campaign-Zustellstatus und erzeugt keinen fehlenden Altdaten-Snapshot. include_needs_review ist in diesem Modus keine pauschale Umgehung."
|
||||
),
|
||||
},
|
||||
"campaigns.reference.shared-build-artifacts": {
|
||||
"title": "Gemeinsam genutzte Campaign-Build-Artefakte betreiben",
|
||||
"summary": "Erzeugte Nachrichten in gemeinsamem Objektspeicher ablegen und vor der Zustellung verifizieren.",
|
||||
"body": (
|
||||
"Campaign speichert erzeugte EML unter undurchsichtigen gemeinsamen Objektschlüsseln und protokolliert erwartete Größe, SHA-256-Digest und Message-ID je Auftrag. Worker auf anderen Knoten prüfen diesen Nachweis vor Zustellung. Vor Objekt- oder Files-Ausgaben zeichnet eine lease-gebundene Core-Recovery-Operation Quelle, validierte Version und reserviertes Präfix auf, prüft das Objekt und erneuert die Sperre vor dem Fach-Commit. Eine getrennte auftragsgebundene Operation erfasst vor realer Mail-, Postbox- oder Druckwirkung unveränderliche Nachrichten- und Empfängerdigests und verifiziert später den autoritativen Kanalversuch. Ablehnung, Annahme, unbekanntes Ergebnis und Recovery-Bedarf bleiben unterscheidbar. Objektfehler weisen Kompensation nach; Files-Ausgaben und unsichere Bereinigung bleiben Vorwärts-Recovery. Aufbewahrung ändert Locator kontrolliert und prüft Abwesenheit unabhängig. Ein reiner Betriebsabgleich inventarisiert begrenzte Mandantenpräfixe, schützt aktive Builds und mindestens 24 Stunden Karenz und löscht nur weiterhin unreferenzierte Objekte. Laufzeitobjektschlüssel sind keine Fachdaten."
|
||||
),
|
||||
},
|
||||
"campaigns.archive-encryption-governance": {
|
||||
"title": "Passwortgeschützte ZIP-Anhänge gesteuert verwenden",
|
||||
"summary": "Standardmäßig AES einsetzen und schwaches Windows-kompatibles ZipCrypto nur mit Policy, Berechtigung, Bestätigung und Nachweis wählen.",
|
||||
"body": (
|
||||
"Campaign löst Archivverschlüsselung über Policy auf System-, Mandanten-, Eigentümer- und Kampagnenebene auf. Passwortgeschützte Archive verwenden AES, außer die vollständig vererbte Richtlinie erlaubt Legacy ZipCrypto ausdrücklich und die handelnde Person besitzt campaigns:archive:use_legacy_zipcrypto. Die Legacy-Auswahl benötigt eine begründete Bestätigung. Passwörter erscheinen weder im Campaign-Nachweis noch in der Nachricht und müssen über den getrennt ausgewählten, per Policy erlaubten Kanal übermittelt werden. Jeder Build friert Archiv- und Mitglied-Hashes, Implementierungsversion, Policy-Hash und -Quellpfad, bestätigende Person, Begründung, Zeitpunkt und Build-Identität ein. Eine später strengere Policy blockiert Einreihen und Senden bis zum Neubau; nach Fehlern wird nie von AES auf ZipCrypto zurückgefallen. Temporärer Klartext und Archive bleiben im begrenzten Build-Verzeichnis und werden nach Erfolg oder Fehler entfernt."
|
||||
" Kampagneneinstellungen, Richtlinien und Anhänge zeigen die wirksame Richtlinie und führen berechtigte Administrierende direkt zu Administration → SYSTEM → Campaign archive encryption. Aktivieren Sie dort Legacy ZipCrypto und speichern Sie. Frische Systemstandardwerte sind bearbeitbar, ohne dass das bloße Öffnen bereits eine Ausnahme erzeugt. Mandanten- und Eigentümerrichtlinien können das Ergebnis weiter einschränken. Laden Sie anschließend in Campaign die Archivrichtlinie neu, wählen Sie Legacy ZipCrypto unter Anhänge → ZIP-Anhänge, bestätigen Sie die schwache Verschlüsselung und geben Sie eine betriebliche Begründung mit mindestens 10 Zeichen an. Ohne Policy bleibt Legacy gesperrt. Weder Richtlinien- noch Anhangskonfiguration versendet beim Speichern eine E-Mail."
|
||||
" Mail-Migration und Archivkorrekturen lassen sich unabhängig in beliebiger Reihenfolge speichern. Eine exakt unveränderte ZIP-Konfiguration bleibt bei einer anderen Korrektur ohne erneute Bestätigung erhalten, auch nach Entzug von Richtlinie oder Berechtigung; ihre Nutzung wird dadurch nicht erlaubt. Jede geänderte ZIP-Konfiguration muss aktuelle Methoden, Passwortkanäle und Legacy-Berechtigungs- sowie Bestätigungsvorgaben erfüllen. Eine Archivkorrektur mit unveränderten öffentlichen Mail-Referenzen erhält den alten Transport exakt serverseitig bis zur ausdrücklichen autorisierten Migration. Clients dürfen dabei weder Inline-Transport einführen oder zurücksenden noch Mail-Referenzen ändern oder Bestätigungsnachweise erfinden. Beide Korrekturen entwerten Ausführungs- und Build-Nachweise; Validierung, Prüfung, Erstellung und Versand bleiben bis zur Erfüllung aller Bedingungen gesperrt."
|
||||
),
|
||||
},
|
||||
"campaigns.workflow.link-exact-campaign-to-case": {
|
||||
"title": "Eine exakte Kampagnenreferenz mit einem aktiven Fall verknüpfen",
|
||||
"summary": "Eine autorisierte Kampagne und ihre aktuelle unveränderliche Version über Quick Access zurückgeben, ohne Kampagneninhalt zu kopieren.",
|
||||
"body": (
|
||||
"Ist ein Fall das aktive Objekt, stellt Campaigns in Quick Access eine begrenzte Auswahl bereit. Die normale Kampagnenliste prüft Mandant, Eigentum, Gruppe, Freigaben und Administrationszugriff vor der Anzeige. Die Auswahl liefert über den versionierten Ergebnisvertrag nur Eigentümermodul, stabile Kampagnen-ID, aktuelle Versions-ID, Anzeigetext, Mandant und Eigentümerroute. Cases verwirft den Anzeigetext und speichert keine Empfänger-, Nachrichten-, Anhangs-, Zustell-, Berichts- oder Konfigurationsinhalte. Beim Öffnen prüft Campaigns den Zugriff erneut. Deaktivierung, Widerruf oder Entfernung lässt daher nur eine nicht verfügbare historische Fallreferenz zurück und macht Fallzugriff nie zu Kampagnenzugriff."
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def localize_documentation_topics(
|
||||
topics: Iterable[DocumentationTopic],
|
||||
) -> tuple[DocumentationTopic, ...]:
|
||||
return _localize_topics(topics, locale="de", translations=_TRANSLATIONS)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -70,11 +74,21 @@ class SmtpConfigurationError(RuntimeError):
|
||||
|
||||
class SmtpSendError(RuntimeError):
|
||||
def __init__(
|
||||
self, message: str, *, temporary: bool = False, outcome_unknown: bool = False
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
temporary: bool = False,
|
||||
outcome_unknown: bool = False,
|
||||
systemic: bool = False,
|
||||
reason_code: str | None = None,
|
||||
phase: str = "send",
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.temporary = temporary
|
||||
self.outcome_unknown = outcome_unknown
|
||||
self.systemic = systemic
|
||||
self.reason_code = reason_code
|
||||
self.phase = phase
|
||||
|
||||
|
||||
class ImapConfigurationError(RuntimeError):
|
||||
@@ -294,6 +308,9 @@ class MailCampaignIntegration:
|
||||
str(exc),
|
||||
temporary=bool(getattr(exc, "temporary", False)),
|
||||
outcome_unknown=bool(getattr(exc, "outcome_unknown", False)),
|
||||
systemic=bool(getattr(exc, "systemic", False)),
|
||||
reason_code=str(getattr(exc, "reason_code", "") or "") or None,
|
||||
phase=str(getattr(exc, "phase", "send") or "send"),
|
||||
) from exc
|
||||
except getattr(
|
||||
delegate, "SmtpConfigurationError", SmtpConfigurationError
|
||||
@@ -302,6 +319,52 @@ class MailCampaignIntegration:
|
||||
except getattr(delegate, "MailProfileError", MailProfileError) as exc:
|
||||
raise MailProfileError(str(exc)) from exc
|
||||
|
||||
@contextmanager
|
||||
def campaign_smtp_batch(self, *args: Any, **kwargs: Any) -> Iterator[Any]:
|
||||
delegate = self._require()
|
||||
method = getattr(delegate, "campaign_smtp_batch", None)
|
||||
if not callable(method):
|
||||
yield None
|
||||
return
|
||||
try:
|
||||
with method(*args, **kwargs) as state:
|
||||
yield state
|
||||
except getattr(delegate, "SmtpSendError", SmtpSendError) as exc:
|
||||
raise SmtpSendError(
|
||||
str(exc),
|
||||
temporary=bool(getattr(exc, "temporary", False)),
|
||||
outcome_unknown=bool(getattr(exc, "outcome_unknown", False)),
|
||||
systemic=bool(getattr(exc, "systemic", False)),
|
||||
reason_code=str(getattr(exc, "reason_code", "") or "") or None,
|
||||
phase=str(getattr(exc, "phase", "preflight") or "preflight"),
|
||||
) from exc
|
||||
except getattr(delegate, "SmtpConfigurationError", SmtpConfigurationError) as exc:
|
||||
raise SmtpConfigurationError(str(exc)) from exc
|
||||
except getattr(delegate, "MailProfileError", MailProfileError) as exc:
|
||||
raise MailProfileError(str(exc)) from exc
|
||||
|
||||
@contextmanager
|
||||
def campaign_imap_batch(self, *, tenant_id: str, campaign_id: str) -> Iterator[Any]:
|
||||
"""Use Mail-owned connection reuse when the installed capability offers it."""
|
||||
delegate = self._require()
|
||||
method = getattr(delegate, "campaign_imap_batch", None)
|
||||
if not callable(method):
|
||||
yield None
|
||||
return
|
||||
try:
|
||||
with method(tenant_id=tenant_id, campaign_id=campaign_id) as state:
|
||||
yield state
|
||||
except getattr(delegate, "ImapAppendError", ImapAppendError) as exc:
|
||||
raise ImapAppendError(
|
||||
str(exc),
|
||||
temporary=getattr(exc, "temporary", None),
|
||||
outcome_unknown=bool(getattr(exc, "outcome_unknown", False)),
|
||||
) from exc
|
||||
except getattr(delegate, "ImapConfigurationError", ImapConfigurationError) as exc:
|
||||
raise ImapConfigurationError(str(exc)) from exc
|
||||
except getattr(delegate, "MailProfileError", MailProfileError) as exc:
|
||||
raise MailProfileError(str(exc)) from exc
|
||||
|
||||
def append_campaign_message_to_sent(self, *args: Any, **kwargs: Any) -> Any:
|
||||
delegate = self._require()
|
||||
try:
|
||||
@@ -527,6 +590,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 +602,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 +640,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 +913,7 @@ def templates_integration() -> TemplatesCampaignIntegration:
|
||||
return TemplatesCampaignIntegration(
|
||||
capability(TEMPLATE_CATALOG_CAPABILITY),
|
||||
capability(TEMPLATE_RENDERER_CAPABILITY),
|
||||
capability(TEMPLATE_CONTENT_LIBRARY_CAPABILITY),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.modules import with_documentation_structured_translations
|
||||
from govoplan_campaign.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_core.core.access import (
|
||||
@@ -13,6 +16,8 @@ from govoplan_core.core.campaigns import (
|
||||
CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT,
|
||||
CAPABILITY_CAMPAIGNS_POLICY_CONTEXT,
|
||||
CAPABILITY_CAMPAIGNS_RETENTION,
|
||||
CAPABILITY_CAMPAIGNS_SCHEDULES,
|
||||
CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION,
|
||||
)
|
||||
from govoplan_core.core.calendar import CAPABILITY_CALENDAR_INVITATIONS
|
||||
from govoplan_core.core.module_guards import (
|
||||
@@ -34,6 +39,8 @@ from govoplan_core.core.modules import (
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
ProductAreaContribution,
|
||||
QuickAccessTool,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.reporting import REPORT_PROVIDER_CAPABILITY_PREFIX
|
||||
@@ -43,9 +50,14 @@ 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
|
||||
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH
|
||||
from govoplan_core.core.idm import CAPABILITY_IDM_FUNCTION_ASSIGNMENTS
|
||||
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY
|
||||
from govoplan_core.core.tasks import CAPABILITY_TASK_COMMANDS
|
||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
from govoplan_core.db.base import Base
|
||||
@@ -62,11 +74,25 @@ from govoplan_campaign.backend.documentation import (
|
||||
CAMPAIGN_USER_DOCUMENTATION,
|
||||
documentation_topics,
|
||||
)
|
||||
from govoplan_campaign.backend.german_documentation import (
|
||||
localize_documentation_topics,
|
||||
)
|
||||
from govoplan_campaign.backend.dsar_provider import CAMPAIGN_DSAR_CAPABILITY
|
||||
from govoplan_campaign.backend.search_source import create_campaign_search_source
|
||||
from govoplan_campaign.backend.workflow_definitions import (
|
||||
campaign_workflow_definitions,
|
||||
)
|
||||
|
||||
register_campaign_change_tracking()
|
||||
|
||||
|
||||
def _dsar_provider(context: ModuleContext) -> object:
|
||||
del context
|
||||
from govoplan_campaign.backend.dsar_provider import CampaignDsarProvider
|
||||
|
||||
return CampaignDsarProvider()
|
||||
|
||||
|
||||
def _permission(
|
||||
scope: str, label: str, description: str, category: str
|
||||
) -> PermissionDefinition:
|
||||
@@ -90,6 +116,42 @@ PERMISSIONS = (
|
||||
"Open campaign metadata, versions and permitted message summaries.",
|
||||
"Campaigns",
|
||||
),
|
||||
_permission(
|
||||
"campaigns:discussion:read",
|
||||
"View campaign discussions",
|
||||
"Read human collaboration entries attached to campaigns the user can already access.",
|
||||
"Campaign collaboration",
|
||||
),
|
||||
_permission(
|
||||
"campaigns:discussion:post",
|
||||
"Post campaign discussions",
|
||||
"Post and withdraw the user's own append-only campaign collaboration entries.",
|
||||
"Campaign collaboration",
|
||||
),
|
||||
_permission(
|
||||
"campaigns:discussion:moderate",
|
||||
"Moderate campaign discussions",
|
||||
"Read moderator-only entries and redact campaign collaboration content while retaining tombstones.",
|
||||
"Campaign collaboration",
|
||||
),
|
||||
_permission(
|
||||
"campaigns:assignment:read",
|
||||
"View campaign work assignments",
|
||||
"Read accountable work assignments for campaigns the user can already access.",
|
||||
"Campaign work",
|
||||
),
|
||||
_permission(
|
||||
"campaigns:assignment:manage",
|
||||
"Manage campaign work assignments",
|
||||
"Create, reassign, cancel, and reconcile authorization-neutral campaign work assignments, including Workflow-opened hand-offs.",
|
||||
"Campaign work",
|
||||
),
|
||||
_permission(
|
||||
"campaigns:assignment:complete",
|
||||
"Complete assigned campaign work",
|
||||
"Accept, complete, or reject campaign work assigned to the current account, group, or organization function.",
|
||||
"Campaign work",
|
||||
),
|
||||
_permission(
|
||||
"campaigns:campaign:create",
|
||||
"Create campaigns",
|
||||
@@ -108,6 +170,24 @@ PERMISSIONS = (
|
||||
"Create campaigns or working versions from existing campaigns.",
|
||||
"Campaigns",
|
||||
),
|
||||
_permission(
|
||||
"campaigns:campaign:export",
|
||||
"Export portable campaigns",
|
||||
"Create integrity-protected portable Campaign packages with explicitly selected data scopes.",
|
||||
"Campaigns",
|
||||
),
|
||||
_permission(
|
||||
"campaigns:campaign:import",
|
||||
"Import portable campaigns",
|
||||
"Preview and create new Campaign drafts from compatible portable packages.",
|
||||
"Campaigns",
|
||||
),
|
||||
_permission(
|
||||
"campaigns:campaign:schedule",
|
||||
"Schedule campaigns",
|
||||
"Prepare manual drafts or opt in to approved autonomous Mail delivery at a governed time or bounded recurrence.",
|
||||
"Campaigns",
|
||||
),
|
||||
_permission(
|
||||
"campaigns:campaign:archive",
|
||||
"Archive campaigns",
|
||||
@@ -150,6 +230,12 @@ PERMISSIONS = (
|
||||
"Build exact messages and attachment evidence.",
|
||||
"Campaigns",
|
||||
),
|
||||
_permission(
|
||||
"campaigns:archive:use_legacy_zipcrypto",
|
||||
"Use legacy ZipCrypto",
|
||||
"Explicitly select weak Windows-compatible ZipCrypto when the effective policy permits it.",
|
||||
"Campaign governance",
|
||||
),
|
||||
_permission(
|
||||
"campaigns:campaign:review",
|
||||
"Complete campaign review",
|
||||
@@ -274,9 +360,18 @@ ROLE_TEMPLATES = (
|
||||
"campaigns:campaign:create",
|
||||
"campaigns:campaign:update",
|
||||
"campaigns:campaign:copy",
|
||||
"campaigns:campaign:export",
|
||||
"campaigns:campaign:import",
|
||||
"campaigns:campaign:schedule",
|
||||
"campaigns:campaign:validate",
|
||||
"campaigns:campaign:build",
|
||||
"campaigns:ownership:accept_group",
|
||||
"campaigns:discussion:read",
|
||||
"campaigns:discussion:post",
|
||||
"campaigns:discussion:moderate",
|
||||
"campaigns:assignment:read",
|
||||
"campaigns:assignment:manage",
|
||||
"campaigns:assignment:complete",
|
||||
"campaigns:recipient:read",
|
||||
"campaigns:recipient:write",
|
||||
"campaigns:recipient:import",
|
||||
@@ -291,6 +386,10 @@ ROLE_TEMPLATES = (
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:campaign:validate",
|
||||
"campaigns:campaign:review",
|
||||
"campaigns:discussion:read",
|
||||
"campaigns:discussion:post",
|
||||
"campaigns:assignment:read",
|
||||
"campaigns:assignment:complete",
|
||||
"campaigns:recipient:read",
|
||||
"campaigns:report:read",
|
||||
),
|
||||
@@ -307,6 +406,10 @@ ROLE_TEMPLATES = (
|
||||
"campaigns:campaign:send",
|
||||
"campaigns:campaign:retry",
|
||||
"campaigns:campaign:reconcile",
|
||||
"campaigns:discussion:read",
|
||||
"campaigns:discussion:post",
|
||||
"campaigns:assignment:read",
|
||||
"campaigns:assignment:complete",
|
||||
"campaigns:diagnostic:read",
|
||||
"campaigns:recipient:read",
|
||||
"campaigns:report:read",
|
||||
@@ -357,10 +460,13 @@ def _campaigns_router(context: ModuleContext):
|
||||
return aggregate
|
||||
|
||||
|
||||
MODULE_VERSION = "0.1.29"
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id="campaigns",
|
||||
name="Campaigns",
|
||||
version="0.1.15",
|
||||
version=MODULE_VERSION,
|
||||
workflow_definitions=campaign_workflow_definitions(module_version=MODULE_VERSION),
|
||||
required_capabilities=(
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
@@ -368,6 +474,10 @@ manifest = ModuleManifest(
|
||||
optional_capabilities=(
|
||||
CAPABILITY_ACCESS_REFERENCE_OPTIONS,
|
||||
CAPABILITY_APPROVAL_REQUESTS,
|
||||
CAPABILITY_NOTIFICATIONS_DISPATCH,
|
||||
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
|
||||
CAPABILITY_ORGANIZATION_DIRECTORY,
|
||||
CAPABILITY_TASK_COMMANDS,
|
||||
),
|
||||
optional_dependencies=(
|
||||
"files",
|
||||
@@ -381,17 +491,26 @@ manifest = ModuleManifest(
|
||||
"approvals",
|
||||
"reporting",
|
||||
"search",
|
||||
"organizations",
|
||||
"idm",
|
||||
"tasks",
|
||||
),
|
||||
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.2.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"),
|
||||
ModuleInterfaceProvider(
|
||||
name="campaigns.work_orchestration",
|
||||
version="1.0.0",
|
||||
),
|
||||
ModuleInterfaceProvider(
|
||||
name=REPORT_PROVIDER_CAPABILITY_PREFIX + "campaigns",
|
||||
version="1.0.0",
|
||||
),
|
||||
ModuleInterfaceProvider(name=CAMPAIGN_DSAR_CAPABILITY, version="0.1.0"),
|
||||
),
|
||||
requires_interfaces=(
|
||||
ModuleInterfaceRequirement(
|
||||
@@ -454,6 +573,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",
|
||||
@@ -484,6 +609,30 @@ manifest = ModuleManifest(
|
||||
version_max_exclusive="0.2.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name=CAPABILITY_NOTIFICATIONS_DISPATCH,
|
||||
version_min="0.1.0",
|
||||
version_max_exclusive="0.2.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name=CAPABILITY_ORGANIZATION_DIRECTORY,
|
||||
version_min="0.1.0",
|
||||
version_max_exclusive="0.2.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name=CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
|
||||
version_min="0.1.0",
|
||||
version_max_exclusive="0.2.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name=CAPABILITY_TASK_COMMANDS,
|
||||
version_min="1.0.0",
|
||||
version_max_exclusive="2.0.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name="search.source",
|
||||
version_min="1.0.0",
|
||||
@@ -560,7 +709,48 @@ manifest = ModuleManifest(
|
||||
order=20,
|
||||
),
|
||||
),
|
||||
product_areas=(
|
||||
ProductAreaContribution(
|
||||
id="communication",
|
||||
module_id="campaigns",
|
||||
label="i18n:govoplan-core.product_area.communication",
|
||||
icon="mail",
|
||||
description="i18n:govoplan-core.product_area.communication_description",
|
||||
surface_ids=(
|
||||
"campaigns.nav.campaigns",
|
||||
"campaigns.route.campaigns",
|
||||
"campaigns.route.operator-redirect",
|
||||
OPERATOR_QUEUE_SURFACE_ID,
|
||||
REPORTS_SURFACE_ID,
|
||||
"campaigns.page.work",
|
||||
"campaigns.page.activity",
|
||||
),
|
||||
order=40,
|
||||
),
|
||||
),
|
||||
view_surfaces=(
|
||||
ViewSurface(
|
||||
id="campaigns.admin.system-delivery", module_id="campaigns",
|
||||
kind="section", label="System Campaign delivery", order=76,
|
||||
),
|
||||
ViewSurface(
|
||||
id="campaigns.admin.tenant-delivery", module_id="campaigns",
|
||||
kind="section", label="Tenant Campaign delivery", order=76,
|
||||
),
|
||||
ViewSurface(
|
||||
id="campaigns.page.work",
|
||||
module_id="campaigns",
|
||||
kind="page",
|
||||
label="Campaign work",
|
||||
order=44,
|
||||
),
|
||||
ViewSurface(
|
||||
id="campaigns.page.activity",
|
||||
module_id="campaigns",
|
||||
kind="page",
|
||||
label="Campaign collaboration",
|
||||
order=45,
|
||||
),
|
||||
ViewSurface(
|
||||
id="campaigns.widget.activity",
|
||||
module_id="campaigns",
|
||||
@@ -568,6 +758,32 @@ manifest = ModuleManifest(
|
||||
label="Campaign activity widget",
|
||||
order=50,
|
||||
),
|
||||
ViewSurface(
|
||||
id="campaigns.quick_access.campaigns",
|
||||
module_id="campaigns",
|
||||
kind="quick_access",
|
||||
label="Campaign selection",
|
||||
order=55,
|
||||
),
|
||||
),
|
||||
quick_access_tools=(
|
||||
QuickAccessTool(
|
||||
id="campaigns.select",
|
||||
module_id="campaigns",
|
||||
category_id="campaigns",
|
||||
label="Campaigns",
|
||||
description="Select an authorized exact Campaign for the active Case.",
|
||||
surface_id="campaigns.quick_access.campaigns",
|
||||
icon="campaign",
|
||||
full_page_path="/campaigns",
|
||||
required_any=("campaigns:campaign:read",),
|
||||
order=20,
|
||||
modes=("select",),
|
||||
availability="active_object",
|
||||
accepted_reference_kinds=("cases.case",),
|
||||
returned_reference_kinds=("campaigns.campaign",),
|
||||
help_context_id="campaigns.quick_access.campaigns",
|
||||
),
|
||||
),
|
||||
),
|
||||
migration_spec=MigrationSpec(
|
||||
@@ -577,7 +793,10 @@ 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.CampaignCollaborationEntry,
|
||||
campaign_models.RecipientImportMappingProfile,
|
||||
campaign_models.CampaignVersion,
|
||||
campaign_models.CampaignJob,
|
||||
@@ -597,7 +816,10 @@ manifest = ModuleManifest(
|
||||
uninstall_guard_providers=(
|
||||
persistent_table_uninstall_guard(
|
||||
campaign_models.Campaign,
|
||||
campaign_models.CampaignSchedule,
|
||||
campaign_models.CampaignScheduleOccurrence,
|
||||
campaign_models.CampaignShare,
|
||||
campaign_models.CampaignCollaborationEntry,
|
||||
campaign_models.RecipientImportMappingProfile,
|
||||
campaign_models.CampaignVersion,
|
||||
campaign_models.CampaignJob,
|
||||
@@ -613,8 +835,314 @@ manifest = ModuleManifest(
|
||||
label="Campaigns",
|
||||
),
|
||||
),
|
||||
documentation=(
|
||||
documentation=localize_documentation_topics((
|
||||
DocumentationTopic(
|
||||
id="campaigns.attachment-filename-fidelity",
|
||||
title="Deterministic attachment and ZIP names",
|
||||
summary="Resolve repeated names efficiently without dropping or reordering attachments.",
|
||||
body="Message attachments and ZIP members share a first-free suffix allocator. Repeated names retain the established case-insensitive collision rule and exact numbered suffixes, including names already containing suffixes, Unicode case folding and multiple extensions. Each message/archive has independent allocation state. Large groups of identical requested names no longer restart every suffix search from two. This changes naming work only: intended attachment bytes, recipients, order, existing duplicate-file review policy and ZIP encryption remain unchanged.",
|
||||
layer="available", documentation_types=("user", "admin"), audience=("campaign_manager", "campaign_admin"), order=18,
|
||||
conditions=(DocumentationCondition(required_modules=("campaigns",), any_scopes=("campaigns:campaign:read", "campaigns:campaign:write")),),
|
||||
links=(DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),),
|
||||
translations={"de": {
|
||||
"title": "Deterministische Namen für Anhänge und ZIP-Einträge",
|
||||
"summary": "Wiederholte Namen effizient auflösen, ohne Anhänge auszulassen oder umzuordnen.",
|
||||
"body": "Nachrichtenanhänge und ZIP-Einträge verwenden dieselbe Vergabe des ersten freien nummerierten Suffixes. Die bisherige groß-/kleinschreibungsunabhängige Kollisionsregel und exakte Nummerierung bleiben erhalten, auch bei vorhandenen Nummernsuffixen, Unicode-Groß-/Kleinschreibung und mehrfachen Erweiterungen. Jede Nachricht und jedes Archiv besitzt einen getrennten Vergabezustand. Große Gruppen gleicher gewünschter Namen beginnen die Suffixsuche nicht mehr jeweils bei zwei. Nur der Suchaufwand ändert sich: vorgesehene Bytes, Empfänger, Reihenfolge, bestehende Prüfung mehrfach verwendeter Dateien und ZIP-Verschlüsselung bleiben unverändert.",
|
||||
}},
|
||||
),
|
||||
*CAMPAIGN_USER_DOCUMENTATION,
|
||||
DocumentationTopic(
|
||||
id="campaigns.workflow.link-exact-campaign-to-case",
|
||||
title="Link an exact Campaign reference to an active Case",
|
||||
summary=(
|
||||
"Return an authorized Campaign and current immutable version through "
|
||||
"Quick Access without copying campaign content."
|
||||
),
|
||||
body=(
|
||||
"When a Case is the active object, Campaigns contributes a bounded "
|
||||
"Quick Access selector. The normal Campaign list endpoint applies the "
|
||||
"current actor's tenant, owner, group, share, and administration access "
|
||||
"before candidates appear. Selecting a Campaign returns only its owner "
|
||||
"module, stable Campaign ID, current version ID, display label, tenant, "
|
||||
"and owner route through the versioned result contract. Cases discards "
|
||||
"the label and stores no recipient, message, attachment, delivery, report, "
|
||||
"or campaign configuration content. Opening the reference enters Campaigns "
|
||||
"and rechecks current access. Campaigns disabled, access revoked, or the "
|
||||
"source removed therefore leaves only an unavailable historical Case "
|
||||
"reference; it never turns Case access into Campaign access."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("campaign_manager", "case_manager", "operator", "module_admin"),
|
||||
related_modules=("cases", "quick_access"),
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("campaigns", "cases"),
|
||||
required_scopes=("campaigns:campaign:read",),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Campaigns",
|
||||
href="/campaigns",
|
||||
kind="runtime",
|
||||
),
|
||||
),
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"help_contexts": ["campaigns.quick_access.campaigns"],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="campaigns.admin.portable-transfer-governance",
|
||||
title="Govern portable Campaign export and import",
|
||||
summary="Separate configuration portability from recipient and delivery-data export, and verify every import as a new draft.",
|
||||
body=(
|
||||
"Portable Campaign export and import use separate campaign-level permissions. The built-in Campaign manager can move configuration, but recipient rows additionally require recipient read/export on export and recipient write/import on import. Review-state export requires report read; recipient-level delivery history requires report export plus recipient read/export. The UI and API default export to metadata plus template/configuration only. Every package records its format, source Campaign/version, selected scopes, item counts, redaction counts, and SHA-256 integrity digest. Campaign removes transport secrets, credential-envelope references, password-field values, infrastructure paths, and attachment bytes. Import fails closed on format, checksum, schema, scope, or destination-ID conflicts; its preview identifies every created and skipped domain. It always creates a separately owned draft, clears deployment-bound Mail references, and never recreates shares, locks, approvals, review decisions, delivery jobs, attempts, or sent state. The destination retains a bounded import receipt and matching Audit evidence. Operators must govern downloaded package storage and deletion outside GovOPlaN according to the selected data scopes."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin",),
|
||||
audience=("module_admin", "security_reviewer", "privacy_officer", "campaign_manager"),
|
||||
order=40,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("campaigns",),
|
||||
any_scopes=(
|
||||
"campaigns:campaign:export",
|
||||
"campaigns:campaign:import",
|
||||
"access:roles:manage",
|
||||
),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),
|
||||
DocumentationLink(
|
||||
label="Campaign handbook",
|
||||
href="govoplan-campaign/docs/CAMPAIGN_HANDBOOK.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
related_modules=("access", "audit", "files", "mail"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Portablen Campaign-Export und -Import steuern",
|
||||
"summary": "Konfigurationsportabilitaet vom Export von Empfaenger- und Zustelldaten trennen und jeden Import als neuen Entwurf pruefen.",
|
||||
"body": (
|
||||
"Portabler Campaign-Export und -Import verwenden getrennte Campaign-Berechtigungen. Empfaengerzeilen erfordern beim Export zusaetzlich Empfaenger-Lese- und Exportrecht sowie beim Import Empfaenger-Schreib- und Importrecht. Pruefstatus erfordert Berichtsleserecht; Zustellhistorie erfordert Berichtsexport sowie Empfaenger-Lese- und Exportrecht. Standardmaessig werden nur Metadaten sowie Vorlage und Konfiguration exportiert. Jedes Paket enthaelt Format, Quelle, ausgewaehlte Umfaenge, Zaehler, Redaktionen und SHA-256-Integritaet. Transportgeheimnisse, Zugangsdatenverweise, Passwortfeldwerte, Infrastrukturpfade und Dateiinhalte werden entfernt. Der Import schlaegt bei Format-, Pruefsummen-, Schema-, Umfangs- oder Kennungskonflikten geschlossen fehl und erstellt immer einen eigenstaendigen Entwurf. Freigaben, Sperren, Genehmigungen, Pruefentscheidungen, Zustellauftraege und Sendezustaende werden nie wiedergegeben."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "configuration",
|
||||
"route": "/campaigns",
|
||||
"screen": "Campaign portable transfer",
|
||||
"help_contexts": [
|
||||
"campaigns.action.export-package",
|
||||
"campaigns.action.import-package",
|
||||
],
|
||||
"permission_scopes": [
|
||||
"campaigns:campaign:export",
|
||||
"campaigns:campaign:import",
|
||||
"campaigns:recipient:read",
|
||||
"campaigns:recipient:write",
|
||||
"campaigns:recipient:import",
|
||||
"campaigns:recipient:export",
|
||||
"campaigns:report:read",
|
||||
"campaigns:report:export",
|
||||
],
|
||||
"privacy_default_scopes": ["metadata", "template_config"],
|
||||
"verification": "Export the default scopes as a Campaign manager, verify a recipient scope is denied without recipient-export, tamper with the JSON and verify preview rejects it, then import a valid package and confirm a new draft plus matching Audit hashes without jobs or approval state.",
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="campaigns.admin.collaboration-governance",
|
||||
title="Govern Campaign collaboration permissions and retention",
|
||||
summary="Configure discussion access separately from Campaign editing and retain auditable moderation tombstones.",
|
||||
body=(
|
||||
"Campaign collaboration uses separate read, post, and moderate permissions in addition to parent Campaign read access. "
|
||||
"The built-in manager role can moderate, while reviewer and sender roles can read and post without receiving Campaign edit permission. "
|
||||
"A read share is sufficient as the parent resource grant; comments never upgrade it to write access. Moderator-only visibility is filtered server-side. "
|
||||
"Posted content has no edit API. Author withdrawal and moderator redaction remove displayed content while retaining the stable entry, SHA-256 evidence, actor snapshot, timestamp, typed reference, tombstone, and bounded Audit event. "
|
||||
"Mention targets are restricted to active users who already have Campaign ownership or share access. If the optional Notifications dispatch capability is available, Campaign emits content-free inbox notifications; provider failure does not make Notifications a required Campaign dependency. "
|
||||
"Operators must apply institutional retention and privacy policy to collaboration rows and Audit evidence together and must not represent comments as approvals, workflow transitions, or system events."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin",),
|
||||
audience=("campaign_manager", "module_admin", "privacy_officer", "records_manager"),
|
||||
order=41,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("campaigns",),
|
||||
any_scopes=(
|
||||
"campaigns:discussion:moderate",
|
||||
"access:roles:manage",
|
||||
),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Campaign collaboration",
|
||||
href="/campaigns",
|
||||
kind="runtime",
|
||||
),
|
||||
DocumentationLink(
|
||||
label="Campaign handbook",
|
||||
href="govoplan-campaign/docs/CAMPAIGN_HANDBOOK.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
related_modules=("access", "audit", "notifications"),
|
||||
metadata={
|
||||
"kind": "configuration",
|
||||
"route": "/campaigns/{campaign_id}/activity",
|
||||
"screen": "Campaign collaboration",
|
||||
"help_contexts": [
|
||||
"campaign.activity",
|
||||
"campaign.activity.composer",
|
||||
"campaign.activity.action.post",
|
||||
"campaign.activity.action.withdraw",
|
||||
"campaign.activity.action.redact",
|
||||
],
|
||||
"permission_scopes": [
|
||||
"campaigns:discussion:read",
|
||||
"campaigns:discussion:post",
|
||||
"campaigns:discussion:moderate",
|
||||
],
|
||||
"verification": "Test a read-only collaborator, a poster without Campaign edit, and a moderator; confirm moderator visibility, mention access filtering, tombstones, and Audit records.",
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="campaigns.privacy.data-subject-requests",
|
||||
title="Review Campaign data in a data-subject request",
|
||||
summary="Collect recipient, collaboration, version, delivery, report, and artifact metadata without rewriting immutable evidence.",
|
||||
body=(
|
||||
"Campaign's DSAR provider searches the effective tenant by normalized recipient email, direct membership references, and namespaced Campaign job, entry, version, or Campaign references. "
|
||||
"It isolates matching inline-recipient fields and job metadata, and reports built versions, delivery attempts, Postbox and print outcomes, message-action corrections, recipient-specific report projections, generated-message digests, attachment metadata, and collaboration entries authored, mentioned, or moderated by the subject. Authored collaboration text is included; text authored by somebody else is not copied merely because the subject was mentioned. It does not export EML bytes, object or local paths, provider target snapshots, worker claims, idempotency material, secrets, credentials, or unrelated recipient addresses. "
|
||||
"Built, locked, published, terminal, delivered, corrected, withdrawn, or redacted records remain retained with a reason and continue through Campaign's configured retention/redaction process. Collaboration tombstones, hashes, and Audit evidence remain immutable. Draft recipient content and user-owned attachment content require coordinated manual review because the same data may occur in version JSON, jobs, and generated artifacts. The provider can idempotently delete a personal recipient-import mapping profile and revoke an active Campaign share aimed at the subject. It never rewrites delivered evidence or deletes generated artifacts directly. Campaign reports are derived projections rather than a separate personal-data store."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin",),
|
||||
audience=("privacy_officer", "campaign_manager", "records_manager", "operator"),
|
||||
order=42,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("campaigns", "access"),
|
||||
any_scopes=(
|
||||
"access:privacy:read",
|
||||
"access:privacy:manage",
|
||||
"access:privacy:erase",
|
||||
),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Data-subject requests",
|
||||
href="/admin?section=tenant-data-subject-requests",
|
||||
kind="runtime",
|
||||
),
|
||||
DocumentationLink(
|
||||
label="Campaign handbook",
|
||||
href="govoplan-campaign/docs/CAMPAIGN_HANDBOOK.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
related_modules=("access", "audit", "files", "mail", "postbox", "reporting"),
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"route": "/admin?section=tenant-data-subject-requests",
|
||||
"screen": "Data-subject requests",
|
||||
"help_contexts": ["admin.privacy.data-subject-requests"],
|
||||
"prerequisites": [
|
||||
"The privacy request and recipient selectors have been independently authorized and corroborated.",
|
||||
"The reviewer understands the effective Campaign retention policy and delivery-evidence obligations.",
|
||||
],
|
||||
"steps": [
|
||||
"Run the Campaign provider search and review recipient, version, job, attempt, report-projection, and artifact dispositions.",
|
||||
"Inspect matching draft content manually and keep every evidence retention reason with the case decision.",
|
||||
"Execute only an approved user-owned mapping deletion or subject-targeted share revocation.",
|
||||
"Use Campaign retention and artifact reconciliation for approved content redaction or expiry; do not mutate delivered evidence ad hoc.",
|
||||
],
|
||||
"limitations": [
|
||||
"Generated EML bytes and attachment content are not embedded in the JSON export; authorized Campaign or Files review paths remain authoritative.",
|
||||
"Draft recipient erasure is manual until a coordinated version/job/artifact rewrite contract can prove that no partial copy remains.",
|
||||
],
|
||||
"outcome": "Campaign personal data receives an explicit retained, review, revoke, or delete disposition without weakening delivery evidence.",
|
||||
"verification": "Confirm matching recipients are isolated, no locator or credential material appears, report counts derive from the same matched jobs, and repeated reversible actions are unchanged.",
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="campaigns.access.child-evidence",
|
||||
title="Explain access to Campaign child evidence",
|
||||
summary="Trace governance, import, recipient, attachment, protocol, delivery, and reconciliation access without disclosing the protected payload.",
|
||||
body=(
|
||||
"Campaign child explanations first identify the parent Campaign and immutable version, then state whether owner, "
|
||||
"group, share, or tenant administration provides the inherited boundary. Recipient evidence additionally requires "
|
||||
"recipient-read authority; review and attachment overrides require review and diagnostic authority; delivery status, "
|
||||
"diagnostics, exports, and reconciliation remain separately permissioned. Version-bound children use a version UUID "
|
||||
"and random job UUID. Missing, cross-tenant, and stale references return non-disclosing provenance. Explanations expose "
|
||||
"bounded state and hashes only, never recipient addresses, source rows, filenames, storage locators, transport responses, "
|
||||
"worker claims, provider targets, or operator notes. Persisted Mail, Postbox, and printable attempt evidence remains "
|
||||
"explainable after an optional provider is disabled; an absent child reports only unavailable-or-hidden. Campaign share "
|
||||
"and ownership-transfer records describe governance visibility separately from the content grant. Saved imports expose "
|
||||
"only their stable source identity and revision, while independently user-owned mapping profiles never inherit a Campaign "
|
||||
"share. Persisted validation, build, snapshot, and review artifacts use typed version references. Reusable templates and "
|
||||
"durable export packages stay with their optional owning modules and fail closed when Campaign is asked to explain them. "
|
||||
"The shared explanation dialog evaluates the signed-in user by default. Policy may permit a tenant-bounded selected-user "
|
||||
"administrator diagnostic; Access returns only permitted subject metadata and records every cross-user explanation in audit evidence."
|
||||
),
|
||||
layer="available",
|
||||
documentation_types=("admin",),
|
||||
audience=("administrator", "security_reviewer", "campaign_operator"),
|
||||
order=43,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("campaigns",),
|
||||
any_scopes=(
|
||||
"campaigns:diagnostic:read",
|
||||
"campaigns:report:read",
|
||||
"campaigns:recipient:read",
|
||||
"admin:users:read",
|
||||
),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Campaign access-explanation coverage",
|
||||
href="govoplan-campaign/docs/ACCESS_EXPLANATION_COVERAGE.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
related_modules=("access", "audit", "mail", "policy", "postbox", "templates"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Zugriff auf untergeordnete Campaign-Nachweise erklaeren",
|
||||
"summary": "Zugriff auf Governance-, Import-, Empfaenger-, Anlagen-, Protokoll-, Zustell- und Abgleichnachweise ohne Offenlegung der geschuetzten Inhalte nachvollziehen.",
|
||||
"body": (
|
||||
"Zugriffserklaerungen fuer untergeordnete Campaign-Nachweise nennen zuerst die uebergeordnete Campaign und "
|
||||
"die unveraenderliche Version. Danach zeigen sie, ob Eigentum, Gruppe, Freigabe oder Mandantenadministration "
|
||||
"die geerbte Grenze begruendet. Empfaengernachweise erfordern zusaetzlich Leserecht fuer Empfaenger; Pruef- und "
|
||||
"Anlagenausnahmen erfordern Pruef- und Diagnoserecht. Zustellstatus, Diagnostik, Export und Abgleich bleiben getrennt "
|
||||
"berechtigt. Versionsgebundene Nachweise verwenden Versions-UUID und zufaellige Auftrags-UUID. Fehlende, mandantenfremde "
|
||||
"oder veraltete Verweise liefern keine geschuetzten Daten. Adressen, Quellzeilen, Dateinamen, Speicherorte, Transportantworten, "
|
||||
"Worker-Claims, Anbieterziele und Bediennotizen werden nie offengelegt. Dauerhafte Mail-, Postbox- und Drucknachweise bleiben "
|
||||
"auch nach Deaktivierung eines optionalen Anbieters erklaerbar; ein fehlender Nachweis meldet nur nicht verfuegbar oder verborgen."
|
||||
" Campaign-Freigaben und Eigentumsuebertragungen trennen Governance-Sichtbarkeit vom Inhaltszugriff. Gespeicherte Importe "
|
||||
"nennen nur stabile Quellidentitaet und Revision; benutzereigene Zuordnungsprofile erben keine Campaign-Freigabe. "
|
||||
"Validierungs-, Build-, Snapshot- und Pruefnachweise verwenden typisierte Versionsverweise. Wiederverwendbare Vorlagen "
|
||||
"und dauerhafte Exportpakete bleiben bei ihren optionalen Eigentuemer-Modulen und werden sonst geschlossen behandelt."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"help_contexts": [
|
||||
"campaign.access",
|
||||
"campaign.import",
|
||||
"campaign.report",
|
||||
"campaign.operator-queue",
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="campaigns.search.campaigns",
|
||||
title="Search authorized campaigns",
|
||||
@@ -694,7 +1222,11 @@ manifest = ModuleManifest(
|
||||
id="campaigns.mail-profile-user-journey",
|
||||
title="Choose a Mail profile for campaign delivery",
|
||||
summary="Campaigns reference an authorized Mail profile and never store SMTP/IMAP settings or credentials.",
|
||||
body="Open the campaign Mail settings, select an available profile, test it through Mail, and save. Validation and delivery recheck profile authorization. A changed transport identity requires a new validation and build.",
|
||||
body=(
|
||||
"Mail settings → Reusable mail profile → SMTP credential (and IMAP credential when used) records an explicit credential identifier. An empty selection means inheritance only if Mail policy permits it; a displayed profile default is not a stored campaign choice. Missing or inactive saved profiles, servers and credentials remain visibly unavailable rather than being silently replaced. "
|
||||
"Open the campaign Mail settings, select an available profile, test it through Mail, and save. If legacy campaign-local transport data is reported, use Migrate selected Mail profile after selecting an authorized profile; this explicit action also works when the existing selection is unchanged and the draft is clean. Locked historical evidence is retained: follow the version's unlock or editable-successor action before migrating. Review and send displays the migration blocker and links back to Mail settings instead of repeatedly requesting an invalid attachment preview. The profile selector loads only campaign-authorized profiles; a separate administrative policy-list failure does not empty it. Validation and delivery recheck profile authorization. Migration saves configuration but never sends mail; validate, build, and review the resulting version again before delivery."
|
||||
" Mail migration and ZIP policy corrections can be saved in either order. Unchanged ZIP settings do not block migration or acquire new consent evidence. An archive or content correction with unchanged Mail references preserves the old transport server-side and retains the migration notice; no implicit migration occurs. A committed save and the following workspace refresh are separate outcomes: failed refresh keeps the last usable same-campaign/version data and displays an error. Retry Reload; obsolete responses from another campaign, version, identity or earlier refresh cannot replace the current workspace."
|
||||
),
|
||||
layer="available",
|
||||
documentation_types=("user",),
|
||||
audience=("campaign_manager", "campaign_reviewer", "campaign_sender"),
|
||||
@@ -732,11 +1264,12 @@ manifest = ModuleManifest(
|
||||
"steps": [
|
||||
"Open the campaign and go to Mail settings.",
|
||||
"Select an available Mail profile; Campaign stores only its stable identifier.",
|
||||
"If migration is required, choose Migrate selected Mail profile even if the selected profile has not changed. For a locked version, first follow its supported unlock or editable-successor action.",
|
||||
"Test SMTP and, when configured, IMAP through the Mail module.",
|
||||
"Save, validate, and build the campaign before queueing delivery.",
|
||||
],
|
||||
"outcome": "The editable campaign version references an authorized Mail-owned delivery profile without copying transport settings or credentials.",
|
||||
"verification": "Reopen Mail settings, confirm the selected profile, then run validation and verify that the build completes without profile-drift errors.",
|
||||
"verification": "Reopen Mail settings and confirm the selected profile and absence of the migration notice; then validate, build, and review. Saving or migrating must not create a delivery effect.",
|
||||
"related_topic_ids": [
|
||||
"campaigns.mail-profile-governance",
|
||||
"campaigns.mail-profile-operations",
|
||||
@@ -748,7 +1281,11 @@ manifest = ModuleManifest(
|
||||
id="campaigns.mail-profile-governance",
|
||||
title="Govern Campaign-to-Mail profile references",
|
||||
summary="Mail owns transport definitions and encrypted credentials; Campaign owns only the selected profile reference and delivery evidence.",
|
||||
body="Grant mail:profile:use to campaign authors, constrain profile availability through Mail policy, and keep effective credential inheritance enabled. Inline transport fields are rejected. Legacy records remain unchanged until an explicit, audited profile migration creates or updates an editable version.",
|
||||
body=(
|
||||
"Grant mail:profile:use to campaign authors, constrain profile availability through Mail policy, and explicitly configure whether profile credentials may be inherited or a campaign must select a Mail-owned credential. SMTP/IMAP credential inheritance controls appear on the Mail policy page, with local, inherited and effective values and ancestor locks. Inline transport fields are rejected and are never returned with credentials to the browser. Legacy transport remains unchanged until an explicit, audited profile migration creates or updates an editable version; selecting the same already referenced profile is sufficient when the operator uses Migrate selected Mail profile. Campaign Mail settings request only the usable campaign-scoped profile list; administrative Mail policy enumeration is a separate request on the Mail policy page and its errors remain local to that surface. This separation does not grant profile administration or bypass owner, tenant, or Mail authorization. Migration never sends mail or restores an old execution snapshot."
|
||||
" Independent draft corrections may retain the exact stored legacy server object only while public Mail references remain unchanged and no inline transport is submitted. This saves content without selecting or using a profile, even if its authorization was revoked. Explicit migration still requires mail:profile:use and current Mail policy. Version-save audit details distinguish legacy_mail_settings_preserved from legacy_mail_settings_migrated. Unchanged ZIP configuration is not re-acknowledged; modified ZIP settings retain full policy checks. Successful repair saves invalidate prior build/execution evidence and do not relax validation, review or delivery."
|
||||
" Already migrated drafts follow the same unchanged-selection rule: a later requirement for explicit SMTP/IMAP credentials does not prevent saving unrelated archive or content corrections. Changing any selected Mail resource still enforces Mail permission and current policy, and authoritative validation/delivery always recheck the full selection."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin",),
|
||||
audience=("tenant_admin", "mail_admin", "campaign_admin"),
|
||||
@@ -796,7 +1333,7 @@ manifest = ModuleManifest(
|
||||
id="campaigns.mail-profile-operations",
|
||||
title="Operate profile-backed campaign delivery",
|
||||
summary="Workers re-authorize and resolve Mail profiles at execution time while Campaign retains only opaque Mail-owned revisions and outcomes.",
|
||||
body="A legacy snapshot, unauthorized or inactive profile, profile-reference mismatch, or changed SMTP/IMAP transport revision stops delivery. Preserve the record, migrate or correct the profile selection, revalidate, rebuild, and only then queue again. Password-only rotation remains possible without copying secrets into Campaign. Uncertain SMTP and IMAP effects remain blocked until an evidence-backed operator reconciliation. If Campaign becomes unavailable to the tenant after a job was accepted, the worker leaves the job untouched and reports an operator action instead of sending or dropping it.",
|
||||
body="SMTP and IMAP runtime actions enforce credential-selection requirements independently for their own protocol; SMTP calls do not need IMAP parameters and vice versa. Full campaign validation and build summaries continue checking both required selections. Preflight distinguishes Mail profile/credential policy from SMTP configuration, authentication, and connectivity failures; a successful server connection test does not replace campaign authorization. A legacy snapshot, unauthorized or inactive profile, profile-reference mismatch, or changed SMTP/IMAP transport revision stops delivery. Synchronous Mail batches preflight DNS, connectivity, TLS, and authentication before their first effect, reuse a bounded healthy SMTP connection, and reconnect before a later message when the old connection is stale. Review and send shows batch, connection, reconnect, failure, and pause counts. A systemic authentication, sender, or connectivity failure pauses remaining queued jobs with a stable reason code; correct and test the Mail profile before explicitly resuming. A connection loss after DATA begins stays outcome-unknown and is never replayed automatically. Preserve a stopped record, migrate or correct the profile selection, revalidate, rebuild, and only then queue again. Password-only rotation remains possible without copying secrets into Campaign. Uncertain SMTP and IMAP effects remain blocked until an evidence-backed operator reconciliation. If Campaign becomes unavailable to the tenant after a job was accepted, the worker leaves the job untouched and reports an operator action instead of sending or dropping it.",
|
||||
layer="configured",
|
||||
documentation_types=("admin",),
|
||||
audience=("campaign_sender", "campaign_operator", "mail_admin"),
|
||||
@@ -844,7 +1381,10 @@ manifest = ModuleManifest(
|
||||
id="campaigns.workflow.prepare-validate-and-build",
|
||||
title="Prepare, validate, and build a campaign",
|
||||
summary="Turn governed recipient, template, attachment, and Mail-profile inputs into exact built messages for review.",
|
||||
body="Prepare each input in its owning surface, resolve every blocking validation issue, and build exact recipient messages before review. Recipient data can activate or deactivate every currently opposite-state row as one explicitly confirmed draft change; saving it creates the normal Campaign version evidence and invalidates stale validation, build, and review state. New campaign credentials and password-valued fields offer the shared secure generator; its candidate remains separate until Use password is confirmed. Campaign freezes recipient and attachment evidence for the selected version; later source changes do not silently alter that build. When the Templates module is installed, its single Templates navigation entry owns the reusable library while campaign-specific composition remains in the campaign workspace.",
|
||||
body=(
|
||||
"Prepare each input in its owning surface, resolve every blocking validation issue, and build exact recipient messages before review. Recipient data can activate or deactivate every currently opposite-state row as one explicitly confirmed draft change; saving it creates the normal Campaign version evidence and invalidates stale validation, build, and review state. New campaign credentials and password-valued fields offer the shared secure generator; its candidate remains separate until Use password is confirmed. Campaign freezes recipient and attachment evidence for the selected version; later source changes do not silently alter that build. Summary metrics expose a named drill-down only when an authorized source collection, filtered review table, attachment preview, or report helps the user inspect and act on the count. Privacy-suppressed aggregate reports remain non-interactive because an unsuppressed subgroup would violate their disclosure boundary. When the Templates module is installed, its single Templates navigation entry owns the reusable library while campaign-specific composition remains in the campaign workspace."
|
||||
" In individual and global address dialogs, the up/down actions set the saved address order. Dialog Save applies that order to the campaign draft without alphabetically sorting it; duplicate email addresses retain their first position. Pasted addresses append in their entered order without rearranging existing addresses. The first individual To address remains the primary name/email shown in the recipient row. Save the campaign page to persist the updated draft; a failed page save retains the order for an explicit retry. Dialog Cancel deliberately discards only its unconfirmed edits."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("user",),
|
||||
audience=("campaign_manager", "campaign_author"),
|
||||
@@ -914,11 +1454,63 @@ manifest = ModuleManifest(
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="campaigns.archive-encryption-governance",
|
||||
title="Use governed password-protected ZIP attachments",
|
||||
summary="Use AES by default and select weak Windows-compatible ZipCrypto only with explicit policy, permission, acknowledgement, and evidence.",
|
||||
body=(
|
||||
"Campaign resolves archive encryption through Policy across system, tenant, owner user or group, and campaign scopes. Password-protected archives use AES unless the complete inherited policy permits Legacy ZipCrypto — Windows-compatible, weak encryption and the actor has campaigns:archive:use_legacy_zipcrypto. A legacy selection requires a reasoned acknowledgement. Passwords are never included in Campaign evidence or the campaign message and must be conveyed through the separately selected, policy-allowed channel. Each build freezes the archive and member hashes, implementation version, policy hash and source path, acknowledgement actor, reason and time, and build identity. A more restrictive later policy blocks queueing and sending until the campaign is rebuilt; Campaign never falls back from AES to ZipCrypto after an error. Temporary plaintext and archive material is confined to the bounded build directory and removed after success or failure."
|
||||
" Campaign Settings, Policies, and Attachments show the effective policy and direct authorized administrators to Administration → SYSTEM → Campaign archive encryption. Enable Legacy ZipCrypto there and Save; fresh system defaults are editable without creating an override merely by opening the page. Tenant and owner policies may still narrow the result. Back in Campaign, Reload archive policy, select Legacy ZipCrypto under Attachments → ZIP attachments, acknowledge its weak encryption, and give an operational reason of at least 10 characters. Policy unavailability keeps Legacy blocked. Neither a policy save nor an attachment configuration save sends mail."
|
||||
" Mail migration and archive corrections can be saved independently in either order. An exact unchanged ZIP configuration is retained without re-acknowledgement during an unrelated save, even after policy or permission revocation; it is not authorized for use. Any modified ZIP configuration must satisfy the current methods, password-delivery channels and legacy permission/acknowledgement requirements. An archive correction with unchanged public Mail references retains the exact legacy transport server-side until an explicit authorized migration. No client may introduce or echo inline transport, change Mail references under this exception, or fabricate acknowledgement evidence. Both repairs invalidate execution/build evidence; validation, review, building and delivery remain fail-closed until all conditions are satisfied."
|
||||
),
|
||||
documentation_types=("user", "admin"),
|
||||
audience=("campaign_manager", "campaign_reviewer", "policy_admin"),
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("campaigns",),
|
||||
any_scopes=(
|
||||
"campaigns:campaign:update",
|
||||
"campaigns:campaign:review",
|
||||
"admin:policies:read",
|
||||
),
|
||||
),
|
||||
),
|
||||
related_modules=("policy", "audit", "access"),
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"route": "/campaigns/{campaign_id}/files",
|
||||
"screen": "Campaign attachments",
|
||||
"help_contexts": ["campaign.archive-encryption"],
|
||||
"prerequisites": [
|
||||
"Policy is available; a policy administrator can read and write the system archive policy.",
|
||||
"The Campaign actor has campaigns:archive:use_legacy_zipcrypto and may edit the selected version.",
|
||||
],
|
||||
"steps": [
|
||||
"From Campaign Settings or Attachments, open the system archive policy under Administration → SYSTEM → Campaign archive encryption.",
|
||||
"Explicitly permit Legacy ZipCrypto and Save; inspect tenant and owner restrictions if the effective Campaign policy still blocks it.",
|
||||
"Return to Campaign and Reload archive policy; enable ZIP attachments and select Legacy ZipCrypto for the intended archive.",
|
||||
"Acknowledge weak encryption, provide a reason of at least 10 characters, and choose an allowed separate password-delivery channel.",
|
||||
"Save, validate, build, and review the exact version before separately authorizing delivery.",
|
||||
],
|
||||
"limitations": [
|
||||
"AES remains the default; Legacy is an explicit compatibility exception, never an automatic fallback.",
|
||||
"Child scopes cannot loosen a parent ceiling, and policy permission does not replace the dedicated Campaign permission.",
|
||||
],
|
||||
"verification": "Reopen the attachment settings and confirm the chosen method, allowed effective policy, separate password channel, and reasoned acknowledgment. Confirm build evidence records the policy hash and actor without the password.",
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="campaigns.workflow.complete-review",
|
||||
title="Inspect built messages and complete review",
|
||||
summary="Resolve critical blockers, record individual message decisions, and acknowledge non-critical review items for one exact build.",
|
||||
body="Review completion remains bound to the current build token, inspected message keys, recorded issue decisions, and message evidence. Changing recipients, content, attachments, owner context, or non-secret transport identity requires validation, building, and review again.",
|
||||
body=(
|
||||
"Opening Template without editing, changing read-only state, or switching visual/source inspection preserves saved HTML and does not mark the page dirty or require a save when leaving. Review completion remains bound to the current build token, inspected message keys, recorded issue decisions, and message evidence. Ordinary editor saves send only client-owned created_from, field_overrides, and opt_ins metadata; review_send and approval_gate are readable server evidence, not writable editor payloads. Omitting that evidence during a metadata save preserves it on the server; supported unlock, fork, and build invalidation rules still clear stale evidence when required. If the selected version requires legacy Mail migration, Review and send stays read-only, suppresses incompatible attachment-preview requests, and offers Open Mail settings with the exact selected version. Resolve the migration, validate, build, and review before sending. Use the explicit actions on actionable recipient, attachment, validation, and review metrics to reveal the corresponding source page, evidence preview, or filtered built-message table. Informational and privacy-suppressed measures do not become hidden click targets. Changing recipients, content, attachments, owner context, or non-secret transport identity requires validation, building, and review again."
|
||||
" Accept similar review conditions groups only server-eligible, unreviewed messages from the currently loaded matching set. Choose one human-readable category, inspect the counted recipient selection and provide one shared reason when accepting attachment exceptions. Each request names at most 200 exact messages and verifies the current build and category; repeat for the remaining messages rather than assuming other categories or unloaded messages were included. Each selected message receives its own frozen, attributable decision evidence. A failed save retains the reason and selection for explicit retry, and a changed build blocks stale acceptance. Group acceptance neither sends messages nor completes the final review gate. Hard blockers cannot be overridden. Deliberate policy exclusions and explicitly allowed zero-match attachment rules remain informational and do not require review decisions."
|
||||
" Saving an individual acceptance persists its reason and reviewed state immediately, before full review completion; reload resumes acknowledged progress for the same build. A failed or conflicting save retains the pending reason for explicit retry and does not mark the message reviewed. Each save merges only selected messages, preserving other reviewers' evidence, without rebuilding messages, inspecting attachment files, or reloading the entire workspace. Partial progress never authorizes delivery; final completion still checks every required decision and hard blocker. Required attachment and hard-block policies remain stronger than an optional rule allowing empty matches; the separate campaign policy for sending an entirely attachment-free message also remains authoritative."
|
||||
" Review saves require campaign review permission, the current version revision, and a safe operational review_build_token; diagnostic scope is not required. Stale builds or concurrent changes return a conflict without overwriting stored progress."
|
||||
" Accepted or expected attachment conditions remain satisfied for the same build in Confirm and send; missing/ambiguous source counts remain visible for context but create no second acceptance gate. The reviewed-stage mock uses verified frozen messages and completed decisions instead of regenerating them. Changed inputs, issue evidence, message bytes or Mail transport stop the test before mock capture; authoring previews retain their separate transient-build behavior."
|
||||
" Validation details and repeated-file lists expose every item through the shared DataGrid pagination controls. A related missing-rule cause and attachment-free policy outcome are explained together while expandable technical evidence remains intact. Built-message filters and rows use four operational states: Ready, Needs review, Blocked and Excluded. Accepted explicit decisions become Ready; warnings awaiting acknowledgment remain Needs review. A second column explains the state. These are presentation changes, not deletion or rewriting of frozen issues or audit evidence."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("user",),
|
||||
audience=("campaign_reviewer",),
|
||||
@@ -955,13 +1547,14 @@ manifest = ModuleManifest(
|
||||
],
|
||||
"steps": [
|
||||
"Open Review and send and inspect the Critical blockers, Individual review, and Group review summaries.",
|
||||
"If a legacy Mail migration notice appears, use Open Mail settings for this version, complete the explicit migration, and validate and build again.",
|
||||
"Correct every critical blocker in the named campaign workspace, then validate and build again.",
|
||||
"Open each remaining individual review message and record its decision.",
|
||||
"Open each remaining individual review message, save its reasoned acceptance and wait for the durable acknowledgement; reload can resume this partial progress.",
|
||||
"When only non-critical group items remain, review their conditions and explicitly complete review.",
|
||||
"Confirm that Reviewed equals the required review total and Remaining is zero before delivery.",
|
||||
],
|
||||
"outcome": "Review evidence for the exact current build, with no unresolved blocker or review decision.",
|
||||
"verification": "Reload Review and send, confirm no critical blocker or remaining decision, and verify that the permitted delivery mode is unlocked for the same version and build.",
|
||||
"verification": "Reload after an individual acceptance before completing the whole review and confirm its reason and reviewed state persist. After final completion, confirm no critical blocker or remaining decision and verify that delivery is unlocked only for the same version and build.",
|
||||
"related_topic_ids": [
|
||||
"campaigns.workflow.prepare-validate-and-build",
|
||||
"campaigns.workflow.retry-and-reconcile",
|
||||
@@ -972,7 +1565,9 @@ manifest = ModuleManifest(
|
||||
id="campaigns.workflow.retry-and-reconcile",
|
||||
title="Retry only known failures and reconcile uncertain effects",
|
||||
summary="Keep safe-to-retry failures separate from Mail, Postbox, or IMAP effects whose outcome is unknown.",
|
||||
body="A retry creates new attempt evidence and is valid only for an explicitly eligible state. Never blindly retry an unknown Mail, Postbox, or IMAP effect. Inspect external evidence and reconcile the affected channel before continuing. Accepted Mail attempts and accepted Postbox targets are immutable during partial retries, and repairing Sent never resends accepted Mail.",
|
||||
body=("A retry creates new attempt evidence and is valid only for an explicitly eligible state. Never blindly retry an unknown Mail, Postbox, or IMAP effect. Inspect external evidence and reconcile the affected channel before continuing. Accepted Mail attempts and accepted Postbox targets are immutable during partial retries, and repairing Sent never resends accepted Mail. "
|
||||
"Without workers, Report offers explicit bounded retry and continuation using the same immutable jobs, execution checks, review evidence, approvals, Mail authorization, rate limits and recovery ledger as Send now. Each request is capped by the effective synchronous policy and returns remaining work; repeating continuation skips already accepted, excluded, active and uncertain jobs. Retry requires campaigns:campaign:retry plus campaigns:campaign:send for inline execution; continuation requires campaigns:campaign:queue plus campaigns:campaign:send. Reconciliation needs campaigns:campaign:reconcile and a factual evidence note; it never sends mail. "
|
||||
"A stalled claimed/sending/appending job is not safe merely because time elapsed. Report exposes Recover interrupted claim only for an expired durable lease whose original runtime is proven stopped or replaced. The submitted opaque revision must still match and the original recovery evidence must be valid; the action changes the effect only to outcome-unknown. Inspect provider/mailbox evidence and separately reconcile accepted/not sent or appended/not appended before an explicit retry. Missing original leases/evidence and unconfirmed owners remain blocked for operator investigation. A duplicate worker task leaves active state unchanged."),
|
||||
layer="evidence",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("campaign_sender", "campaign_operator"),
|
||||
@@ -1017,9 +1612,9 @@ manifest = ModuleManifest(
|
||||
"Provider, mailbox, worker, and campaign evidence has been preserved.",
|
||||
],
|
||||
"steps": [
|
||||
"Classify the job and latest SMTP and IMAP attempts independently.",
|
||||
"Retry only an explicitly temporary, permanent-with-override, or unattempted eligible state.",
|
||||
"For an unknown effect, inspect provider or mailbox evidence and record the factual reconciliation with a note.",
|
||||
"Open the selected version's Report and classify each job's SMTP and IMAP attempts independently.",
|
||||
"Explicitly retry eligible failures or continue the unattempted jobs on this page; without workers, use the bounded Send now action and inspect remaining work.",
|
||||
"For an abandoned active claim, recover only with expired-lease and stopped/replaced-owner proof; then inspect provider or mailbox evidence and reconcile the unknown effect with a factual note.",
|
||||
"Verify the resulting protected state before allowing more work for that job.",
|
||||
],
|
||||
"outcome": "Every investigated job is either protected as effected, explicitly retryable, or still visibly unresolved.",
|
||||
@@ -1030,11 +1625,35 @@ manifest = ModuleManifest(
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="campaigns.admin.delivery-policy",
|
||||
title="Configure the interactive Campaign delivery limit",
|
||||
summary="Set an audited system maximum for Send now and optional narrower tenant limits without changing any campaign or sending messages.",
|
||||
body="Administration → SYSTEM → Campaign delivery permits system:settings:read to inspect and system:settings:write to save the synchronous recipient-job maximum. Its unchanged default is 25; an administrator may explicitly choose 0–500, for example 200 for a 183-job run. Administration → TENANT → Campaign delivery requires admin:policies:read/write and may only narrow the inherited system limit. Clearing an override restores inheritance. An explicitly set GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS remains an authoritative deployment ceiling, including zero. With no deployment value, the implicit default does not prevent an authorized system override. Larger interactive batches take longer and may exceed proxy/request timeouts; background workers remain the preferred separate mode for large campaigns and require CELERY_ENABLED plus working Redis/Celery infrastructure. This setting limits one exact persisted eligible Send now run, not campaign size or worker dispatch. Save stores only this scoped setting with revision conflict protection, before/after configuration history and audit; it neither sends messages, alters saved review evidence, nor bypasses Mail, review, approval or execution-integrity gates. Failed saves retain the draft. Reload uses the shared unsaved-change guard and returns fresh saved policy.",
|
||||
layer="configured", documentation_types=("admin",), audience=("administrator", "platform_operator"), order=51,
|
||||
conditions=(DocumentationCondition(required_modules=("campaigns",), any_scopes=("system:settings:read", "admin:policies:read")),),
|
||||
links=(DocumentationLink(label="System Campaign delivery", href="/admin?section=system-campaign-delivery", kind="runtime"),
|
||||
DocumentationLink(label="Tenant Campaign delivery", href="/admin?section=tenant-campaign-delivery", kind="runtime")),
|
||||
metadata={"kind": "workflow", "route": "/admin?section=system-campaign-delivery", "screen": "Campaign delivery",
|
||||
"prerequisites": ["You hold the read and write permissions for the intended system or active tenant scope."],
|
||||
"steps": ["Open Administration and select Campaign delivery in SYSTEM or TENANT.",
|
||||
"Inspect the saved, inherited and maximum permitted values; disable inheritance to set a whole-number override.",
|
||||
"Save and verify the saved effective limit, or retain the draft and resolve an explicit conflict before retrying.",
|
||||
"Return to Review and send and reload delivery options before separately choosing a delivery action."],
|
||||
"limitations": ["Tenant settings cannot raise system policy; an explicit deployment ceiling cannot be raised through this UI.",
|
||||
"Zero disables Send now. Worker availability is independent; changing this setting never starts workers or sends messages."],
|
||||
"verification": "Reload the administration section and confirm the effective value, then inspect before/after configuration history. Review and send must still enforce the exact eligible count and all delivery gates."},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="campaigns.reference.composition-assurance",
|
||||
title="Assure the Campaign reference composition",
|
||||
summary="Release Campaign only with aligned contracts, role-safe surfaces, durable effect evidence, optional-module isolation, and recoverable data.",
|
||||
body="Campaign is a reference composition only when Core, Mail, Files, Addresses, workers, storage, policies, and documentation are tested in the exact installed combination. Normal readers see business state rather than paths, storage keys, worker claims, or raw provider diagnostics; diagnostic and export authority remain separate.",
|
||||
body=(
|
||||
"Campaign is a reference composition only when Core, Mail, Files, Addresses, workers, storage, policies, and documentation are tested in the exact installed combination. Normal readers see business state rather than paths, storage keys, worker claims, or raw provider diagnostics; diagnostic and export authority remain separate."
|
||||
" Release checks must initialize Campaign validation and attachment resolution independently in fresh processes, without relying on an earlier page or test import. These local entry points remain usable without installing Mail or Files; their import never starts delivery or relaxes managed-file path authorization."
|
||||
" Verify that individual review reasons and reviewed state survive reload before final completion. Incremental review saves require campaigns:campaign:review and write access, merge exact selected current-build jobs under revision checks, and audit each acceptance without replacing other reviewers' evidence. The operational review_build_token does not expose raw diagnostic tokens. Partial progress never enables delivery, and hard blockers cannot be accepted. Policy-driven exclusions and explicitly allowed empty optional attachment rules create no new review obligation; required/global hard blocks remain enforced. These resolution changes apply to new builds only: an intentional rebuild is needed to reclassify existing messages and invalidates prior review and approval evidence. Frozen historical job issues must not be rewritten from mutable policy."
|
||||
" The opt-in mock-send use_reviewed_build mode requires an existing sealed execution and same-build completed review when review is needed. It checks persisted job/issue input seals, EML length/digest/Message-ID and current Mail transport before any mock capture or requested mailbox clear. It never sends SMTP, alters Campaign delivery state, or creates a missing legacy snapshot. include_needs_review is not a blanket override in this mode."
|
||||
),
|
||||
layer="evidence",
|
||||
documentation_types=("admin",),
|
||||
audience=(
|
||||
@@ -1129,7 +1748,7 @@ manifest = ModuleManifest(
|
||||
],
|
||||
},
|
||||
),
|
||||
),
|
||||
)),
|
||||
documentation_providers=(documentation_topics,),
|
||||
ownership_providers=(
|
||||
OwnershipProviderRegistration(
|
||||
@@ -1149,6 +1768,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"],
|
||||
@@ -1161,12 +1784,27 @@ manifest = ModuleManifest(
|
||||
"govoplan_campaign.backend.capabilities",
|
||||
fromlist=["retention_capability"],
|
||||
).retention_capability(context),
|
||||
CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION: lambda context: __import__(
|
||||
"govoplan_campaign.backend.work_orchestration",
|
||||
fromlist=["SqlCampaignWorkOrchestrationProvider"],
|
||||
).SqlCampaignWorkOrchestrationProvider(registry=context.registry),
|
||||
REPORT_PROVIDER_CAPABILITY_PREFIX + "campaigns": lambda context: __import__(
|
||||
"govoplan_campaign.backend.reports.provider",
|
||||
fromlist=["CampaignAggregateReportProvider"],
|
||||
).CampaignAggregateReportProvider(),
|
||||
CAMPAIGN_DSAR_CAPABILITY: _dsar_provider,
|
||||
},
|
||||
capability_documentation={
|
||||
CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION: CapabilityDocumentation(
|
||||
label="Campaign work orchestration",
|
||||
summary=(
|
||||
"Creates or references Campaign work idempotently and exposes "
|
||||
"revision-bearing lifecycle events without granting access."
|
||||
),
|
||||
contract_version="1.0",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("campaign_manager", "workflow_designer", "module_admin"),
|
||||
),
|
||||
REPORT_PROVIDER_CAPABILITY_PREFIX + "campaigns": CapabilityDocumentation(
|
||||
label="Campaign aggregate report provider",
|
||||
summary=(
|
||||
@@ -1177,6 +1815,13 @@ manifest = ModuleManifest(
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "reporting_analyst", "privacy_officer"),
|
||||
),
|
||||
CAMPAIGN_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||
label="Campaign data-subject request provider",
|
||||
summary="Finds isolated recipient and Campaign evidence metadata and classifies governed erasure actions.",
|
||||
contract_version="0.1.0",
|
||||
documentation_types=("admin",),
|
||||
audience=("privacy_officer", "campaign_manager", "records_manager"),
|
||||
),
|
||||
},
|
||||
operational_check_providers=(
|
||||
OperationalCheckProviderRegistration(
|
||||
@@ -1224,5 +1869,10 @@ manifest = ModuleManifest(
|
||||
)
|
||||
|
||||
|
||||
manifest = with_documentation_structured_translations(
|
||||
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
|
||||
)
|
||||
|
||||
|
||||
def get_manifest() -> ModuleManifest:
|
||||
return manifest
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_campaign.backend.services.filenames import FilenameAllocator
|
||||
|
||||
import mimetypes
|
||||
import re
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from email.message import EmailMessage
|
||||
from email.utils import make_msgid, formatdate
|
||||
from pathlib import Path
|
||||
@@ -19,16 +21,19 @@ from govoplan_campaign.backend.attachments.resolver import (
|
||||
effective_send_without_attachments_behavior,
|
||||
resolve_entry_attachments,
|
||||
)
|
||||
from govoplan_campaign.backend.attachments.reuse import evaluate_attachment_reuse
|
||||
from govoplan_campaign.backend.campaign.addressing import effective_address_lists, formatted_recipient
|
||||
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,
|
||||
@@ -37,7 +42,10 @@ from govoplan_campaign.backend.campaign.models import (
|
||||
effective_delivery_channel_policy,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.template_values import build_template_values
|
||||
from govoplan_campaign.backend.services.zip_service import create_zip_archive
|
||||
from govoplan_campaign.backend.services.zip_service import (
|
||||
create_zip_archive,
|
||||
zip_archive_evidence,
|
||||
)
|
||||
from govoplan_campaign.backend.template_rendering import (
|
||||
find_unresolved_placeholders as _find_unresolved_placeholders,
|
||||
render_template as _render_template,
|
||||
@@ -90,6 +98,14 @@ class _MimeBuildResult:
|
||||
build_status: BuildStatus
|
||||
validation_status: MessageValidationStatus
|
||||
attachment_count: int
|
||||
archive_evidence: list[dict[str, object]] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _ResidualFileGroup:
|
||||
source_name: str
|
||||
directory: Path
|
||||
files: list[Path]
|
||||
|
||||
|
||||
def _resolve(campaign_file: str | Path, raw_path: str) -> Path:
|
||||
@@ -289,15 +305,8 @@ def _archive_filename(archive: ZipArchiveConfig, values: dict[str, Any], entry_i
|
||||
return filename if filename.lower().endswith(".zip") else f"{filename}.zip"
|
||||
|
||||
|
||||
def _unique_attachment_filename(filename: str, used: set[str]) -> str:
|
||||
candidate = filename
|
||||
path = Path(filename)
|
||||
counter = 2
|
||||
while candidate.casefold() in used:
|
||||
candidate = f"{path.stem} ({counter}){path.suffix}"
|
||||
counter += 1
|
||||
used.add(candidate.casefold())
|
||||
return candidate
|
||||
def _unique_attachment_filename(filename: str, used: FilenameAllocator) -> str:
|
||||
return used.allocate(filename)
|
||||
|
||||
|
||||
def _deduplicated_archive_members(members: list[tuple[Path, str]]) -> list[tuple[Path, str]]:
|
||||
@@ -362,12 +371,13 @@ def _attach_files(
|
||||
resolution: EntryAttachmentResolution,
|
||||
values: dict[str, Any],
|
||||
work_dir: Path,
|
||||
) -> int:
|
||||
) -> tuple[int, list[dict[str, object]]]:
|
||||
attached_count = 0
|
||||
evidence: list[dict[str, object]] = []
|
||||
archive_members: dict[str, list[tuple[Path, str]]] = {}
|
||||
archive_attachments: dict[str, list[ResolvedAttachment]] = {}
|
||||
used_message_filenames: set[str] = set()
|
||||
used_zip_member_filenames: dict[str, set[str]] = {}
|
||||
used_message_filenames = FilenameAllocator()
|
||||
used_zip_member_filenames: dict[str, FilenameAllocator] = {}
|
||||
|
||||
for attachment in resolution.attachments:
|
||||
attachment.message_filenames = []
|
||||
@@ -379,7 +389,7 @@ def _attach_files(
|
||||
continue
|
||||
match_paths = [Path(match) for match in attachment.matches]
|
||||
if attachment.zip_enabled and attachment.zip_archive_id:
|
||||
used_archive_names = used_zip_member_filenames.setdefault(attachment.zip_archive_id, set())
|
||||
used_archive_names = used_zip_member_filenames.setdefault(attachment.zip_archive_id, FilenameAllocator())
|
||||
for position, path in enumerate(match_paths, start=1):
|
||||
requested = _render_attachment_filename(
|
||||
template=attachment.zip_entry_name_template,
|
||||
@@ -419,13 +429,38 @@ def _attach_files(
|
||||
password,
|
||||
archive.method.value,
|
||||
)
|
||||
archive_record = zip_archive_evidence(
|
||||
archive_path,
|
||||
members,
|
||||
password_protected=bool(password),
|
||||
method=archive.method.value,
|
||||
)
|
||||
archive_record.update(
|
||||
{
|
||||
"archive_id": archive.id,
|
||||
"filename": filename,
|
||||
"password_delivery_channel": (
|
||||
archive.password_delivery_channel.value if password else None
|
||||
),
|
||||
"legacy_acknowledgement": (
|
||||
{
|
||||
"actor_id": archive.legacy_zipcrypto_acknowledged_by,
|
||||
"reason": archive.legacy_zipcrypto_reason,
|
||||
"recorded_at": archive.legacy_zipcrypto_acknowledged_at,
|
||||
}
|
||||
if archive.method.value == "zip_standard"
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
evidence.append(archive_record)
|
||||
data, maintype, subtype = _attachment_bytes(archive_path)
|
||||
message.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename)
|
||||
attached_count += 1
|
||||
for attachment in archive_attachments.get(archive.id, []):
|
||||
attachment.zip_filename = filename
|
||||
|
||||
return attached_count
|
||||
return attached_count, evidence
|
||||
|
||||
def _imap_initial_status(
|
||||
config: CampaignConfig,
|
||||
@@ -517,6 +552,7 @@ def _message_draft(
|
||||
imap_status: ImapStatus | None = None,
|
||||
subject: str | None = None,
|
||||
attachment_count: int = 0,
|
||||
archive_evidence: list[dict[str, object]] | None = None,
|
||||
issues: list[MessageIssue] | None = None,
|
||||
eml_path: str | None = None,
|
||||
eml_size: int | None = None,
|
||||
@@ -556,6 +592,7 @@ def _message_draft(
|
||||
disposition_notification_to=_message_addresses(context.recipients["disposition_notification_to"]),
|
||||
attachment_count=attachment_count,
|
||||
attachments=_attachment_summaries(context.resolution),
|
||||
archive_evidence=archive_evidence or [],
|
||||
issues=issues if issues is not None else context.issues,
|
||||
eml_path=eml_path,
|
||||
eml_size_bytes=eml_size,
|
||||
@@ -753,7 +790,7 @@ def _build_mime_message(
|
||||
_populate_message_body(message, rendered)
|
||||
if work_dir is None:
|
||||
work_dir = output_dir or Path(tempfile.mkdtemp(prefix="govoplan-build-"))
|
||||
attachment_count = _attach_files(
|
||||
attachment_count, archive_evidence = _attach_files(
|
||||
message=message,
|
||||
config=config,
|
||||
entry=entry,
|
||||
@@ -779,6 +816,7 @@ def _build_mime_message(
|
||||
build_status=BuildStatus.BUILT,
|
||||
validation_status=context.validation_status,
|
||||
attachment_count=attachment_count,
|
||||
archive_evidence=archive_evidence,
|
||||
)
|
||||
except ZipBuildError as exc:
|
||||
context.issues.append(
|
||||
@@ -883,6 +921,7 @@ def build_entry_message(
|
||||
validation_status=mime_result.validation_status,
|
||||
subject=rendered.subject,
|
||||
attachment_count=mime_result.attachment_count,
|
||||
archive_evidence=mime_result.archive_evidence,
|
||||
eml_path=eml_path,
|
||||
eml_size=eml_size,
|
||||
)
|
||||
@@ -890,17 +929,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 +943,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 +957,216 @@ 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,
|
||||
archive_evidence=mime_result.archive_evidence,
|
||||
eml_path=eml_path,
|
||||
eml_size=eml_size,
|
||||
),
|
||||
mime=mime_result.message,
|
||||
)
|
||||
|
||||
|
||||
def _residual_file_disposition_evidence(
|
||||
*,
|
||||
config: CampaignConfig,
|
||||
residual_groups: list[_ResidualFileGroup],
|
||||
) -> dict[str, object]:
|
||||
disposition = config.attachments.residual_files
|
||||
behavior = config.validation_policy.unsent_attachment_files.value
|
||||
if disposition.mode == ResidualFileMode.REPORT:
|
||||
action = "route_report"
|
||||
elif disposition.mode == ResidualFileMode.ATTACH:
|
||||
action = "route_with_files"
|
||||
elif behavior == Behavior.BLOCK.value:
|
||||
action = "block"
|
||||
elif behavior in {Behavior.CONTINUE.value, Behavior.DROP.value}:
|
||||
action = "ignore"
|
||||
else:
|
||||
action = "review"
|
||||
recipient = (
|
||||
disposition.recipient.model_dump(mode="json", by_alias=True)
|
||||
if disposition.recipient is not None
|
||||
else None
|
||||
)
|
||||
return {
|
||||
"contract_version": "1",
|
||||
"action": action,
|
||||
"routing_mode": disposition.mode.value,
|
||||
"validation_behavior": behavior,
|
||||
"watched_source_count": len(residual_groups),
|
||||
"residual_file_count": sum(len(group.files) for group in residual_groups),
|
||||
"recipient": recipient,
|
||||
}
|
||||
|
||||
|
||||
def _apply_campaign_level_issues(built_messages: list[BuiltMessage], issues: list[MessageIssue]) -> None:
|
||||
if not issues:
|
||||
return
|
||||
@@ -949,6 +1180,28 @@ def _apply_campaign_level_issues(built_messages: list[BuiltMessage], issues: lis
|
||||
status = _apply_behavior(status, issue.behavior)
|
||||
built.draft.validation_status = status
|
||||
|
||||
|
||||
def _apply_attachment_reuse_policy(
|
||||
config: CampaignConfig,
|
||||
built_messages: list[BuiltMessage],
|
||||
) -> dict[str, object]:
|
||||
evaluation = evaluate_attachment_reuse(
|
||||
[built.draft for built in built_messages],
|
||||
policy=config.attachments.reuse_policy,
|
||||
)
|
||||
for built in built_messages:
|
||||
issues = evaluation.issues_by_entry_index.get(built.draft.entry_index, [])
|
||||
if not issues:
|
||||
continue
|
||||
built.draft.issues.extend(issues)
|
||||
for issue in issues:
|
||||
if issue.behavior:
|
||||
built.draft.validation_status = _apply_behavior(
|
||||
built.draft.validation_status,
|
||||
issue.behavior,
|
||||
)
|
||||
return evaluation.report
|
||||
|
||||
def build_campaign_messages(
|
||||
config: CampaignConfig,
|
||||
*,
|
||||
@@ -978,15 +1231,35 @@ 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)
|
||||
attachment_reuse = _apply_attachment_reuse_policy(
|
||||
config,
|
||||
built_messages,
|
||||
)
|
||||
|
||||
rules_resolved = sum(len(built.draft.attachments) for built in built_messages)
|
||||
report = CampaignBuildReport(
|
||||
@@ -1000,5 +1273,10 @@ def build_campaign_messages(
|
||||
duration_ms=(time.perf_counter() - started) * 1000,
|
||||
rules_resolved=rules_resolved,
|
||||
),
|
||||
attachment_reuse=attachment_reuse,
|
||||
residual_file_disposition=_residual_file_disposition_evidence(
|
||||
config=config,
|
||||
residual_groups=residual_groups,
|
||||
),
|
||||
)
|
||||
return CampaignBuildResult(report=report, built_messages=built_messages)
|
||||
|
||||
@@ -94,6 +94,7 @@ class MessageDraft(BaseModel):
|
||||
|
||||
attachment_count: int = 0
|
||||
attachments: list[MessageAttachmentSummary] = Field(default_factory=list)
|
||||
archive_evidence: list[dict[str, object]] = Field(default_factory=list)
|
||||
issues: list[MessageIssue] = Field(default_factory=list)
|
||||
|
||||
eml_path: str | None = None
|
||||
@@ -117,6 +118,8 @@ class CampaignBuildReport(BaseModel):
|
||||
inactive_entries_count: int = 0
|
||||
messages: list[MessageDraft] = Field(default_factory=list)
|
||||
attachment_resolution_profile: dict[str, object] = Field(default_factory=dict)
|
||||
attachment_reuse: dict[str, object] = Field(default_factory=dict)
|
||||
residual_file_disposition: dict[str, object] = Field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def built_count(self) -> int:
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
"""add campaign schedule optimistic-concurrency revisions
|
||||
|
||||
Revision ID: a5b6c7d8e9f0
|
||||
Revises: f4a5b6c7d8e9
|
||||
Create Date: 2026-08-07 12:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
|
||||
|
||||
_migration = import_module(
|
||||
"govoplan_campaign.backend.migrations.versions."
|
||||
"a5b6c7d8e9f0_v0120_campaign_schedule_revisions"
|
||||
)
|
||||
revision = _migration.revision
|
||||
down_revision = _migration.down_revision
|
||||
branch_labels = _migration.branch_labels
|
||||
depends_on = _migration.depends_on
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_migration.upgrade()
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_migration.downgrade()
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
"""add durable campaign schedules and occurrence evidence
|
||||
|
||||
Revision ID: f4a5b6c7d8e9
|
||||
Revises: e3c8f4a5b6d7
|
||||
Create Date: 2026-08-07 10:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
|
||||
|
||||
_migration = import_module(
|
||||
"govoplan_campaign.backend.migrations.versions."
|
||||
"f4a5b6c7d8e9_v0119_campaign_schedules"
|
||||
)
|
||||
revision = _migration.revision
|
||||
down_revision = _migration.down_revision
|
||||
branch_labels = _migration.branch_labels
|
||||
depends_on = _migration.depends_on
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_migration.upgrade()
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_migration.downgrade()
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
"""add campaign schedule optimistic-concurrency revisions
|
||||
|
||||
Revision ID: a5b6c7d8e9f0
|
||||
Revises: f4a5b6c7d8e9
|
||||
Create Date: 2026-08-07 12:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "a5b6c7d8e9f0"
|
||||
down_revision = "f4a5b6c7d8e9"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if inspector.has_table("campaign_schedules"):
|
||||
columns = {column["name"] for column in inspector.get_columns("campaign_schedules")}
|
||||
if "resource_revision" not in columns:
|
||||
op.add_column(
|
||||
"campaign_schedules",
|
||||
sa.Column(
|
||||
"resource_revision",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="1",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if inspector.has_table("campaign_schedules"):
|
||||
columns = {column["name"] for column in inspector.get_columns("campaign_schedules")}
|
||||
if "resource_revision" in columns:
|
||||
op.drop_column("campaign_schedules", "resource_revision")
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
"""add governed autonomous Campaign schedule evidence
|
||||
|
||||
revision = "b6c7d8e9f0a1"
|
||||
down_revision = "a5b6c7d8e9f0"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "b6c7d8e9f0a1"
|
||||
down_revision = "a5b6c7d8e9f0"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"campaign_schedules",
|
||||
sa.Column("delivery_mode", sa.String(length=20), nullable=False, server_default="manual"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_campaign_schedules_delivery_mode",
|
||||
"campaign_schedules",
|
||||
["delivery_mode"],
|
||||
)
|
||||
op.add_column(
|
||||
"campaign_schedules",
|
||||
sa.Column("approved_execution_snapshot_hash", sa.String(length=64), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_campaign_schedules_approved_execution_snapshot_hash",
|
||||
"campaign_schedules",
|
||||
["approved_execution_snapshot_hash"],
|
||||
)
|
||||
op.add_column(
|
||||
"campaign_schedules",
|
||||
sa.Column("last_outcome", sa.String(length=30), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"campaign_schedules",
|
||||
sa.Column("last_recovery_state", sa.String(length=30), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"campaign_schedule_occurrences",
|
||||
sa.Column("idempotency_key", sa.String(length=200), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_campaign_schedule_occurrences_idempotency_key",
|
||||
"campaign_schedule_occurrences",
|
||||
["idempotency_key"],
|
||||
)
|
||||
op.add_column(
|
||||
"campaign_schedule_occurrences",
|
||||
sa.Column("delivery_command_ids", sa.JSON(), nullable=False, server_default="[]"),
|
||||
)
|
||||
op.add_column(
|
||||
"campaign_schedule_occurrences",
|
||||
sa.Column("recovery_state", sa.String(length=30), nullable=False, server_default="none"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_campaign_schedule_occurrences_recovery_state",
|
||||
"campaign_schedule_occurrences",
|
||||
["recovery_state"],
|
||||
)
|
||||
op.add_column(
|
||||
"campaign_schedule_occurrences",
|
||||
sa.Column("evidence", sa.JSON(), nullable=False, server_default="{}"),
|
||||
)
|
||||
op.add_column(
|
||||
"campaign_schedule_occurrences",
|
||||
sa.Column("last_checked_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("campaign_schedule_occurrences", "last_checked_at")
|
||||
op.drop_column("campaign_schedule_occurrences", "evidence")
|
||||
op.drop_index(
|
||||
"ix_campaign_schedule_occurrences_recovery_state",
|
||||
table_name="campaign_schedule_occurrences",
|
||||
)
|
||||
op.drop_column("campaign_schedule_occurrences", "recovery_state")
|
||||
op.drop_column("campaign_schedule_occurrences", "delivery_command_ids")
|
||||
op.drop_index(
|
||||
"ix_campaign_schedule_occurrences_idempotency_key",
|
||||
table_name="campaign_schedule_occurrences",
|
||||
)
|
||||
op.drop_column("campaign_schedule_occurrences", "idempotency_key")
|
||||
op.drop_column("campaign_schedules", "last_recovery_state")
|
||||
op.drop_column("campaign_schedules", "last_outcome")
|
||||
op.drop_index(
|
||||
"ix_campaign_schedules_approved_execution_snapshot_hash",
|
||||
table_name="campaign_schedules",
|
||||
)
|
||||
op.drop_column("campaign_schedules", "approved_execution_snapshot_hash")
|
||||
op.drop_index("ix_campaign_schedules_delivery_mode", table_name="campaign_schedules")
|
||||
op.drop_column("campaign_schedules", "delivery_mode")
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
"""add governed campaign collaboration entries
|
||||
|
||||
revision = "c7d8e9f0a1b2"
|
||||
down_revision = "b6c7d8e9f0a1"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "c7d8e9f0a1b2"
|
||||
down_revision = "b6c7d8e9f0a1"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if inspector.has_table("campaign_collaboration_entries"):
|
||||
return
|
||||
op.create_table(
|
||||
"campaign_collaboration_entries",
|
||||
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("campaign_version_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("reference_kind", sa.String(length=40), nullable=True),
|
||||
sa.Column("reference_id", sa.String(length=500), nullable=True),
|
||||
sa.Column("reference_label", sa.String(length=255), nullable=True),
|
||||
sa.Column("actor_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("actor_label_snapshot", sa.String(length=255), nullable=False),
|
||||
sa.Column(
|
||||
"visibility",
|
||||
sa.String(length=30),
|
||||
nullable=False,
|
||||
server_default="collaborators",
|
||||
),
|
||||
sa.Column("content", sa.Text(), nullable=True),
|
||||
sa.Column("content_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("mention_user_ids", sa.JSON(), nullable=False, server_default="[]"),
|
||||
sa.Column("withdrawn_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("withdrawn_by_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("redacted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("redacted_by_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("tombstone_reason", sa.String(length=500), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["actor_user_id"], ["access_users.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["campaign_id"], ["campaigns.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["campaign_version_id"],
|
||||
["campaign_versions.id"],
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["redacted_by_user_id"],
|
||||
["access_users.id"],
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["withdrawn_by_user_id"],
|
||||
["access_users.id"],
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
for name, columns in (
|
||||
("ix_campaign_collaboration_entries_tenant_id", ["tenant_id"]),
|
||||
("ix_campaign_collaboration_entries_campaign_id", ["campaign_id"]),
|
||||
("ix_campaign_collaboration_entries_campaign_version_id", ["campaign_version_id"]),
|
||||
("ix_campaign_collaboration_entries_reference_kind", ["reference_kind"]),
|
||||
("ix_campaign_collaboration_entries_actor_user_id", ["actor_user_id"]),
|
||||
("ix_campaign_collaboration_entries_visibility", ["visibility"]),
|
||||
("ix_campaign_collaboration_entries_withdrawn_at", ["withdrawn_at"]),
|
||||
("ix_campaign_collaboration_entries_redacted_at", ["redacted_at"]),
|
||||
(
|
||||
"ix_campaign_collaboration_entries_thread",
|
||||
["tenant_id", "campaign_id", "created_at", "id"],
|
||||
),
|
||||
):
|
||||
op.create_index(name, "campaign_collaboration_entries", columns, unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if inspector.has_table("campaign_collaboration_entries"):
|
||||
op.drop_table("campaign_collaboration_entries")
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
"""add accountable campaign work assignments
|
||||
|
||||
revision = "d8e9f0a1b2c3"
|
||||
down_revision = "c7d8e9f0a1b2"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "d8e9f0a1b2c3"
|
||||
down_revision = "c7d8e9f0a1b2"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if not inspector.has_table("campaign_work_assignments"):
|
||||
op.create_table(
|
||||
"campaign_work_assignments",
|
||||
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("campaign_version_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("reference_kind", sa.String(length=40), nullable=True),
|
||||
sa.Column("reference_id", sa.String(length=500), nullable=True),
|
||||
sa.Column("reference_label", sa.String(length=255), nullable=True),
|
||||
sa.Column("purpose", sa.String(length=500), nullable=False),
|
||||
sa.Column("status", sa.String(length=30), nullable=False, server_default="open"),
|
||||
sa.Column("due_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("assignee_type", sa.String(length=40), nullable=False),
|
||||
sa.Column("assignee_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("assignee_label_snapshot", sa.String(length=500), nullable=False),
|
||||
sa.Column("assignee_current_label", sa.String(length=500), nullable=True),
|
||||
sa.Column("assignee_resolution_state", sa.String(length=30), nullable=False, server_default="resolved"),
|
||||
sa.Column("resolution_provenance", sa.JSON(), nullable=False, server_default="{}"),
|
||||
sa.Column("resolution_checked_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("assigned_by_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("assigned_by_label_snapshot", sa.String(length=255), nullable=False),
|
||||
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("task_mirror_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("task_mirror_status", sa.String(length=30), nullable=False, server_default="not_configured"),
|
||||
sa.Column("task_mirror_error", sa.String(length=500), nullable=True),
|
||||
sa.Column("task_mirrored_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("resource_revision", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["assigned_by_user_id"], ["access_users.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["campaign_id"], ["campaigns.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["campaign_version_id"], ["campaign_versions.id"], ondelete="RESTRICT"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
for name, columns in (
|
||||
("ix_campaign_work_assignments_tenant_id", ["tenant_id"]),
|
||||
("ix_campaign_work_assignments_campaign_id", ["campaign_id"]),
|
||||
("ix_campaign_work_assignments_campaign_version_id", ["campaign_version_id"]),
|
||||
("ix_campaign_work_assignments_reference_kind", ["reference_kind"]),
|
||||
("ix_campaign_work_assignments_status", ["status"]),
|
||||
("ix_campaign_work_assignments_due_at", ["due_at"]),
|
||||
("ix_campaign_work_assignments_assignee_type", ["assignee_type"]),
|
||||
("ix_campaign_work_assignments_assignee_id", ["assignee_id"]),
|
||||
("ix_campaign_work_assignments_assignee_resolution_state", ["assignee_resolution_state"]),
|
||||
("ix_campaign_work_assignments_assigned_by_user_id", ["assigned_by_user_id"]),
|
||||
("ix_campaign_work_assignments_campaign_status", ["tenant_id", "campaign_id", "status", "due_at", "id"]),
|
||||
):
|
||||
op.create_index(name, "campaign_work_assignments", columns, unique=False)
|
||||
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if not inspector.has_table("campaign_work_assignment_events"):
|
||||
op.create_table(
|
||||
"campaign_work_assignment_events",
|
||||
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("assignment_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("event_kind", sa.String(length=40), nullable=False),
|
||||
sa.Column("actor_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("actor_label_snapshot", sa.String(length=255), nullable=False),
|
||||
sa.Column("status_snapshot", sa.String(length=30), nullable=False),
|
||||
sa.Column("assignee_type_snapshot", sa.String(length=40), nullable=False),
|
||||
sa.Column("assignee_id_snapshot", sa.String(length=255), nullable=False),
|
||||
sa.Column("assignee_label_snapshot", sa.String(length=500), nullable=False),
|
||||
sa.Column("resolution_state_snapshot", sa.String(length=30), nullable=False),
|
||||
sa.Column("details", sa.JSON(), nullable=False, server_default="{}"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["actor_user_id"], ["access_users.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["assignment_id"], ["campaign_work_assignments.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["campaign_id"], ["campaigns.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
for name, columns in (
|
||||
("ix_campaign_work_assignment_events_tenant_id", ["tenant_id"]),
|
||||
("ix_campaign_work_assignment_events_campaign_id", ["campaign_id"]),
|
||||
("ix_campaign_work_assignment_events_assignment_id", ["assignment_id"]),
|
||||
("ix_campaign_work_assignment_events_event_kind", ["event_kind"]),
|
||||
("ix_campaign_work_assignment_events_actor_user_id", ["actor_user_id"]),
|
||||
("ix_campaign_work_assignment_events_history", ["tenant_id", "assignment_id", "created_at", "id"]),
|
||||
):
|
||||
op.create_index(name, "campaign_work_assignment_events", columns, unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if inspector.has_table("campaign_work_assignment_events"):
|
||||
op.drop_table("campaign_work_assignment_events")
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if inspector.has_table("campaign_work_assignments"):
|
||||
op.drop_table("campaign_work_assignments")
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
"""add durable Campaign work orchestration provenance
|
||||
|
||||
revision = "f3c7a9d2e6b1"
|
||||
down_revision = "d8e9f0a1b2c3"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "f3c7a9d2e6b1"
|
||||
down_revision = "d8e9f0a1b2c3"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
_COLUMN_SPECS = (
|
||||
("orchestration_idempotency_key", sa.String(length=255)),
|
||||
("orchestration_request_sha256", sa.String(length=64)),
|
||||
("orchestration_correlation_id", sa.String(length=128)),
|
||||
("workflow_instance_id", sa.String(length=36)),
|
||||
("workflow_step_id", sa.String(length=36)),
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if not inspector.has_table("campaign_work_assignments"):
|
||||
return
|
||||
existing = {
|
||||
item["name"]
|
||||
for item in inspector.get_columns("campaign_work_assignments")
|
||||
}
|
||||
with op.batch_alter_table("campaign_work_assignments") as batch:
|
||||
for name, column_type in _COLUMN_SPECS:
|
||||
if name not in existing:
|
||||
batch.add_column(sa.Column(name, column_type, nullable=True))
|
||||
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
indexes = {
|
||||
item["name"]
|
||||
for item in inspector.get_indexes("campaign_work_assignments")
|
||||
}
|
||||
for name, columns in (
|
||||
(
|
||||
"ix_campaign_work_assignments_orchestration_idempotency_key",
|
||||
["orchestration_idempotency_key"],
|
||||
),
|
||||
(
|
||||
"ix_campaign_work_assignments_orchestration_correlation_id",
|
||||
["orchestration_correlation_id"],
|
||||
),
|
||||
(
|
||||
"ix_campaign_work_assignments_workflow_instance_id",
|
||||
["workflow_instance_id"],
|
||||
),
|
||||
(
|
||||
"ix_campaign_work_assignments_workflow_step_id",
|
||||
["workflow_step_id"],
|
||||
),
|
||||
(
|
||||
"uq_campaign_work_assignment_orchestration_key",
|
||||
["tenant_id", "orchestration_idempotency_key"],
|
||||
),
|
||||
):
|
||||
if name not in indexes:
|
||||
op.create_index(
|
||||
name,
|
||||
"campaign_work_assignments",
|
||||
columns,
|
||||
unique=name.startswith("uq_"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if not inspector.has_table("campaign_work_assignments"):
|
||||
return
|
||||
indexes = {
|
||||
item["name"]
|
||||
for item in inspector.get_indexes("campaign_work_assignments")
|
||||
}
|
||||
for name in (
|
||||
"uq_campaign_work_assignment_orchestration_key",
|
||||
"ix_campaign_work_assignments_workflow_step_id",
|
||||
"ix_campaign_work_assignments_workflow_instance_id",
|
||||
"ix_campaign_work_assignments_orchestration_correlation_id",
|
||||
"ix_campaign_work_assignments_orchestration_idempotency_key",
|
||||
):
|
||||
if name in indexes:
|
||||
op.drop_index(name, table_name="campaign_work_assignments")
|
||||
existing = {
|
||||
item["name"]
|
||||
for item in sa.inspect(op.get_bind()).get_columns(
|
||||
"campaign_work_assignments"
|
||||
)
|
||||
}
|
||||
with op.batch_alter_table("campaign_work_assignments") as batch:
|
||||
for name, _column_type in reversed(_COLUMN_SPECS):
|
||||
if name in existing:
|
||||
batch.drop_column(name)
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
"""add durable campaign schedules and occurrence evidence
|
||||
|
||||
Revision ID: f4a5b6c7d8e9
|
||||
Revises: e3c8f4a5b6d7
|
||||
Create Date: 2026-08-07 10:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "f4a5b6c7d8e9"
|
||||
down_revision = "e3c8f4a5b6d7"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if not inspector.has_table("campaign_schedules"):
|
||||
op.create_table(
|
||||
"campaign_schedules",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("campaign_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("source_version_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("recurrence_kind", sa.String(length=20), nullable=False),
|
||||
sa.Column("interval_count", sa.Integer(), nullable=False),
|
||||
sa.Column("timezone", sa.String(length=100), nullable=False),
|
||||
sa.Column("starts_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("next_fire_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("ends_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("max_occurrences", sa.Integer(), nullable=False),
|
||||
sa.Column("occurrence_count", sa.Integer(), nullable=False),
|
||||
sa.Column("active", sa.Boolean(), nullable=False),
|
||||
sa.Column("resource_revision", sa.Integer(), nullable=False),
|
||||
sa.Column("copy_options", sa.JSON(), nullable=False),
|
||||
sa.Column("source_snapshot", sa.JSON(), nullable=False),
|
||||
sa.Column("source_snapshot_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("source_base_path", sa.String(length=1000), nullable=True),
|
||||
sa.Column("last_fired_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_campaign_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("last_error", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["campaign_id"], ["campaigns.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["source_version_id"], ["campaign_versions.id"], ondelete="RESTRICT"),
|
||||
sa.ForeignKeyConstraint(["created_by_user_id"], ["access_users.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["last_campaign_id"], ["campaigns.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
for name, columns in (
|
||||
("ix_campaign_schedules_tenant_id", ["tenant_id"]),
|
||||
("ix_campaign_schedules_campaign_id", ["campaign_id"]),
|
||||
("ix_campaign_schedules_source_version_id", ["source_version_id"]),
|
||||
("ix_campaign_schedules_created_by_user_id", ["created_by_user_id"]),
|
||||
("ix_campaign_schedules_recurrence_kind", ["recurrence_kind"]),
|
||||
("ix_campaign_schedules_next_fire_at", ["next_fire_at"]),
|
||||
("ix_campaign_schedules_active", ["active"]),
|
||||
("ix_campaign_schedules_source_snapshot_hash", ["source_snapshot_hash"]),
|
||||
("ix_campaign_schedules_last_campaign_id", ["last_campaign_id"]),
|
||||
("ix_campaign_schedules_due", ["tenant_id", "active", "next_fire_at"]),
|
||||
):
|
||||
op.create_index(name, "campaign_schedules", columns, unique=False)
|
||||
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if not inspector.has_table("campaign_schedule_occurrences"):
|
||||
op.create_table(
|
||||
"campaign_schedule_occurrences",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("schedule_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("scheduled_for", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("status", sa.String(length=30), nullable=False),
|
||||
sa.Column("generated_campaign_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("generated_version_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("error", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["schedule_id"], ["campaign_schedules.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["generated_campaign_id"], ["campaigns.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["generated_version_id"], ["campaign_versions.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("schedule_id", "scheduled_for", name="uq_campaign_schedule_occurrence"),
|
||||
)
|
||||
for name, columns in (
|
||||
("ix_campaign_schedule_occurrences_tenant_id", ["tenant_id"]),
|
||||
("ix_campaign_schedule_occurrences_schedule_id", ["schedule_id"]),
|
||||
("ix_campaign_schedule_occurrences_status", ["status"]),
|
||||
("ix_campaign_schedule_occurrences_generated_campaign_id", ["generated_campaign_id"]),
|
||||
("ix_campaign_schedule_occurrences_generated_version_id", ["generated_version_id"]),
|
||||
("ix_campaign_schedule_occurrences_schedule", ["schedule_id", "scheduled_for"]),
|
||||
):
|
||||
op.create_index(name, "campaign_schedule_occurrences", columns, unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if inspector.has_table("campaign_schedule_occurrences"):
|
||||
op.drop_table("campaign_schedule_occurrences")
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if inspector.has_table("campaign_schedules"):
|
||||
op.drop_table("campaign_schedules")
|
||||
@@ -40,6 +40,10 @@ from govoplan_campaign.backend.db.models import (
|
||||
JobSendStatus,
|
||||
JobValidationStatus,
|
||||
)
|
||||
from govoplan_campaign.backend.archive_encryption import (
|
||||
CampaignArchiveEncryptionError,
|
||||
assert_archive_encryption_allowed,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.loader import (
|
||||
load_campaign_json,
|
||||
validate_against_schema,
|
||||
@@ -657,6 +661,15 @@ def validate_campaign_version(
|
||||
raise CampaignPersistenceError(
|
||||
"Campaign version is not accessible for this tenant"
|
||||
)
|
||||
try:
|
||||
archive_policy = assert_archive_encryption_allowed(
|
||||
session,
|
||||
campaign,
|
||||
version.raw_json if isinstance(version.raw_json, dict) else {},
|
||||
principal=principal,
|
||||
)
|
||||
except CampaignArchiveEncryptionError as exc:
|
||||
raise CampaignPersistenceError(str(exc)) from exc
|
||||
_ensure_current_campaign_version(campaign, version, action="validate")
|
||||
if _version_is_user_locked(version) or version.workflow_state in {
|
||||
CampaignVersionWorkflowState.QUEUED.value,
|
||||
@@ -734,6 +747,7 @@ def validate_campaign_version(
|
||||
"warning_count": report.warning_count,
|
||||
"validated_at": datetime.now(UTC).isoformat(),
|
||||
"validated_by_user_id": user_id,
|
||||
"archive_encryption_policy": archive_policy.to_dict(),
|
||||
}
|
||||
)
|
||||
version.validation_summary = report_json
|
||||
@@ -1430,6 +1444,11 @@ def _store_execution_snapshot(
|
||||
delivery=config.delivery,
|
||||
jobs=jobs,
|
||||
build_summary=build_summary,
|
||||
archive_encryption=(
|
||||
build_summary.get("archive_encryption")
|
||||
if isinstance(build_summary.get("archive_encryption"), dict)
|
||||
else None
|
||||
),
|
||||
)
|
||||
version.execution_snapshot = snapshot
|
||||
version.execution_snapshot_hash = snapshot_hash
|
||||
@@ -1615,6 +1634,15 @@ def build_campaign_version(
|
||||
raise CampaignPersistenceError(
|
||||
"Campaign version is not accessible for this tenant"
|
||||
)
|
||||
try:
|
||||
archive_policy = assert_archive_encryption_allowed(
|
||||
session,
|
||||
campaign,
|
||||
version.raw_json if isinstance(version.raw_json, dict) else {},
|
||||
principal=principal,
|
||||
)
|
||||
except CampaignArchiveEncryptionError as exc:
|
||||
raise CampaignPersistenceError(str(exc)) from exc
|
||||
_ensure_current_campaign_version(campaign, version, action="build")
|
||||
if version.workflow_state == CampaignVersionWorkflowState.COMPLETED.value:
|
||||
raise CampaignPersistenceError("Sent campaign versions cannot be rebuilt")
|
||||
@@ -1749,6 +1777,25 @@ def build_campaign_version(
|
||||
)
|
||||
report_json = _campaign_build_report(result, files)
|
||||
report_json["built_by_user_id"] = user_id
|
||||
archive_records = [
|
||||
{
|
||||
**archive,
|
||||
"campaign_id": campaign.id,
|
||||
"campaign_version_id": version.id,
|
||||
"build_token": report_json["build_token"],
|
||||
"built_at": report_json["built_at"],
|
||||
"policy_hash": archive_policy.policy_hash,
|
||||
"policy_source_path": [
|
||||
dict(item) for item in archive_policy.source_path
|
||||
],
|
||||
}
|
||||
for message in result.report.messages
|
||||
for archive in message.archive_evidence
|
||||
]
|
||||
report_json["archive_encryption"] = {
|
||||
"policy": archive_policy.to_dict(),
|
||||
"archives": archive_records,
|
||||
}
|
||||
if resolved_print_outputs_by_index:
|
||||
first_output = next(iter(resolved_print_outputs_by_index.values()))
|
||||
report_json["print_output"] = {
|
||||
|
||||
@@ -8,6 +8,7 @@ from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import String, and_, cast, or_
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm.exc import StaleDataError
|
||||
|
||||
@@ -28,8 +29,11 @@ from govoplan_campaign.backend.db.models import (
|
||||
from govoplan_campaign.backend.sending.execution import clear_execution_snapshot
|
||||
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
||||
campaign_editor_state_for_edit,
|
||||
campaign_editor_state_with_client_update,
|
||||
campaign_mail_profile_boundary_violations,
|
||||
campaign_mail_profile_id,
|
||||
campaign_mail_references_unchanged,
|
||||
campaign_preserves_legacy_mail_settings,
|
||||
assert_campaign_uses_mail_profile_reference,
|
||||
public_campaign_mail_server,
|
||||
validate_campaign_editor_state,
|
||||
@@ -41,6 +45,7 @@ from govoplan_campaign.backend.persistence.campaigns import (
|
||||
normalize_campaign_paths,
|
||||
)
|
||||
from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths
|
||||
from govoplan_campaign.backend.services.review_decisions import review_decision_metadata
|
||||
|
||||
|
||||
class LockedCampaignVersionError(CampaignPersistenceError):
|
||||
@@ -701,6 +706,12 @@ def _updated_runtime_json(
|
||||
campaign_mail_profile_boundary_violations(version.raw_json)
|
||||
)
|
||||
if requires_migration and not migrate_legacy_mail_settings:
|
||||
if campaign_preserves_legacy_mail_settings(version.raw_json, runtime_json):
|
||||
# The browser only knows the sanitized reference. Keep the exact
|
||||
# stored transport here; never infer migration from its omission.
|
||||
# No Mail resource is being selected/used by this content repair.
|
||||
runtime_json["server"] = copy.deepcopy(version.raw_json["server"])
|
||||
return runtime_json
|
||||
raise CampaignPersistenceError(
|
||||
"This version contains legacy campaign-local SMTP/IMAP settings. Select an authorized Mail "
|
||||
"profile on the Mail settings page and explicitly save the migration; the stored legacy version "
|
||||
@@ -712,6 +723,11 @@ def _updated_runtime_json(
|
||||
"Migrating legacy campaign mail settings requires an authorized server.mail_profile_id. "
|
||||
"Select a Mail profile before saving."
|
||||
)
|
||||
if not migrate_legacy_mail_settings and campaign_mail_references_unchanged(version.raw_json, runtime_json):
|
||||
# Retaining a selection is not selecting or using a Mail resource. An
|
||||
# unrelated repair must remain saveable after credential policy changes;
|
||||
# validation/build/delivery still reauthorize the complete selection.
|
||||
return runtime_json
|
||||
mail_integration().assert_campaign_mail_policy_allows_json(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
@@ -745,7 +761,9 @@ def _apply_version_field_updates(
|
||||
if value is not None:
|
||||
setattr(version, field_name, value)
|
||||
if editor_state is not None:
|
||||
version.editor_state = validate_campaign_editor_state(editor_state)
|
||||
version.editor_state = campaign_editor_state_with_client_update(
|
||||
version.editor_state, editor_state
|
||||
)
|
||||
if autosave:
|
||||
version.autosaved_at = datetime.now(UTC)
|
||||
|
||||
@@ -908,6 +926,10 @@ def update_campaign_review_state(
|
||||
reviewed_message_keys: list[str],
|
||||
issue_decisions: list[dict[str, Any]] | None = None,
|
||||
user_id: str | None,
|
||||
merge_progress: bool = False,
|
||||
expected_build_token: str | None = None,
|
||||
expected_revision: int | None = None,
|
||||
decision_category_key: str | None = None,
|
||||
commit: bool = True,
|
||||
) -> CampaignVersion:
|
||||
"""Persist review acknowledgement without mutating the locked campaign data.
|
||||
@@ -930,21 +952,64 @@ def update_campaign_review_state(
|
||||
"Delivery has started; message review state can no longer be changed."
|
||||
)
|
||||
build_token = _campaign_review_build_token(version)
|
||||
if merge_progress and (expected_build_token is None or expected_revision is None):
|
||||
raise CampaignPersistenceError("Incremental review requires the current build token and revision.")
|
||||
if expected_build_token is not None and expected_build_token not in {build_token, version.review_build_token}:
|
||||
raise LockedCampaignVersionError("The message build changed. Reload the current build before recording review decisions.")
|
||||
if expected_revision is not None and version.edit_revision != expected_revision:
|
||||
raise RevisionConflictError(
|
||||
resource_type="campaign_version", resource_id=version.id,
|
||||
current_revision=version.edit_revision, submitted_base_revision=expected_revision,
|
||||
refresh_path=f"/api/v1/campaigns/{campaign_id}/versions/{version.id}",
|
||||
current_etag=version.strong_etag,
|
||||
)
|
||||
normalized_reviewed = list(
|
||||
dict.fromkeys(
|
||||
str(value) for value in reviewed_message_keys if str(value).strip()
|
||||
)
|
||||
)
|
||||
normalized_decisions: list[dict[str, Any]] = []
|
||||
requested = issue_decisions or []
|
||||
if merge_progress and not inspection_complete and (len(normalized_reviewed) > 1_000 or len(requested) > 1_000):
|
||||
raise CampaignPersistenceError("Save review progress in groups of at most 1000 messages.")
|
||||
previous = (version.editor_state or {}).get("review_send", {})
|
||||
previous = previous if isinstance(previous, dict) and previous.get("build_token") == build_token else {}
|
||||
previous_decisions = [item for item in previous.get("issue_decisions", []) if isinstance(item, dict)]
|
||||
if merge_progress:
|
||||
normalized_reviewed = list(dict.fromkeys([*previous.get("reviewed_message_keys", []), *normalized_reviewed]))
|
||||
merged_decisions = {str(item.get("job_id")): item for item in previous_decisions}
|
||||
# Do not silently collapse duplicate client decisions; reject them below.
|
||||
submitted_ids = [str(item.get("job_id") or "") for item in requested]
|
||||
if len(submitted_ids) != len(set(submitted_ids)):
|
||||
raise CampaignPersistenceError("Only one review decision may be recorded per built message.")
|
||||
merged_decisions.update({str(item.get("job_id")): item for item in requested})
|
||||
else:
|
||||
merged_decisions = {}
|
||||
if inspection_complete:
|
||||
normalized_reviewed, normalized_decisions = _complete_campaign_review(
|
||||
session,
|
||||
version,
|
||||
normalized_reviewed,
|
||||
issue_decisions or [],
|
||||
list(merged_decisions.values()) if merge_progress else requested,
|
||||
user_id=user_id,
|
||||
build_token=build_token,
|
||||
)
|
||||
else:
|
||||
# A progress save inspects only explicitly submitted rows. In particular,
|
||||
# it does not rebuild messages, resolve files or load every built body.
|
||||
selected_keys = set(str(value) for value in reviewed_message_keys if str(value).strip())
|
||||
jobs = _selected_review_jobs(session, version.id, selected_keys, requested)
|
||||
_assert_review_selection(jobs, selected_keys, requested, decision_category_key)
|
||||
decisions = _normalize_review_issue_decisions(jobs, requested, user_id=user_id, build_token=build_token)
|
||||
normalized_reviewed = list(dict.fromkeys([*normalized_reviewed, *(item["review_key"] for item in decisions)]))
|
||||
if merge_progress:
|
||||
# Retain other reviewers' evidence verbatim; only submitted decisions
|
||||
# may replace their own job's reason/evidence.
|
||||
merged_decisions = {str(item.get("job_id")): item for item in previous_decisions}
|
||||
merged_decisions.update({item["job_id"]: item for item in decisions})
|
||||
normalized_decisions = list(merged_decisions.values())
|
||||
else:
|
||||
normalized_decisions = decisions
|
||||
normalized_decisions = _preserve_unchanged_review_evidence(normalized_decisions, previous_decisions)
|
||||
_write_campaign_review_state(
|
||||
version,
|
||||
build_token=build_token,
|
||||
@@ -961,6 +1026,43 @@ def update_campaign_review_state(
|
||||
return version
|
||||
|
||||
|
||||
def _selected_review_jobs(session, version_id, keys, requested) -> list[CampaignJob]:
|
||||
ids = [str(item.get("job_id") or "") for item in requested]
|
||||
if not keys and not ids:
|
||||
return []
|
||||
return session.query(CampaignJob).filter(
|
||||
CampaignJob.campaign_version_id == version_id,
|
||||
or_(
|
||||
CampaignJob.id.in_(ids), CampaignJob.entry_id.in_(keys),
|
||||
and_(or_(CampaignJob.entry_id.is_(None), CampaignJob.entry_id == ""), cast(CampaignJob.entry_index, String).in_(keys)),
|
||||
),
|
||||
).order_by(CampaignJob.entry_index.asc()).all()
|
||||
|
||||
|
||||
def _assert_review_selection(jobs, keys, requested, category_key) -> None:
|
||||
available_keys = {str(job.entry_id or job.entry_index) for job in jobs}
|
||||
if not keys.issubset(available_keys):
|
||||
raise CampaignPersistenceError("A reviewed message references a message outside the current build.")
|
||||
if any(job.build_status != "built" or job.validation_status in {"blocked", "excluded", "inactive"} for job in jobs):
|
||||
raise CampaignPersistenceError("Only built, non-blocked delivery messages can be accepted for review.")
|
||||
if any(any(isinstance(issue, dict) and str(issue.get("behavior") or "").lower() == "block" for issue in (job.issues_snapshot or [])) for job in jobs):
|
||||
raise CampaignPersistenceError("Hard-blocking issues cannot be overridden by a review decision.")
|
||||
if category_key is not None:
|
||||
selected_ids = {str(item.get("job_id") or "") for item in requested}
|
||||
if not selected_ids or any(review_decision_metadata(job)["category_key"] != category_key for job in jobs if job.id in selected_ids):
|
||||
raise CampaignPersistenceError("The selected messages no longer share the requested review category.")
|
||||
|
||||
|
||||
def _preserve_unchanged_review_evidence(decisions, previous) -> list[dict[str, Any]]:
|
||||
prior_by_id = {item.get("job_id"): item for item in previous}
|
||||
result = []
|
||||
for decision in decisions:
|
||||
prior = prior_by_id.get(decision.get("job_id"))
|
||||
fields = ("decision", "reason", "build_token", "message_sha256", "issue_fingerprint", "review_key")
|
||||
result.append(copy.deepcopy(prior) if prior and all(prior.get(key) == decision.get(key) for key in fields) else decision)
|
||||
return result
|
||||
|
||||
|
||||
def _campaign_review_build_token(version: CampaignVersion) -> str:
|
||||
build_summary = (
|
||||
version.build_summary if isinstance(version.build_summary, dict) else {}
|
||||
@@ -997,7 +1099,9 @@ def _complete_campaign_review(
|
||||
blocking = [
|
||||
job
|
||||
for job in jobs
|
||||
if job.build_status != "built" or job.validation_status == "blocked"
|
||||
if job.validation_status == "blocked"
|
||||
or (job.build_status != "built" and job.validation_status not in {"excluded", "inactive"})
|
||||
or any(isinstance(issue, dict) and str(issue.get("behavior") or "").lower() == "block" for issue in (job.issues_snapshot or []))
|
||||
]
|
||||
if blocking:
|
||||
raise CampaignPersistenceError(
|
||||
@@ -1039,7 +1143,7 @@ def _normalize_review_issue_decisions(
|
||||
raise CampaignPersistenceError(
|
||||
"A review decision references a message outside the current build."
|
||||
)
|
||||
if jobs_by_id[job_id].validation_status != "needs_review":
|
||||
if not review_decision_metadata(jobs_by_id[job_id])["eligible"]:
|
||||
raise CampaignPersistenceError(
|
||||
"Review decisions are accepted only for messages requiring review."
|
||||
)
|
||||
@@ -1054,6 +1158,10 @@ def _normalize_review_issue_decisions(
|
||||
timestamp = (decided_at or datetime.now(UTC)).isoformat()
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for job in jobs:
|
||||
if job.validation_status != "needs_review":
|
||||
# Excluded jobs may retain the issues that triggered the deliberate
|
||||
# drop. That evidence is not a request to override their exclusion.
|
||||
continue
|
||||
reviewable_issues = [
|
||||
issue
|
||||
for issue in (job.issues_snapshot or [])
|
||||
@@ -1134,7 +1242,7 @@ def _bulk_acceptable_review_keys(jobs: list[CampaignJob]) -> list[str]:
|
||||
return [
|
||||
str(job.entry_id or job.entry_index)
|
||||
for job in jobs
|
||||
if job.validation_status in {"warning", "excluded"}
|
||||
if job.validation_status == "warning"
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -48,7 +48,12 @@ _SEND_NOW_RESULT_KEYS = (
|
||||
"failed_count",
|
||||
"outcome_unknown_count",
|
||||
"skipped_count",
|
||||
"paused_count",
|
||||
"preflight_count",
|
||||
"batch_state",
|
||||
"batch_pause_reason_code",
|
||||
"smtp_connection_count",
|
||||
"smtp_reconnect_count",
|
||||
"delivery_mode",
|
||||
"dry_run",
|
||||
)
|
||||
@@ -71,6 +76,8 @@ _SYNCHRONOUS_POLICY_KEYS = (
|
||||
"source",
|
||||
"deployment_max_recipient_jobs",
|
||||
"tenant_max_recipient_jobs",
|
||||
"system_max_recipient_jobs",
|
||||
"deployment_ceiling_explicit",
|
||||
)
|
||||
_VALIDATION_SUMMARY_KEYS = ("ok", "error_count", "warning_count")
|
||||
_BUILD_SUMMARY_KEYS = (
|
||||
|
||||
@@ -31,7 +31,13 @@ from govoplan_core.core.object_storage import (
|
||||
from govoplan_core.core.runtime_coordination import process_runtime_identity
|
||||
from govoplan_core.db.session import get_database
|
||||
from govoplan_core.settings import settings as core_settings
|
||||
from govoplan_campaign.backend.db.models import CampaignJob, CampaignVersion, JobImapStatus, JobQueueStatus
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
CampaignJob,
|
||||
CampaignSchedule,
|
||||
CampaignVersion,
|
||||
JobImapStatus,
|
||||
JobQueueStatus,
|
||||
)
|
||||
from govoplan_campaign.backend.runtime import get_settings
|
||||
|
||||
FINAL_VERSION_STATES = {
|
||||
@@ -351,6 +357,18 @@ def _apply_eml_retention(
|
||||
"delete_failed": 0,
|
||||
"recovery_blocked": 0,
|
||||
"skipped_not_final": 0,
|
||||
"skipped_schedule_source": 0,
|
||||
}
|
||||
protected_source_versions = {
|
||||
str(version_id)
|
||||
for (version_id,) in (
|
||||
session.query(CampaignSchedule.source_version_id)
|
||||
.filter(
|
||||
CampaignSchedule.delivery_mode == "autonomous",
|
||||
CampaignSchedule.next_fire_at.is_not(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
}
|
||||
jobs = (
|
||||
session.query(CampaignJob)
|
||||
@@ -359,6 +377,9 @@ def _apply_eml_retention(
|
||||
.all()
|
||||
)
|
||||
for job in jobs:
|
||||
if getattr(job, "campaign_version_id", None) in protected_source_versions:
|
||||
result["skipped_schedule_source"] += 1
|
||||
continue
|
||||
policy = policy_for_campaign_id(job.campaign_id)
|
||||
cutoff = _cutoff(policy.generated_eml_retention_days, now=now)
|
||||
if not _is_before_cutoff(job.updated_at, cutoff):
|
||||
|
||||
@@ -12,6 +12,12 @@ from sqlalchemy.orm import Session
|
||||
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
||||
CAMPAIGN_MAIL_SERVER_KEYS,
|
||||
campaign_mail_profile_id,
|
||||
campaign_mail_references_unchanged,
|
||||
campaign_preserves_legacy_mail_settings,
|
||||
)
|
||||
from govoplan_campaign.backend.archive_encryption import (
|
||||
CampaignArchiveEncryptionError,
|
||||
stamp_legacy_zipcrypto_acknowledgements,
|
||||
)
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
@@ -388,7 +394,9 @@ def _update_campaign_version_detail_response(
|
||||
autosave: bool,
|
||||
audit_action: str,
|
||||
) -> CampaignVersionDetailResponse:
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
campaign = _get_campaign_for_principal(
|
||||
session, campaign_id, principal, write=True
|
||||
)
|
||||
current_version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
||||
if payload.base_revision is None:
|
||||
error = MissingPreconditionError(
|
||||
@@ -421,9 +429,51 @@ def _update_campaign_version_detail_response(
|
||||
) from exc
|
||||
if _recipient_sections_changed(current_version.raw_json, payload.campaign_json):
|
||||
_require_permission(principal, "campaigns:recipient:write")
|
||||
_require_mail_profile_use_if_needed(principal, payload.campaign_json)
|
||||
acknowledgements: list[dict[str, Any]] = []
|
||||
try:
|
||||
return _campaign_version_detail_response(
|
||||
payload.campaign_json, acknowledgements = (
|
||||
stamp_legacy_zipcrypto_acknowledgements(
|
||||
session,
|
||||
campaign,
|
||||
current_version.raw_json
|
||||
if isinstance(current_version.raw_json, dict)
|
||||
else {},
|
||||
payload.campaign_json,
|
||||
principal=principal,
|
||||
)
|
||||
)
|
||||
except CampaignArchiveEncryptionError as exc:
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.archive_encryption_denied",
|
||||
object_type="campaign_version",
|
||||
object_id=version_id,
|
||||
details={"campaign_id": campaign_id, "reason": str(exc)},
|
||||
commit=True,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=(
|
||||
status.HTTP_403_FORBIDDEN
|
||||
if "Missing scope:" in str(exc)
|
||||
else status.HTTP_422_UNPROCESSABLE_CONTENT
|
||||
),
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
preserves_legacy_mail = (
|
||||
not payload.migrate_legacy_mail_settings
|
||||
and campaign_preserves_legacy_mail_settings(
|
||||
current_version.raw_json, payload.campaign_json
|
||||
)
|
||||
)
|
||||
unchanged_mail_selection = (
|
||||
not payload.migrate_legacy_mail_settings
|
||||
and campaign_mail_references_unchanged(current_version.raw_json, payload.campaign_json)
|
||||
)
|
||||
if not unchanged_mail_selection:
|
||||
_require_mail_profile_use_if_needed(principal, payload.campaign_json)
|
||||
try:
|
||||
result = _campaign_version_detail_response(
|
||||
session,
|
||||
principal,
|
||||
campaign_id,
|
||||
@@ -462,9 +512,24 @@ def _update_campaign_version_detail_response(
|
||||
}
|
||||
),
|
||||
"legacy_mail_settings_migrated": payload.migrate_legacy_mail_settings,
|
||||
"legacy_mail_settings_preserved": preserves_legacy_mail,
|
||||
"legacy_zipcrypto_acknowledgements": acknowledgements,
|
||||
},
|
||||
validation_error_status=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
)
|
||||
for acknowledgement in acknowledgements:
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.legacy_zipcrypto_acknowledged",
|
||||
object_type="campaign_version",
|
||||
object_id=version_id,
|
||||
details={"campaign_id": campaign_id, **acknowledgement},
|
||||
commit=False,
|
||||
)
|
||||
if acknowledgements:
|
||||
session.commit()
|
||||
return result
|
||||
except RevisionConflictError as exc:
|
||||
session.rollback()
|
||||
audit_from_principal(
|
||||
|
||||
@@ -3,22 +3,32 @@ from __future__ import annotations
|
||||
from fastapi import APIRouter
|
||||
|
||||
from govoplan_campaign.backend.routes.attachments import router as attachments_router
|
||||
from govoplan_campaign.backend.routes.assignments import router as assignments_router
|
||||
from govoplan_campaign.backend.routes.campaigns import router as campaigns_router
|
||||
from govoplan_campaign.backend.routes.collaboration import router as collaboration_router
|
||||
from govoplan_campaign.backend.routes.delivery import router as delivery_router
|
||||
from govoplan_campaign.backend.routes.delivery_settings import router as delivery_settings_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.transfers import router as transfers_router
|
||||
from govoplan_campaign.backend.routes.versions import router as versions_router
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
for workflow_router in (
|
||||
delivery_settings_router,
|
||||
operations_router,
|
||||
transfers_router,
|
||||
campaigns_router,
|
||||
assignments_router,
|
||||
collaboration_router,
|
||||
versions_router,
|
||||
jobs_router,
|
||||
reports_router,
|
||||
schedules_router,
|
||||
sharing_router,
|
||||
delivery_router,
|
||||
attachments_router,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import dataclasses
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
@@ -14,6 +15,7 @@ from govoplan_campaign.backend.schemas import (
|
||||
CampaignCreateResponse,
|
||||
CampaignCreateMinimalRequest,
|
||||
CampaignCopyRequest,
|
||||
CampaignContentLibrarySaveRequest,
|
||||
CampaignLifecycleMutationRequest,
|
||||
CampaignLifecyclePolicyResponse,
|
||||
CampaignAddressLookupCandidate,
|
||||
@@ -42,6 +44,11 @@ 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,
|
||||
TemplateFieldRequirement,
|
||||
TemplateRef,
|
||||
)
|
||||
from govoplan_core.core.change_sequence import (
|
||||
decode_sequence_watermark,
|
||||
encode_sequence_watermark,
|
||||
@@ -60,6 +67,7 @@ from govoplan_campaign.backend.change_tracking import (
|
||||
)
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignShare,
|
||||
CampaignVersion,
|
||||
RecipientImportMappingProfile,
|
||||
)
|
||||
@@ -70,12 +78,14 @@ 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,
|
||||
postbox_integration,
|
||||
templates_integration,
|
||||
)
|
||||
from govoplan_campaign.backend.template_rendering import find_unresolved_placeholders
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_core.core.distribution_lists import (
|
||||
CAPABILITY_DISTRIBUTION_LIST_EXPAND,
|
||||
@@ -120,9 +130,22 @@ from govoplan_campaign.backend.route_support import (
|
||||
_write_current_version_snapshot_if_available,
|
||||
bounded_query_rows as _bounded_query_rows,
|
||||
)
|
||||
from govoplan_campaign.backend.archive_encryption import (
|
||||
effective_archive_encryption_policy,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/archive-encryption-policy")
|
||||
def campaign_archive_encryption_policy(
|
||||
campaign_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
|
||||
):
|
||||
campaign = _get_campaign_for_principal(session, campaign_id, principal)
|
||||
return effective_archive_encryption_policy(session, campaign).to_dict()
|
||||
|
||||
CAPABILITY_ADDRESSES_LOOKUP = "addresses.lookup"
|
||||
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE = "addresses.recipient_source"
|
||||
|
||||
@@ -214,6 +237,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 +954,229 @@ 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",
|
||||
"required_fields": [
|
||||
dataclasses.asdict(field) for field in revision.required_fields
|
||||
] if revision else [],
|
||||
}
|
||||
|
||||
|
||||
def _campaign_content_required_fields(
|
||||
payload: CampaignContentLibrarySaveRequest,
|
||||
) -> tuple[TemplateFieldRequirement, ...]:
|
||||
if payload.kind == "fragment":
|
||||
values = (
|
||||
payload.subject if payload.target == "subject"
|
||||
else payload.html if payload.target == "html"
|
||||
else payload.text,
|
||||
)
|
||||
else:
|
||||
values = (
|
||||
payload.subject,
|
||||
payload.text if payload.body_mode != "html" else None,
|
||||
payload.html if payload.body_mode != "text" else None,
|
||||
)
|
||||
paths = {
|
||||
path
|
||||
for value in values
|
||||
for key in find_unresolved_placeholders(value)
|
||||
if (path := _campaign_content_requirement_path(key)) is not None
|
||||
}
|
||||
return tuple(
|
||||
TemplateFieldRequirement(path=path, label=path)
|
||||
for path in sorted(paths)
|
||||
)
|
||||
|
||||
|
||||
def _campaign_content_requirement_path(key: str) -> str | None:
|
||||
path = key.replace("local::", "local.", 1).replace("global::", "global.", 1)
|
||||
path = re.sub(r"\[([1-9][0-9]*)\]", r".\1", path)
|
||||
return path if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_.-]*", path) else None
|
||||
|
||||
|
||||
@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,
|
||||
required_fields=_campaign_content_required_fields(payload),
|
||||
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 +1905,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 +1930,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 +1952,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 +1993,14 @@ def copy_campaign(
|
||||
"source_version_id": source_version.id,
|
||||
"destination_version_id": version.id,
|
||||
"copied_evidence": False,
|
||||
"copy_options": {
|
||||
"recipients": payload.include_recipients,
|
||||
"files": payload.include_files,
|
||||
"shares": payload.include_shares,
|
||||
"policies": payload.include_policies,
|
||||
"mail_profile": payload.include_mail_profile,
|
||||
},
|
||||
"copied_share_count": copied_share_count,
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,571 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import and_, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignCollaborationEntry,
|
||||
CampaignJob,
|
||||
CampaignShare,
|
||||
CampaignVersion,
|
||||
)
|
||||
from govoplan_campaign.backend.path_security import _attachment_rules
|
||||
from govoplan_campaign.backend.route_support import (
|
||||
_access_directory,
|
||||
_get_campaign_for_principal,
|
||||
_require_permission,
|
||||
)
|
||||
from govoplan_campaign.backend.runtime import get_registry
|
||||
from govoplan_campaign.backend.schemas import (
|
||||
CampaignCollaborationCreateRequest,
|
||||
CampaignCollaborationEntryResponse,
|
||||
CampaignCollaborationListResponse,
|
||||
CampaignCollaborationModerationRequest,
|
||||
CampaignCollaborationReferenceInput,
|
||||
CampaignCollaborationReferenceResponse,
|
||||
)
|
||||
from govoplan_core.api.v1.schemas import (
|
||||
ReferenceOptionListResponse,
|
||||
ReferenceOptionResponse,
|
||||
)
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_core.auth import ApiPrincipal, has_scope, require_scope
|
||||
from govoplan_core.core.notifications import (
|
||||
NotificationDispatchRequest,
|
||||
notification_dispatch_provider,
|
||||
)
|
||||
from govoplan_core.core.references import (
|
||||
access_scope_reference_page,
|
||||
access_scope_reference_provider_available,
|
||||
)
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_core.security.time import utc_now
|
||||
|
||||
|
||||
router = APIRouter(prefix="/campaigns", tags=["campaign collaboration"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{campaign_id}/collaboration/mention-options",
|
||||
response_model=ReferenceOptionListResponse,
|
||||
)
|
||||
def search_campaign_collaboration_mentions(
|
||||
campaign_id: str,
|
||||
q: str = "",
|
||||
selected: list[str] = Query(default=[]),
|
||||
limit: int = Query(default=50, ge=1, le=100),
|
||||
cursor: str | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:discussion:post")),
|
||||
) -> ReferenceOptionListResponse:
|
||||
campaign = _get_campaign_for_principal(session, campaign_id, principal)
|
||||
_require_permission(principal, "campaigns:campaign:read")
|
||||
try:
|
||||
page = access_scope_reference_page(
|
||||
get_registry(),
|
||||
principal,
|
||||
scope_type="user",
|
||||
reference_kind="membership",
|
||||
query=q,
|
||||
selected_values=selected,
|
||||
limit=limit,
|
||||
cursor=cursor,
|
||||
administrative=True,
|
||||
session=session,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
allowed = [
|
||||
option
|
||||
for option in page.options
|
||||
if _mentioned_user_has_campaign_access(
|
||||
session,
|
||||
campaign=campaign,
|
||||
user_id=option.value,
|
||||
)
|
||||
]
|
||||
return ReferenceOptionListResponse(
|
||||
options=[ReferenceOptionResponse(**option.to_dict()) for option in allowed],
|
||||
provider_available=access_scope_reference_provider_available(get_registry()),
|
||||
next_cursor=page.next_cursor,
|
||||
has_more=page.has_more,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{campaign_id}/collaboration",
|
||||
response_model=CampaignCollaborationListResponse,
|
||||
)
|
||||
def list_campaign_collaboration(
|
||||
campaign_id: str,
|
||||
limit: int = Query(default=25, ge=1, le=50),
|
||||
cursor: str | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:discussion:read")),
|
||||
) -> CampaignCollaborationListResponse:
|
||||
campaign = _get_campaign_for_principal(session, campaign_id, principal)
|
||||
_require_permission(principal, "campaigns:campaign:read")
|
||||
query = session.query(CampaignCollaborationEntry).filter(
|
||||
CampaignCollaborationEntry.tenant_id == principal.tenant_id,
|
||||
CampaignCollaborationEntry.campaign_id == campaign.id,
|
||||
)
|
||||
if not has_scope(principal, "campaigns:discussion:moderate"):
|
||||
query = query.filter(CampaignCollaborationEntry.visibility == "collaborators")
|
||||
if cursor:
|
||||
created_at, entry_id = _decode_cursor(cursor)
|
||||
query = query.filter(
|
||||
or_(
|
||||
CampaignCollaborationEntry.created_at < created_at,
|
||||
and_(
|
||||
CampaignCollaborationEntry.created_at == created_at,
|
||||
CampaignCollaborationEntry.id < entry_id,
|
||||
),
|
||||
)
|
||||
)
|
||||
rows = (
|
||||
query.order_by(
|
||||
CampaignCollaborationEntry.created_at.desc(),
|
||||
CampaignCollaborationEntry.id.desc(),
|
||||
)
|
||||
.limit(limit + 1)
|
||||
.all()
|
||||
)
|
||||
has_more = len(rows) > limit
|
||||
items = rows[:limit]
|
||||
return CampaignCollaborationListResponse(
|
||||
items=[_entry_response(item) for item in items],
|
||||
next_cursor=_encode_cursor(items[-1]) if has_more and items else None,
|
||||
has_more=has_more,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/collaboration",
|
||||
response_model=CampaignCollaborationEntryResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_campaign_collaboration_entry(
|
||||
campaign_id: str,
|
||||
payload: CampaignCollaborationCreateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:discussion:post")),
|
||||
) -> CampaignCollaborationEntryResponse:
|
||||
campaign = _get_campaign_for_principal(session, campaign_id, principal)
|
||||
_require_permission(principal, "campaigns:campaign:read")
|
||||
if payload.visibility == "moderators":
|
||||
_require_permission(principal, "campaigns:discussion:moderate")
|
||||
reference = _validated_reference(
|
||||
session,
|
||||
campaign=campaign,
|
||||
reference=payload.reference,
|
||||
)
|
||||
mentions = _validated_mentions(
|
||||
session,
|
||||
campaign=campaign,
|
||||
user_ids=payload.mention_user_ids,
|
||||
actor_user_id=principal.user.id,
|
||||
)
|
||||
actor_label = (
|
||||
getattr(principal.user, "display_name", None)
|
||||
or getattr(principal.user, "email", None)
|
||||
or principal.user.id
|
||||
)
|
||||
entry = CampaignCollaborationEntry(
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
campaign_version_id=reference[0] if reference else None,
|
||||
reference_kind=reference[1] if reference else None,
|
||||
reference_id=reference[2] if reference else None,
|
||||
reference_label=reference[3] if reference else None,
|
||||
actor_user_id=principal.user.id,
|
||||
actor_label_snapshot=str(actor_label)[:255],
|
||||
visibility=payload.visibility,
|
||||
content=payload.content,
|
||||
content_sha256=hashlib.sha256(payload.content.encode("utf-8")).hexdigest(),
|
||||
mention_user_ids=mentions,
|
||||
)
|
||||
session.add(entry)
|
||||
session.flush()
|
||||
_enqueue_mention_notifications(
|
||||
session,
|
||||
campaign=campaign,
|
||||
entry=entry,
|
||||
mention_user_ids=mentions,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.collaboration.posted",
|
||||
object_type="campaign_collaboration_entry",
|
||||
object_id=entry.id,
|
||||
details={
|
||||
"campaign_id": campaign.id,
|
||||
"campaign_version_id": entry.campaign_version_id,
|
||||
"visibility": entry.visibility,
|
||||
"reference_kind": entry.reference_kind,
|
||||
"reference_id": entry.reference_id,
|
||||
"mention_count": len(mentions),
|
||||
"content_sha256": entry.content_sha256,
|
||||
"content_disclosed": False,
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
session.refresh(entry)
|
||||
return _entry_response(entry)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/collaboration/{entry_id}/withdraw",
|
||||
response_model=CampaignCollaborationEntryResponse,
|
||||
)
|
||||
def withdraw_campaign_collaboration_entry(
|
||||
campaign_id: str,
|
||||
entry_id: str,
|
||||
payload: CampaignCollaborationModerationRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:discussion:post")),
|
||||
) -> CampaignCollaborationEntryResponse:
|
||||
_get_campaign_for_principal(session, campaign_id, principal)
|
||||
_require_permission(principal, "campaigns:campaign:read")
|
||||
entry = _entry_for_campaign(session, campaign_id=campaign_id, entry_id=entry_id, principal=principal)
|
||||
if entry.actor_user_id != principal.user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only the author can withdraw this collaboration entry.",
|
||||
)
|
||||
if entry.redacted_at is not None or entry.withdrawn_at is not None:
|
||||
return _entry_response(entry)
|
||||
entry.content = None
|
||||
entry.withdrawn_at = utc_now()
|
||||
entry.withdrawn_by_user_id = principal.user.id
|
||||
entry.tombstone_reason = payload.reason
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.collaboration.withdrawn",
|
||||
object_type="campaign_collaboration_entry",
|
||||
object_id=entry.id,
|
||||
details={
|
||||
"campaign_id": campaign_id,
|
||||
"content_sha256": entry.content_sha256,
|
||||
"reason_recorded": bool(payload.reason),
|
||||
"content_disclosed": False,
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
session.refresh(entry)
|
||||
return _entry_response(entry)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/collaboration/{entry_id}/redact",
|
||||
response_model=CampaignCollaborationEntryResponse,
|
||||
)
|
||||
def redact_campaign_collaboration_entry(
|
||||
campaign_id: str,
|
||||
entry_id: str,
|
||||
payload: CampaignCollaborationModerationRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:discussion:moderate")),
|
||||
) -> CampaignCollaborationEntryResponse:
|
||||
_get_campaign_for_principal(session, campaign_id, principal)
|
||||
_require_permission(principal, "campaigns:campaign:read")
|
||||
entry = _entry_for_campaign(session, campaign_id=campaign_id, entry_id=entry_id, principal=principal)
|
||||
if entry.redacted_at is not None:
|
||||
return _entry_response(entry)
|
||||
entry.content = None
|
||||
entry.redacted_at = utc_now()
|
||||
entry.redacted_by_user_id = principal.user.id
|
||||
entry.tombstone_reason = payload.reason
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.collaboration.redacted",
|
||||
object_type="campaign_collaboration_entry",
|
||||
object_id=entry.id,
|
||||
details={
|
||||
"campaign_id": campaign_id,
|
||||
"content_sha256": entry.content_sha256,
|
||||
"reason_recorded": bool(payload.reason),
|
||||
"previously_withdrawn": entry.withdrawn_at is not None,
|
||||
"content_disclosed": False,
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
session.refresh(entry)
|
||||
return _entry_response(entry)
|
||||
|
||||
|
||||
def _entry_for_campaign(
|
||||
session: Session,
|
||||
*,
|
||||
campaign_id: str,
|
||||
entry_id: str,
|
||||
principal: ApiPrincipal,
|
||||
) -> CampaignCollaborationEntry:
|
||||
entry = session.get(CampaignCollaborationEntry, entry_id)
|
||||
if (
|
||||
entry is None
|
||||
or entry.tenant_id != principal.tenant_id
|
||||
or entry.campaign_id != campaign_id
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Collaboration entry not found")
|
||||
return entry
|
||||
|
||||
|
||||
def _validated_reference(
|
||||
session: Session,
|
||||
*,
|
||||
campaign: Campaign,
|
||||
reference: CampaignCollaborationReferenceInput | None,
|
||||
) -> tuple[str | None, str, str, str] | None:
|
||||
if reference is None:
|
||||
return None
|
||||
kind = reference.kind
|
||||
reference_id = reference.id
|
||||
version: CampaignVersion | None = None
|
||||
default_label = kind.replace("_", " ").title()
|
||||
if kind == "campaign_version":
|
||||
version = session.get(CampaignVersion, reference_id)
|
||||
if version is not None:
|
||||
default_label = f"Version {version.version_number}"
|
||||
elif kind == "delivery_job":
|
||||
job = session.get(CampaignJob, reference_id)
|
||||
if job is None or job.campaign_id != campaign.id or job.tenant_id != campaign.tenant_id:
|
||||
raise _invalid_reference()
|
||||
version = session.get(CampaignVersion, job.campaign_version_id)
|
||||
default_label = f"Delivery job {job.id[:8]}"
|
||||
elif kind in {"recipient_import_batch", "attachment_rule"}:
|
||||
version_id, separator, child_id = reference_id.partition(":")
|
||||
if not separator or not version_id or not child_id:
|
||||
raise _invalid_reference()
|
||||
version = session.get(CampaignVersion, version_id)
|
||||
if version is not None and kind == "recipient_import_batch":
|
||||
raw = version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||
entries = raw.get("entries") if isinstance(raw.get("entries"), dict) else {}
|
||||
imports = entries.get("imports") if isinstance(entries, dict) else []
|
||||
if not any(isinstance(item, dict) and str(item.get("id") or "") == child_id for item in imports or []):
|
||||
raise _invalid_reference()
|
||||
default_label = "Recipient import batch"
|
||||
elif version is not None:
|
||||
raw = version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||
if child_id not in {path for path, _rule in _attachment_rules(raw)}:
|
||||
raise _invalid_reference()
|
||||
default_label = "Attachment rule"
|
||||
else:
|
||||
referenced_campaign_id, separator, remainder = reference_id.partition(":")
|
||||
version_id, separator_two, report_kind = remainder.partition(":")
|
||||
if (
|
||||
not separator
|
||||
or not separator_two
|
||||
or referenced_campaign_id != campaign.id
|
||||
or not version_id
|
||||
or not report_kind
|
||||
):
|
||||
raise _invalid_reference()
|
||||
version = session.get(CampaignVersion, version_id)
|
||||
default_label = report_kind.replace("_", " ").title()
|
||||
if version is None or version.campaign_id != campaign.id:
|
||||
raise _invalid_reference()
|
||||
return version.id, kind, reference_id, default_label[:255]
|
||||
|
||||
|
||||
def _invalid_reference() -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="The collaboration reference is not stable evidence owned by this campaign.",
|
||||
)
|
||||
|
||||
|
||||
def _validated_mentions(
|
||||
session: Session,
|
||||
*,
|
||||
campaign: Campaign,
|
||||
user_ids: list[str],
|
||||
actor_user_id: str,
|
||||
) -> list[str]:
|
||||
mentions = [user_id for user_id in user_ids if user_id != actor_user_id]
|
||||
invalid = [
|
||||
user_id
|
||||
for user_id in mentions
|
||||
if not _mentioned_user_has_campaign_access(
|
||||
session,
|
||||
campaign=campaign,
|
||||
user_id=user_id,
|
||||
)
|
||||
]
|
||||
if invalid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="Mentioned users must be active and already have access to this campaign.",
|
||||
)
|
||||
return mentions
|
||||
|
||||
|
||||
def _mentioned_user_has_campaign_access(
|
||||
session: Session,
|
||||
*,
|
||||
campaign: Campaign,
|
||||
user_id: str,
|
||||
) -> bool:
|
||||
directory = _access_directory()
|
||||
user = next(
|
||||
(candidate for candidate in directory.users_for_tenant(campaign.tenant_id) if candidate.id == user_id),
|
||||
None,
|
||||
)
|
||||
if user is None or user.status != "active":
|
||||
return False
|
||||
if campaign.owner_user_id == user_id:
|
||||
return True
|
||||
group_ids = {
|
||||
group.id
|
||||
for group in directory.groups_for_user(user_id, tenant_id=campaign.tenant_id)
|
||||
}
|
||||
if campaign.owner_group_id and campaign.owner_group_id in group_ids:
|
||||
return True
|
||||
clauses = [
|
||||
and_(
|
||||
CampaignShare.target_type == "user",
|
||||
CampaignShare.target_id == user_id,
|
||||
)
|
||||
]
|
||||
if group_ids:
|
||||
clauses.append(
|
||||
and_(
|
||||
CampaignShare.target_type == "group",
|
||||
CampaignShare.target_id.in_(sorted(group_ids)),
|
||||
)
|
||||
)
|
||||
return (
|
||||
session.query(CampaignShare.id)
|
||||
.filter(
|
||||
CampaignShare.tenant_id == campaign.tenant_id,
|
||||
CampaignShare.campaign_id == campaign.id,
|
||||
CampaignShare.revoked_at.is_(None),
|
||||
or_(*clauses),
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def _enqueue_mention_notifications(
|
||||
session: Session,
|
||||
*,
|
||||
campaign: Campaign,
|
||||
entry: CampaignCollaborationEntry,
|
||||
mention_user_ids: list[str],
|
||||
) -> None:
|
||||
provider = notification_dispatch_provider(get_registry())
|
||||
if provider is None or not mention_user_ids:
|
||||
return
|
||||
try:
|
||||
with session.begin_nested():
|
||||
for user_id in mention_user_ids:
|
||||
provider.enqueue_notification(
|
||||
session,
|
||||
NotificationDispatchRequest(
|
||||
tenant_id=campaign.tenant_id,
|
||||
source_module="campaigns",
|
||||
source_resource_type="campaign_collaboration_entry",
|
||||
source_resource_id=entry.id,
|
||||
event_kind="campaign.collaboration.mentioned",
|
||||
channel="inbox",
|
||||
recipient_type="user",
|
||||
recipient_id=user_id,
|
||||
subject=f"Mentioned in campaign: {campaign.name}",
|
||||
body_text=(
|
||||
f"{entry.actor_label_snapshot} mentioned you in the campaign collaboration thread."
|
||||
),
|
||||
action_url=f"/campaigns/{campaign.id}/activity",
|
||||
payload={
|
||||
"campaign_id": campaign.id,
|
||||
"entry_id": entry.id,
|
||||
"content_disclosed": False,
|
||||
},
|
||||
),
|
||||
enqueue_delivery=False,
|
||||
)
|
||||
except Exception:
|
||||
# Collaboration remains available when the optional Notifications
|
||||
# provider is absent or temporarily unhealthy.
|
||||
return
|
||||
|
||||
|
||||
def _entry_response(entry: CampaignCollaborationEntry) -> CampaignCollaborationEntryResponse:
|
||||
tombstone: Literal["withdrawn", "redacted"] | None = None
|
||||
if entry.redacted_at is not None:
|
||||
tombstone = "redacted"
|
||||
elif entry.withdrawn_at is not None:
|
||||
tombstone = "withdrawn"
|
||||
reference = None
|
||||
if entry.reference_kind and entry.reference_id:
|
||||
reference = CampaignCollaborationReferenceResponse(
|
||||
kind=entry.reference_kind, # type: ignore[arg-type]
|
||||
id=entry.reference_id,
|
||||
label=entry.reference_label,
|
||||
)
|
||||
return CampaignCollaborationEntryResponse(
|
||||
id=entry.id,
|
||||
campaign_id=entry.campaign_id,
|
||||
actor_user_id=entry.actor_user_id,
|
||||
actor_label=entry.actor_label_snapshot,
|
||||
visibility=entry.visibility, # type: ignore[arg-type]
|
||||
content=entry.content if tombstone is None else None,
|
||||
content_sha256=entry.content_sha256,
|
||||
mention_user_ids=list(entry.mention_user_ids or []),
|
||||
reference=reference,
|
||||
tombstone=tombstone,
|
||||
tombstone_reason=entry.tombstone_reason,
|
||||
withdrawn_at=entry.withdrawn_at,
|
||||
redacted_at=entry.redacted_at,
|
||||
created_at=entry.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _encode_cursor(entry: CampaignCollaborationEntry) -> str:
|
||||
created_at = entry.created_at
|
||||
if created_at.tzinfo is None:
|
||||
# SQLite returns timezone-aware columns as naive UTC values.
|
||||
created_at = created_at.replace(tzinfo=UTC)
|
||||
payload = json.dumps(
|
||||
{"created_at": created_at.astimezone(UTC).isoformat(), "id": entry.id},
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
).encode("utf-8")
|
||||
return base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _decode_cursor(value: str) -> tuple[datetime, str]:
|
||||
try:
|
||||
padded = value + "=" * (-len(value) % 4)
|
||||
payload = json.loads(base64.urlsafe_b64decode(padded).decode("utf-8"))
|
||||
created_at = datetime.fromisoformat(str(payload["created_at"]))
|
||||
entry_id = str(payload["id"])
|
||||
if created_at.tzinfo is None or not entry_id or len(entry_id) > 36:
|
||||
raise ValueError
|
||||
return created_at, entry_id
|
||||
except (
|
||||
KeyError,
|
||||
TypeError,
|
||||
ValueError,
|
||||
UnicodeDecodeError,
|
||||
json.JSONDecodeError,
|
||||
binascii.Error,
|
||||
) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="Invalid collaboration cursor.",
|
||||
) from exc
|
||||
@@ -12,6 +12,7 @@ from govoplan_campaign.backend.schemas import (
|
||||
CampaignSendJobRequest,
|
||||
CampaignSendUnattemptedRequest,
|
||||
CampaignResolveOutcomeRequest,
|
||||
CampaignRecoverClaimRequest,
|
||||
CampaignDeliveryOptionsResponse,
|
||||
MockCampaignSendRequest,
|
||||
MockCampaignSendResponse,
|
||||
@@ -84,6 +85,32 @@ router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/delivery-progress")
|
||||
def get_campaign_delivery_progress(
|
||||
campaign_id: str,
|
||||
version_id: str | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
|
||||
):
|
||||
from govoplan_campaign.backend.services.delivery_progress import campaign_delivery_progress
|
||||
|
||||
_get_campaign_for_principal(session, campaign_id, principal)
|
||||
try:
|
||||
return campaign_delivery_progress(session, tenant_id=principal.tenant_id, campaign_id=campaign_id, version_id=version_id)
|
||||
except QueueingError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
def _public_recovery_result(result: dict) -> dict:
|
||||
if not result.get("run_inline"):
|
||||
return result
|
||||
public = public_send_campaign_now_result(result, validation_summary={}, build_summary={})
|
||||
for key in ("action", "selected_count", "remaining_count", "enqueued_count", "skipped", "run_inline"):
|
||||
if key in result:
|
||||
public[key] = result[key]
|
||||
return public
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{campaign_id}/delivery-options", response_model=CampaignDeliveryOptionsResponse
|
||||
)
|
||||
@@ -242,6 +269,8 @@ def retry_campaign_jobs(
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
payload = payload or CampaignRetryJobsRequest()
|
||||
if payload.run_inline:
|
||||
_require_permission(principal, "campaigns:campaign:send")
|
||||
_require_campaign_profile_use_if_needed(
|
||||
session, principal, campaign_id, payload.version_id
|
||||
)
|
||||
@@ -255,6 +284,7 @@ def retry_campaign_jobs(
|
||||
include_permanent=payload.include_permanent,
|
||||
force_max_attempts=payload.force_max_attempts,
|
||||
enqueue_celery=payload.enqueue_celery,
|
||||
run_inline=payload.run_inline,
|
||||
dry_run=payload.dry_run,
|
||||
)
|
||||
audit_from_principal(
|
||||
@@ -265,10 +295,10 @@ def retry_campaign_jobs(
|
||||
else "campaign.jobs_retry_dry_run",
|
||||
object_type="campaign",
|
||||
object_id=campaign_id,
|
||||
details=result,
|
||||
details=_public_recovery_result(result),
|
||||
commit=True,
|
||||
)
|
||||
return CampaignActionResponse(result=result)
|
||||
return CampaignActionResponse(result=_public_recovery_result(result))
|
||||
except (QueueingError, ExecutionSnapshotError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
@@ -287,6 +317,8 @@ def send_unattempted_campaign_jobs(
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
payload = payload or CampaignSendUnattemptedRequest()
|
||||
if payload.run_inline:
|
||||
_require_permission(principal, "campaigns:campaign:send")
|
||||
_require_campaign_profile_use_if_needed(
|
||||
session, principal, campaign_id, payload.version_id
|
||||
)
|
||||
@@ -298,6 +330,7 @@ def send_unattempted_campaign_jobs(
|
||||
version_id=payload.version_id,
|
||||
job_ids=payload.job_ids or None,
|
||||
enqueue_celery=payload.enqueue_celery,
|
||||
run_inline=payload.run_inline,
|
||||
dry_run=payload.dry_run,
|
||||
)
|
||||
audit_from_principal(
|
||||
@@ -308,10 +341,10 @@ def send_unattempted_campaign_jobs(
|
||||
else "campaign.unattempted_jobs_dry_run",
|
||||
object_type="campaign",
|
||||
object_id=campaign_id,
|
||||
details=result,
|
||||
details=_public_recovery_result(result),
|
||||
commit=True,
|
||||
)
|
||||
return CampaignActionResponse(result=result)
|
||||
return CampaignActionResponse(result=_public_recovery_result(result))
|
||||
except (QueueingError, ExecutionSnapshotError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
@@ -387,6 +420,34 @@ def send_single_campaign_job_endpoint(
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/jobs/{job_id}/recover-claim", response_model=CampaignActionResponse)
|
||||
def recover_campaign_job_claim(
|
||||
campaign_id: str,
|
||||
job_id: str,
|
||||
payload: CampaignRecoverClaimRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:reconcile")),
|
||||
):
|
||||
from govoplan_campaign.backend.services.delivery_recovery import recover_stale_delivery_claim, RecoveryStateConflict
|
||||
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
try:
|
||||
result = recover_stale_delivery_claim(
|
||||
session, tenant_id=principal.tenant_id, campaign_id=campaign_id,
|
||||
job_id=job_id, channel=payload.channel,
|
||||
expected_revision=payload.expected_revision, note=payload.note,
|
||||
)
|
||||
audit_from_principal(session, principal, action="campaign.job_claim_recovered", object_type="campaign_job", object_id=job_id, details=result, commit=True)
|
||||
return CampaignActionResponse(result=result)
|
||||
except (QueueingError, ExecutionSnapshotError) as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=409 if isinstance(exc, RecoveryStateConflict) else 422, detail=str(exc)) from exc
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/jobs/{job_id}/resolve-outcome",
|
||||
response_model=CampaignActionResponse,
|
||||
@@ -440,8 +501,9 @@ def mock_send_campaign(
|
||||
):
|
||||
"""Run a fully visible mock delivery flow without mutating campaign state.
|
||||
|
||||
The route validates and builds the selected version, then optionally records
|
||||
mock SMTP deliveries and mock IMAP appends. It never talks to the configured
|
||||
Authoring previews validate and build transiently; reviewed-build mode
|
||||
verifies frozen jobs/EML and completed review instead. Both optionally record
|
||||
mock SMTP deliveries and mock IMAP appends. Neither talks to the configured
|
||||
real SMTP/IMAP servers and it does not mark the version sent/final.
|
||||
"""
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
@@ -460,6 +522,7 @@ def mock_send_campaign(
|
||||
send=payload.send,
|
||||
include_warnings=payload.include_warnings,
|
||||
include_needs_review=payload.include_needs_review,
|
||||
use_reviewed_build=payload.use_reviewed_build,
|
||||
append_sent=payload.append_sent,
|
||||
clear_mailbox=payload.clear_mailbox,
|
||||
check_files=payload.check_files,
|
||||
@@ -475,6 +538,7 @@ def mock_send_campaign(
|
||||
details={
|
||||
"version_id": result.get("version_id"),
|
||||
"send_requested": payload.send,
|
||||
"use_reviewed_build": payload.use_reviewed_build,
|
||||
"sent_count": result.get("send", {}).get("sent_count"),
|
||||
"failed_count": result.get("send", {}).get("failed_count"),
|
||||
},
|
||||
@@ -703,30 +767,16 @@ def append_sent(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:send")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
payload = payload or AppendSentRequest()
|
||||
version_ids = {
|
||||
row[0]
|
||||
for row in session.query(CampaignJob.campaign_version_id)
|
||||
.filter(
|
||||
CampaignJob.tenant_id == principal.tenant_id,
|
||||
CampaignJob.campaign_id == campaign_id,
|
||||
CampaignJob.send_status.in_(
|
||||
[JobSendStatus.SMTP_ACCEPTED.value, JobSendStatus.SENT.value]
|
||||
),
|
||||
CampaignJob.imap_status.in_(
|
||||
[JobImapStatus.PENDING.value, JobImapStatus.FAILED.value]
|
||||
),
|
||||
)
|
||||
.distinct()
|
||||
.all()
|
||||
}
|
||||
_require_campaign_versions_profile_use(session, principal, campaign_id, version_ids)
|
||||
selected_version_id = payload.version_id or campaign.current_version_id
|
||||
_require_campaign_profile_use_if_needed(session, principal, campaign_id, selected_version_id)
|
||||
try:
|
||||
result = enqueue_pending_imap_appends(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=selected_version_id,
|
||||
enqueue_celery=payload.enqueue_celery,
|
||||
run_inline=payload.run_inline,
|
||||
dry_run=payload.dry_run,
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Audited, independently editable delivery limits; never a delivery command."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict, Field, StrictInt
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.admin.models import SystemSettings
|
||||
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID, get_system_settings
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_core.auth import ApiPrincipal, require_any_scope
|
||||
from govoplan_core.core.configuration_control import (
|
||||
ConfigurationControlError, configuration_value_digest,
|
||||
ensure_configuration_change_allowed, record_configuration_change_applied,
|
||||
)
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_core.tenancy.scope import Tenant
|
||||
from govoplan_campaign.backend.delivery_policy import (
|
||||
ABSOLUTE_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS,
|
||||
DEFAULT_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS,
|
||||
CAMPAIGN_DELIVERY_POLICY_SETTINGS_KEY, SYNCHRONOUS_SEND_MAX_SETTINGS_KEY,
|
||||
CampaignDeliveryPolicyError, effective_synchronous_send_policy,
|
||||
)
|
||||
from govoplan_campaign.backend.route_support import _require_permission
|
||||
|
||||
router = APIRouter(prefix="/campaigns/settings/delivery-policy", tags=["campaigns"])
|
||||
Scope = Literal["system", "tenant"]
|
||||
|
||||
|
||||
class DeliveryPolicyUpdate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
synchronous_send_max_recipients: StrictInt | None = Field(default=None, ge=0, le=500)
|
||||
expected_revision: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
def _state(session: Session, principal: ApiPrincipal, scope: Scope) -> dict:
|
||||
policy = effective_synchronous_send_policy(session, tenant_id=principal.tenant_id, apply_tenant_override=scope == "tenant")
|
||||
system_limit = policy.system_max_recipient_jobs
|
||||
# Resolve the parent without projecting the tenant's own override into it.
|
||||
parent_limit = min(policy.deployment_max_recipient_jobs, system_limit) if system_limit is not None else (
|
||||
policy.deployment_max_recipient_jobs if policy.deployment_ceiling_explicit else DEFAULT_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS
|
||||
)
|
||||
own = system_limit if scope == "system" else policy.tenant_max_recipient_jobs
|
||||
system = session.get(SystemSettings, SYSTEM_SETTINGS_ID)
|
||||
tenant = session.get(Tenant, principal.tenant_id) if scope == "tenant" else None
|
||||
def stored_revision(row):
|
||||
return ((row.settings or {}).get(CAMPAIGN_DELIVERY_POLICY_SETTINGS_KEY) or {}).get("revision", 0) if row else 0
|
||||
revision = configuration_value_digest({
|
||||
"scope": scope, "tenant_id": principal.tenant_id if scope == "tenant" else None,
|
||||
"own": own, "system": system_limit, "deployment": policy.deployment_max_recipient_jobs,
|
||||
"explicit_deployment": policy.deployment_ceiling_explicit,
|
||||
"system_revision": stored_revision(system), "tenant_revision": stored_revision(tenant),
|
||||
})
|
||||
return {
|
||||
"scope": scope, "synchronous_send_max_recipients": own, "revision": revision,
|
||||
"max_configurable_recipients": policy.deployment_max_recipient_jobs if scope == "system" else parent_limit,
|
||||
"effective_max_recipients": parent_limit if scope == "system" else policy.max_recipient_jobs,
|
||||
"inherited_max_recipients": (policy.deployment_max_recipient_jobs if policy.deployment_ceiling_explicit else DEFAULT_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS) if scope == "system" else parent_limit,
|
||||
"absolute_max_recipients": ABSOLUTE_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS,
|
||||
"deployment_ceiling_explicit": policy.deployment_ceiling_explicit,
|
||||
"deployment_max_recipients": policy.deployment_max_recipient_jobs,
|
||||
}
|
||||
|
||||
|
||||
def _scope_permission(principal: ApiPrincipal, scope: Scope, operation: str) -> None:
|
||||
_require_permission(principal, f"system:settings:{operation}" if scope == "system" else f"admin:policies:{operation}")
|
||||
|
||||
|
||||
@router.get("/{scope}")
|
||||
def read_delivery_policy(
|
||||
scope: Scope, session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope("system:settings:read", "admin:policies:read")),
|
||||
):
|
||||
_scope_permission(principal, scope, "read")
|
||||
try:
|
||||
return _state(session, principal, scope)
|
||||
except CampaignDeliveryPolicyError as exc:
|
||||
raise HTTPException(422, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.put("/{scope}")
|
||||
def update_delivery_policy(
|
||||
scope: Scope, payload: DeliveryPolicyUpdate, session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope("system:settings:write", "admin:policies:write")),
|
||||
):
|
||||
_scope_permission(principal, scope, "write")
|
||||
try:
|
||||
# Always lock in the same order; history and system policy share one Core row.
|
||||
system = session.query(SystemSettings).filter(SystemSettings.id == SYSTEM_SETTINGS_ID).populate_existing().with_for_update().one_or_none()
|
||||
if system is None:
|
||||
system = get_system_settings(session)
|
||||
target = system
|
||||
if scope == "tenant":
|
||||
target = session.query(Tenant).filter(Tenant.id == principal.tenant_id).populate_existing().with_for_update().one_or_none()
|
||||
if target is None:
|
||||
raise HTTPException(404, detail="Tenant not found")
|
||||
before = _state(session, principal, scope)
|
||||
if payload.expected_revision != before["revision"]:
|
||||
raise HTTPException(409, detail="Campaign delivery policy changed. Reload the saved policy before retrying; your draft has not been saved.")
|
||||
value = payload.synchronous_send_max_recipients
|
||||
if value is not None and value > before["max_configurable_recipients"]:
|
||||
raise HTTPException(422, detail=f"This scope may configure at most {before['max_configurable_recipients']} recipient jobs; inherited or explicit deployment ceilings cannot be raised here.")
|
||||
key = f"campaign_delivery_policy.{scope}"
|
||||
after_value = {SYNCHRONOUS_SEND_MAX_SETTINGS_KEY: value}
|
||||
approval = ensure_configuration_change_allowed(
|
||||
session, key=key, value=after_value, actor_user_id=principal.user.id,
|
||||
actor_scopes=tuple(principal.scopes), target={"scope": scope, "tenant_id": principal.tenant_id if scope == "tenant" else None},
|
||||
)
|
||||
settings = dict(target.settings or {})
|
||||
saved_policy = dict(settings.get(CAMPAIGN_DELIVERY_POLICY_SETTINGS_KEY) or {})
|
||||
saved_policy["revision"] = int(saved_policy.get("revision") or 0) + 1
|
||||
if value is None:
|
||||
saved_policy.pop(SYNCHRONOUS_SEND_MAX_SETTINGS_KEY, None)
|
||||
else:
|
||||
saved_policy[SYNCHRONOUS_SEND_MAX_SETTINGS_KEY] = value
|
||||
settings[CAMPAIGN_DELIVERY_POLICY_SETTINGS_KEY] = saved_policy
|
||||
target.settings = settings
|
||||
session.flush()
|
||||
record_configuration_change_applied(
|
||||
session, key=key, before_value={SYNCHRONOUS_SEND_MAX_SETTINGS_KEY: before[SYNCHRONOUS_SEND_MAX_SETTINGS_KEY]},
|
||||
after_value=after_value, actor_user_id=principal.user.id, approval=approval,
|
||||
target={"scope": scope, "tenant_id": principal.tenant_id if scope == "tenant" else None},
|
||||
audit_event="campaign.delivery_policy_updated",
|
||||
)
|
||||
result = _state(session, principal, scope)
|
||||
audit_from_principal(session, principal, action="campaign.delivery_policy_updated", scope=scope, object_type="campaign_delivery_policy",
|
||||
object_id=scope if scope == "system" else principal.tenant_id,
|
||||
details={"scope": scope, "before": before[SYNCHRONOUS_SEND_MAX_SETTINGS_KEY], "after": value, "effective_max_recipients": result["effective_max_recipients"]}, commit=False)
|
||||
session.commit()
|
||||
return result
|
||||
except (CampaignDeliveryPolicyError, ConfigurationControlError) as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(422, detail=str(exc)) from exc
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
@@ -48,6 +48,7 @@ from govoplan_campaign.backend.services.job_queries import (
|
||||
_job_attempts_payload,
|
||||
_calendar_invitations_for_jobs,
|
||||
_job_detail_payload,
|
||||
_job_page_recovery_metadata,
|
||||
_job_diagnostics_payload,
|
||||
)
|
||||
|
||||
@@ -425,6 +426,7 @@ def get_job_detail(
|
||||
return CampaignJobDetailResponse(
|
||||
job=_job_detail_payload(
|
||||
job,
|
||||
recovery=_job_page_recovery_metadata(session, [job]).get(job.id),
|
||||
calendar_invitation=_calendar_invitations_for_jobs(
|
||||
session,
|
||||
[job],
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
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,
|
||||
validate_autonomous_schedule_source,
|
||||
)
|
||||
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")
|
||||
if payload.delivery_mode == "autonomous":
|
||||
_require_permission(principal, "campaigns:campaign:queue")
|
||||
_require_permission(principal, "campaigns:campaign:send")
|
||||
_require_permission(principal, "mail:profile:use")
|
||||
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
|
||||
],
|
||||
)
|
||||
autonomous_evidence: dict[str, object] | None = None
|
||||
if payload.delivery_mode == "autonomous":
|
||||
try:
|
||||
autonomous_evidence = validate_autonomous_schedule_source(
|
||||
session,
|
||||
campaign=campaign,
|
||||
version=source_version,
|
||||
)
|
||||
except (RuntimeError, ValueError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
snapshot["autonomous_delivery"] = autonomous_evidence
|
||||
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(),
|
||||
delivery_mode=payload.delivery_mode,
|
||||
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),
|
||||
approved_execution_snapshot_hash=(
|
||||
str(autonomous_evidence["execution_snapshot_hash"])
|
||||
if autonomous_evidence is not None
|
||||
else None
|
||||
),
|
||||
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_mode": schedule.delivery_mode,
|
||||
"delivery_started": False,
|
||||
"autonomous_delivery_opted_in": (
|
||||
schedule.delivery_mode == "autonomous"
|
||||
),
|
||||
"approved_execution_snapshot_hash": (
|
||||
schedule.approved_execution_snapshot_hash
|
||||
),
|
||||
"approval_request_id": (
|
||||
autonomous_evidence.get("approval_request_id")
|
||||
if autonomous_evidence is not None
|
||||
else None
|
||||
),
|
||||
},
|
||||
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.",
|
||||
)
|
||||
if payload.active:
|
||||
unresolved = (
|
||||
session.query(CampaignScheduleOccurrence.id)
|
||||
.filter(
|
||||
CampaignScheduleOccurrence.schedule_id == schedule.id,
|
||||
CampaignScheduleOccurrence.status == "uncertain",
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if unresolved is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=(
|
||||
"Reconcile the autonomous delivery outcome in Mail before "
|
||||
"resuming this schedule."
|
||||
),
|
||||
)
|
||||
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
|
||||
]
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,378 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from importlib import metadata
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.campaign.transfers import (
|
||||
CampaignImportInspection,
|
||||
CampaignTransferError,
|
||||
build_campaign_portable_package,
|
||||
inspect_campaign_portable_package,
|
||||
)
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignIssue,
|
||||
CampaignJob,
|
||||
CampaignVersion,
|
||||
)
|
||||
from govoplan_campaign.backend.persistence.campaigns import (
|
||||
create_campaign_version_from_json,
|
||||
)
|
||||
from govoplan_campaign.backend.route_support import (
|
||||
_campaign_response_context,
|
||||
_get_campaign_for_principal,
|
||||
_require_permission,
|
||||
_write_current_version_snapshot_if_available,
|
||||
)
|
||||
from govoplan_campaign.backend.schemas import (
|
||||
CampaignExportRequest,
|
||||
CampaignImportApplyRequest,
|
||||
CampaignImportApplyResponse,
|
||||
CampaignImportPreviewRequest,
|
||||
CampaignImportPreviewResponse,
|
||||
CampaignPortablePackageResponse,
|
||||
CampaignResponse,
|
||||
CampaignVersionResponse,
|
||||
)
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_core.auth import ApiPrincipal, require_scope
|
||||
from govoplan_core.db.session import get_session
|
||||
|
||||
|
||||
router = APIRouter(tags=["campaigns"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/campaigns/{campaign_id}/versions/{version_id}/exports",
|
||||
response_model=CampaignPortablePackageResponse,
|
||||
)
|
||||
def export_campaign_package(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
payload: CampaignExportRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_scope("campaigns:campaign:export")
|
||||
),
|
||||
):
|
||||
campaign = _get_campaign_for_principal(session, campaign_id, principal)
|
||||
version = (
|
||||
session.query(CampaignVersion)
|
||||
.filter(
|
||||
CampaignVersion.id == version_id,
|
||||
CampaignVersion.campaign_id == campaign.id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if version is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Campaign version not found",
|
||||
)
|
||||
scopes = set(payload.scopes)
|
||||
if "recipients" in scopes:
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
_require_permission(principal, "campaigns:recipient:export")
|
||||
if "review_state" in scopes:
|
||||
_require_permission(principal, "campaigns:report:read")
|
||||
if "delivery_history" in scopes:
|
||||
_require_permission(principal, "campaigns:report:export")
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
_require_permission(principal, "campaigns:recipient:export")
|
||||
|
||||
jobs = (
|
||||
session.query(CampaignJob)
|
||||
.filter(CampaignJob.campaign_version_id == version.id)
|
||||
.order_by(CampaignJob.entry_index.asc(), CampaignJob.id.asc())
|
||||
.all()
|
||||
if "delivery_history" in scopes
|
||||
else ()
|
||||
)
|
||||
issues = (
|
||||
session.query(CampaignIssue)
|
||||
.filter(CampaignIssue.campaign_version_id == version.id)
|
||||
.order_by(CampaignIssue.id.asc())
|
||||
.all()
|
||||
if "review_state" in scopes
|
||||
else ()
|
||||
)
|
||||
try:
|
||||
package = build_campaign_portable_package(
|
||||
campaign=campaign,
|
||||
version=version,
|
||||
scopes=payload.scopes,
|
||||
jobs=jobs,
|
||||
issues=issues,
|
||||
module_version=_module_version(),
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.portable_export_created",
|
||||
object_type="campaign_version",
|
||||
object_id=version.id,
|
||||
details={
|
||||
"campaign_id": campaign.id,
|
||||
"package_id": package["package_id"],
|
||||
"package_sha256": package["integrity"]["package_sha256"],
|
||||
"format_version": package["format_version"],
|
||||
"scopes": package["scopes"],
|
||||
"item_counts": package["manifest"]["item_counts"],
|
||||
"redactions": package["manifest"]["redactions"],
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
except CampaignTransferError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
return package
|
||||
|
||||
|
||||
@router.post(
|
||||
"/campaign-transfers/imports/preview",
|
||||
response_model=CampaignImportPreviewResponse,
|
||||
)
|
||||
def preview_campaign_import(
|
||||
payload: CampaignImportPreviewRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_scope("campaigns:campaign:import")
|
||||
),
|
||||
):
|
||||
_require_permission(principal, "campaigns:campaign:create")
|
||||
inspection = _inspect_import_request(
|
||||
session,
|
||||
principal,
|
||||
package=payload.package,
|
||||
selected_scopes=payload.selected_scopes,
|
||||
external_id=payload.external_id,
|
||||
name=payload.name,
|
||||
)
|
||||
return inspection.preview
|
||||
|
||||
|
||||
@router.post(
|
||||
"/campaign-transfers/imports",
|
||||
response_model=CampaignImportApplyResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def import_campaign_package(
|
||||
payload: CampaignImportApplyRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_scope("campaigns:campaign:import")
|
||||
),
|
||||
):
|
||||
_require_permission(principal, "campaigns:campaign:create")
|
||||
inspection = _inspect_import_request(
|
||||
session,
|
||||
principal,
|
||||
package=payload.package,
|
||||
selected_scopes=payload.selected_scopes,
|
||||
external_id=payload.external_id,
|
||||
name=payload.name,
|
||||
)
|
||||
_require_import_scope_permissions(principal, inspection)
|
||||
preview = inspection.preview
|
||||
if payload.expected_package_sha256 != preview.get("package_sha256"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="The Campaign package changed after preview. Preview it again before importing.",
|
||||
)
|
||||
if not preview["compatible"] or inspection.configuration is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail={
|
||||
"message": "The Campaign package is not compatible.",
|
||||
"errors": preview["errors"],
|
||||
},
|
||||
)
|
||||
|
||||
destination = preview["destination"]
|
||||
package_id = str(preview["package_id"])
|
||||
package_sha256 = str(preview["package_sha256"])
|
||||
receipt = {
|
||||
"package_id": package_id,
|
||||
"package_sha256": package_sha256,
|
||||
"format_version": preview["format_version"],
|
||||
"source": copy.deepcopy(preview["source"]),
|
||||
"selected_scopes": list(preview["selected_scopes"]),
|
||||
"created": copy.deepcopy(preview["will_create"]),
|
||||
"skipped": copy.deepcopy(preview["will_skip"]),
|
||||
}
|
||||
try:
|
||||
campaign, version = create_campaign_version_from_json(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
user_id=principal.user.id,
|
||||
raw_json=inspection.configuration,
|
||||
source_filename=f"{package_id}.govoplan-campaign.json",
|
||||
source_base_path=None,
|
||||
commit=False,
|
||||
)
|
||||
campaign.settings = {
|
||||
**inspection.portable_settings,
|
||||
"portable_import": receipt,
|
||||
}
|
||||
session.add(campaign)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.portable_import_applied",
|
||||
object_type="campaign",
|
||||
object_id=campaign.id,
|
||||
details={
|
||||
"version_id": version.id,
|
||||
"external_id": destination["external_id"],
|
||||
"package_id": package_id,
|
||||
"package_sha256": package_sha256,
|
||||
"format_version": preview["format_version"],
|
||||
"selected_scopes": preview["selected_scopes"],
|
||||
"created_codes": [item["code"] for item in preview["will_create"]],
|
||||
"skipped_codes": [item["code"] for item in preview["will_skip"]],
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
session.refresh(campaign)
|
||||
session.refresh(version)
|
||||
_write_current_version_snapshot_if_available(version)
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
return CampaignImportApplyResponse(
|
||||
campaign=CampaignResponse.model_validate(campaign),
|
||||
version=CampaignVersionResponse.model_validate(
|
||||
version,
|
||||
context=_campaign_response_context(principal),
|
||||
),
|
||||
receipt=receipt,
|
||||
)
|
||||
|
||||
|
||||
def _inspect_import_request(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
package: dict[str, Any],
|
||||
selected_scopes: list[str] | None,
|
||||
external_id: str | None,
|
||||
name: str | None,
|
||||
) -> CampaignImportInspection:
|
||||
source = package.get("source")
|
||||
source = source if isinstance(source, dict) else {}
|
||||
metadata_payload = package.get("payload")
|
||||
metadata_payload = metadata_payload if isinstance(metadata_payload, dict) else {}
|
||||
metadata_scope = metadata_payload.get("metadata")
|
||||
metadata_scope = metadata_scope if isinstance(metadata_scope, dict) else {}
|
||||
source_external_id = str(
|
||||
metadata_scope.get("external_id")
|
||||
or source.get("campaign_external_id")
|
||||
or "campaign"
|
||||
)
|
||||
destination_external_id = _portable_import_external_id(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
source_external_id=source_external_id,
|
||||
requested=external_id,
|
||||
)
|
||||
destination_name = str(
|
||||
name
|
||||
or metadata_scope.get("name")
|
||||
or source.get("campaign_name")
|
||||
or "Imported campaign"
|
||||
).strip()
|
||||
if not destination_name:
|
||||
destination_name = "Imported campaign"
|
||||
inspection = inspect_campaign_portable_package(
|
||||
package,
|
||||
selected_scopes=selected_scopes,
|
||||
external_id=destination_external_id,
|
||||
name=destination_name,
|
||||
)
|
||||
if _campaign_external_id_exists(
|
||||
session, principal.tenant_id, destination_external_id
|
||||
):
|
||||
inspection.preview["compatible"] = False
|
||||
inspection.preview["errors"].append(
|
||||
"The destination Campaign ID already exists in this tenant."
|
||||
)
|
||||
return CampaignImportInspection(
|
||||
preview=inspection.preview,
|
||||
configuration=None,
|
||||
portable_settings=inspection.portable_settings,
|
||||
)
|
||||
return inspection
|
||||
|
||||
|
||||
def _portable_import_external_id(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
source_external_id: str,
|
||||
requested: str | None,
|
||||
) -> str:
|
||||
if requested is not None:
|
||||
candidate = requested.strip()
|
||||
if not candidate:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="Campaign ID cannot be empty.",
|
||||
)
|
||||
return candidate
|
||||
stem = f"{source_external_id[:238]}-import"
|
||||
for suffix in ("", *(f"-{number}" for number in range(2, 10_000))):
|
||||
candidate = f"{stem[:255 - len(suffix)]}{suffix}"
|
||||
if not _campaign_external_id_exists(session, tenant_id, candidate):
|
||||
return candidate
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="No available Campaign import identifier could be generated.",
|
||||
)
|
||||
|
||||
|
||||
def _campaign_external_id_exists(
|
||||
session: Session, tenant_id: str, external_id: str
|
||||
) -> bool:
|
||||
return (
|
||||
session.query(Campaign.id)
|
||||
.filter(
|
||||
Campaign.tenant_id == tenant_id,
|
||||
Campaign.external_id == external_id,
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def _require_import_scope_permissions(
|
||||
principal: ApiPrincipal, inspection: CampaignImportInspection
|
||||
) -> None:
|
||||
selected = set(inspection.preview.get("selected_scopes") or [])
|
||||
if "recipients" in selected:
|
||||
_require_permission(principal, "campaigns:recipient:import")
|
||||
_require_permission(principal, "campaigns:recipient:write")
|
||||
|
||||
|
||||
def _module_version() -> str:
|
||||
try:
|
||||
return metadata.version("govoplan-campaign")
|
||||
except metadata.PackageNotFoundError:
|
||||
return "development"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"export_campaign_package",
|
||||
"import_campaign_package",
|
||||
"preview_campaign_import",
|
||||
"router",
|
||||
]
|
||||
@@ -6,6 +6,7 @@ from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm.exc import StaleDataError
|
||||
|
||||
from govoplan_campaign.backend.schemas import (
|
||||
BuildCampaignRequest,
|
||||
@@ -23,6 +24,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.object_storage import StorageBackendError
|
||||
from govoplan_core.core.concurrency import RevisionConflictError
|
||||
from govoplan_core.core.recovery import (
|
||||
RecoveryGuaranteeError,
|
||||
RecoveryMode,
|
||||
@@ -581,6 +583,10 @@ def set_version_review_state(
|
||||
for item in payload.issue_decisions
|
||||
],
|
||||
user_id=principal.user.id,
|
||||
merge_progress=payload.merge_progress,
|
||||
expected_build_token=payload.build_token,
|
||||
expected_revision=payload.base_revision,
|
||||
decision_category_key=payload.decision_category_key,
|
||||
commit=False,
|
||||
)
|
||||
audit_from_principal(
|
||||
@@ -592,8 +598,17 @@ def set_version_review_state(
|
||||
details={
|
||||
"campaign_id": campaign_id,
|
||||
"inspection_complete": payload.inspection_complete,
|
||||
"merge_progress": payload.merge_progress,
|
||||
"build_token": payload.build_token,
|
||||
"base_revision": payload.base_revision,
|
||||
"result_revision": version.edit_revision,
|
||||
"reviewed_message_count": len(payload.reviewed_message_keys),
|
||||
"issue_decision_count": len(payload.issue_decisions),
|
||||
"issue_decisions": _review_decision_audit_evidence(
|
||||
version,
|
||||
job_ids={item.job_id for item in payload.issue_decisions}
|
||||
if payload.merge_progress and not payload.inspection_complete else None,
|
||||
),
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
@@ -601,6 +616,12 @@ def set_version_review_state(
|
||||
version,
|
||||
context=_campaign_response_context(principal),
|
||||
)
|
||||
except RevisionConflictError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=exc.as_dict()) from exc
|
||||
except StaleDataError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Review progress changed concurrently. Reload before saving this decision again.") from exc
|
||||
except LockedCampaignVersionError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
@@ -769,6 +790,14 @@ def validate_version(
|
||||
except HTTPException:
|
||||
raise
|
||||
except CampaignPersistenceError as exc:
|
||||
if _is_archive_encryption_denial(exc):
|
||||
_audit_archive_encryption_denial(
|
||||
session, principal, version_id=version_id, error=exc
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||
) from exc
|
||||
@@ -876,6 +905,15 @@ def build_version(
|
||||
"write_eml": write_eml,
|
||||
"built_count": result.get("built_count"),
|
||||
"recovery_operation_id": recovery_start.operation_id,
|
||||
"residual_file_disposition": _residual_file_audit_evidence(
|
||||
result.get("residual_file_disposition")
|
||||
),
|
||||
"attachment_reuse": _attachment_reuse_audit_evidence(
|
||||
result.get("attachment_reuse")
|
||||
),
|
||||
"archive_encryption": _archive_encryption_audit_evidence(
|
||||
result.get("archive_encryption")
|
||||
),
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
@@ -884,6 +922,18 @@ def build_version(
|
||||
include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"),
|
||||
)
|
||||
except CampaignPersistenceError as exc:
|
||||
if _is_archive_encryption_denial(exc):
|
||||
_audit_archive_encryption_denial(
|
||||
session, principal, version_id=version_id, error=exc
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=(
|
||||
status.HTTP_403_FORBIDDEN
|
||||
if "Missing scope:" in str(exc)
|
||||
else status.HTTP_422_UNPROCESSABLE_CONTENT
|
||||
),
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||
) from exc
|
||||
@@ -906,3 +956,126 @@ def build_version(
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
def _is_archive_encryption_denial(error: Exception) -> bool:
|
||||
message = str(error).casefold()
|
||||
return any(
|
||||
marker in message
|
||||
for marker in (
|
||||
"archive-encryption",
|
||||
"archive encryption",
|
||||
"zipcrypto",
|
||||
"password-delivery channel",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _audit_archive_encryption_denial(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
version_id: str,
|
||||
error: Exception,
|
||||
) -> None:
|
||||
session.rollback()
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.archive_encryption_denied",
|
||||
object_type="campaign_version",
|
||||
object_id=version_id,
|
||||
details={"reason": str(error)},
|
||||
commit=True,
|
||||
)
|
||||
|
||||
|
||||
def _residual_file_audit_evidence(value: object) -> dict[str, object]:
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
return {
|
||||
key: value.get(key)
|
||||
for key in (
|
||||
"contract_version",
|
||||
"action",
|
||||
"routing_mode",
|
||||
"validation_behavior",
|
||||
"watched_source_count",
|
||||
"residual_file_count",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def _attachment_reuse_audit_evidence(value: object) -> dict[str, object]:
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
policy = value.get("policy")
|
||||
return {
|
||||
"contract_version": value.get("contract_version"),
|
||||
"policy": dict(policy) if isinstance(policy, dict) else {},
|
||||
"duplicate_file_count": value.get("duplicate_file_count"),
|
||||
"allowed_file_count": value.get("allowed_file_count"),
|
||||
"violation_file_count": value.get("violation_file_count"),
|
||||
"affected_message_count": value.get("affected_message_count"),
|
||||
}
|
||||
|
||||
|
||||
def _archive_encryption_audit_evidence(value: object) -> dict[str, object]:
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
policy = value.get("policy")
|
||||
archives = [
|
||||
item for item in (value.get("archives") or []) if isinstance(item, dict)
|
||||
]
|
||||
return {
|
||||
"policy_hash": policy.get("policy_hash")
|
||||
if isinstance(policy, dict)
|
||||
else None,
|
||||
"archive_count": len(archives),
|
||||
"legacy_zipcrypto_count": sum(
|
||||
1 for item in archives if item.get("method") == "zip_standard"
|
||||
),
|
||||
"archive_sha256": [item.get("archive_sha256") for item in archives],
|
||||
}
|
||||
|
||||
|
||||
def _review_decision_audit_evidence(
|
||||
version: CampaignVersion,
|
||||
*,
|
||||
job_ids: set[str] | None = None,
|
||||
) -> dict[str, object]:
|
||||
editor_state = version.editor_state if isinstance(version.editor_state, dict) else {}
|
||||
review_state = editor_state.get("review_send")
|
||||
if not isinstance(review_state, dict):
|
||||
return {}
|
||||
raw_decisions = review_state.get("issue_decisions")
|
||||
decisions = [
|
||||
item for item in raw_decisions or []
|
||||
if isinstance(item, dict) and (job_ids is None or str(item.get("job_id")) in job_ids)
|
||||
]
|
||||
evidence = [
|
||||
{
|
||||
"decision": item.get("decision"),
|
||||
"issue_codes": sorted(
|
||||
str(code) for code in item.get("issue_codes") or [] if code
|
||||
),
|
||||
"issue_fingerprint": item.get("issue_fingerprint"),
|
||||
"message_sha256": item.get("message_sha256"),
|
||||
"reason_recorded": bool(str(item.get("reason") or "").strip()),
|
||||
}
|
||||
for item in decisions
|
||||
]
|
||||
return {
|
||||
"count": len(evidence),
|
||||
"with_reason_count": sum(
|
||||
1 for item in evidence if item["reason_recorded"]
|
||||
),
|
||||
"issue_codes": sorted(
|
||||
{
|
||||
code
|
||||
for item in evidence
|
||||
for code in item["issue_codes"]
|
||||
}
|
||||
),
|
||||
"evidence_sha256": _canonical_sha256(evidence),
|
||||
}
|
||||
|
||||
@@ -371,6 +371,13 @@
|
||||
"warn"
|
||||
],
|
||||
"default": "ask"
|
||||
},
|
||||
"reuse_policy": {
|
||||
"$ref": "#/$defs/attachment_reuse_policy",
|
||||
"description": "Controls whether resolving the same source file more than once is allowed, warned, reviewed, or blocked, with an optional same-recipient or same-message allowance."
|
||||
},
|
||||
"residual_files": {
|
||||
"$ref": "#/$defs/residual_file_disposition"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
@@ -1455,6 +1462,53 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"attachment_reuse_policy": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["allow", "warn", "review", "block"],
|
||||
"default": "allow",
|
||||
"description": "Action when one resolved source file is used more than once outside the configured allowance. Review creates an explicit, reasoned review decision."
|
||||
},
|
||||
"allow_within": {
|
||||
"type": "string",
|
||||
"enum": ["none", "same_recipient", "same_message"],
|
||||
"default": "none",
|
||||
"description": "Optional exception that permits reuse confined to one recipient or one built message."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"default": {"action": "allow", "allow_within": "none"}
|
||||
},
|
||||
"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": {
|
||||
@@ -1571,6 +1625,44 @@
|
||||
],
|
||||
"default": "aes"
|
||||
},
|
||||
"password_delivery_channel": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"separate_mail",
|
||||
"sms",
|
||||
"letter",
|
||||
"phone",
|
||||
"in_person"
|
||||
],
|
||||
"default": "separate_mail"
|
||||
},
|
||||
"legacy_zipcrypto_acknowledged": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"legacy_zipcrypto_reason": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 1000
|
||||
},
|
||||
"legacy_zipcrypto_acknowledged_by": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 255,
|
||||
"readOnly": true
|
||||
},
|
||||
"legacy_zipcrypto_acknowledged_at": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 80,
|
||||
"readOnly": true
|
||||
},
|
||||
"password_mode": {
|
||||
"type": [
|
||||
"string",
|
||||
|
||||
@@ -61,6 +61,16 @@
|
||||
}
|
||||
],
|
||||
"fields": {
|
||||
"/attachments/reuse_policy/action": {
|
||||
"label": "Duplicate-file action",
|
||||
"control": "select",
|
||||
"description": "Allow and record, warn, require a reasoned review decision, or block repeated use of the same resolved file."
|
||||
},
|
||||
"/attachments/reuse_policy/allow_within": {
|
||||
"label": "Allowed reuse boundary",
|
||||
"control": "select",
|
||||
"description": "Optionally exempt reuse confined to one recipient or one built message."
|
||||
},
|
||||
"/attachments/global[]/message_filename_template": {
|
||||
"label": "Direct attachment filename",
|
||||
"control": "text",
|
||||
|
||||
@@ -16,6 +16,7 @@ from pydantic import (
|
||||
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
||||
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
||||
public_campaign_editor_state,
|
||||
campaign_review_reference,
|
||||
validate_campaign_editor_state,
|
||||
)
|
||||
from govoplan_campaign.backend.response_security import (
|
||||
@@ -42,6 +43,240 @@ class CampaignUpdateRequest(BaseModel):
|
||||
description: str | None = None
|
||||
|
||||
|
||||
CampaignCollaborationReferenceKind = Literal[
|
||||
"campaign_version",
|
||||
"recipient_import_batch",
|
||||
"attachment_rule",
|
||||
"delivery_job",
|
||||
"report",
|
||||
]
|
||||
|
||||
|
||||
class CampaignCollaborationReferenceInput(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
kind: CampaignCollaborationReferenceKind
|
||||
id: str = Field(min_length=1, max_length=500)
|
||||
|
||||
@field_validator("id")
|
||||
@classmethod
|
||||
def strip_reference_text(cls, value: str) -> str:
|
||||
clean = value.strip()
|
||||
if not clean:
|
||||
raise ValueError("A collaboration reference ID cannot be empty.")
|
||||
return clean
|
||||
|
||||
|
||||
class CampaignCollaborationCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
content: str = Field(min_length=1, max_length=8_000)
|
||||
visibility: Literal["collaborators", "moderators"] = "collaborators"
|
||||
reference: CampaignCollaborationReferenceInput | None = None
|
||||
mention_user_ids: list[str] = Field(default_factory=list, max_length=20)
|
||||
|
||||
@field_validator("content")
|
||||
@classmethod
|
||||
def strip_content(cls, value: str) -> str:
|
||||
if not value.strip():
|
||||
raise ValueError("A collaboration entry cannot be empty.")
|
||||
return value.strip()
|
||||
|
||||
@field_validator("mention_user_ids")
|
||||
@classmethod
|
||||
def normalize_mentions(cls, value: list[str]) -> list[str]:
|
||||
normalized = list(dict.fromkeys(item.strip() for item in value if item.strip()))
|
||||
if len(normalized) > 20:
|
||||
raise ValueError("A collaboration entry can mention at most 20 users.")
|
||||
if any(len(item) > 64 for item in normalized):
|
||||
raise ValueError("A collaboration mention contains an invalid user ID.")
|
||||
return normalized
|
||||
|
||||
|
||||
class CampaignCollaborationModerationRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
reason: str | None = Field(default=None, max_length=500)
|
||||
|
||||
@field_validator("reason")
|
||||
@classmethod
|
||||
def strip_reason(cls, value: str | None) -> str | None:
|
||||
clean = value.strip() if value is not None else None
|
||||
return clean or None
|
||||
|
||||
|
||||
class CampaignCollaborationReferenceResponse(BaseModel):
|
||||
kind: CampaignCollaborationReferenceKind
|
||||
id: str
|
||||
label: str | None = None
|
||||
|
||||
|
||||
class CampaignCollaborationEntryResponse(BaseModel):
|
||||
id: str
|
||||
campaign_id: str
|
||||
actor_user_id: str | None = None
|
||||
actor_label: str
|
||||
visibility: Literal["collaborators", "moderators"]
|
||||
content: str | None = None
|
||||
content_sha256: str
|
||||
mention_user_ids: list[str] = Field(default_factory=list)
|
||||
reference: CampaignCollaborationReferenceResponse | None = None
|
||||
tombstone: Literal["withdrawn", "redacted"] | None = None
|
||||
tombstone_reason: str | None = None
|
||||
withdrawn_at: datetime | None = None
|
||||
redacted_at: datetime | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class CampaignCollaborationListResponse(BaseModel):
|
||||
items: list[CampaignCollaborationEntryResponse]
|
||||
next_cursor: str | None = None
|
||||
has_more: bool = False
|
||||
|
||||
|
||||
CampaignWorkAssigneeType = Literal["account", "group", "organization_function"]
|
||||
CampaignWorkAssignmentStatus = Literal[
|
||||
"open",
|
||||
"in_progress",
|
||||
"completed",
|
||||
"rejected",
|
||||
"cancelled",
|
||||
]
|
||||
CampaignWorkAssigneeResolutionState = Literal[
|
||||
"resolved",
|
||||
"unavailable",
|
||||
"provider_unavailable",
|
||||
]
|
||||
|
||||
|
||||
class CampaignWorkAssigneeInput(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
type: CampaignWorkAssigneeType
|
||||
id: str = Field(min_length=1, max_length=255)
|
||||
|
||||
@field_validator("id")
|
||||
@classmethod
|
||||
def strip_assignee_id(cls, value: str) -> str:
|
||||
return value.strip()
|
||||
|
||||
|
||||
class CampaignWorkAssignmentCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
purpose: str = Field(min_length=1, max_length=500)
|
||||
assignee: CampaignWorkAssigneeInput
|
||||
due_at: datetime | None = None
|
||||
reference: CampaignCollaborationReferenceInput | None = None
|
||||
mirror_to_tasks: bool = True
|
||||
|
||||
@field_validator("purpose")
|
||||
@classmethod
|
||||
def strip_assignment_purpose(cls, value: str) -> str:
|
||||
return value.strip()
|
||||
|
||||
@field_validator("due_at")
|
||||
@classmethod
|
||||
def require_due_timezone(cls, value: datetime | None) -> datetime | None:
|
||||
if value is not None and value.tzinfo is None:
|
||||
raise ValueError("Assignment due dates must include a timezone.")
|
||||
return value
|
||||
|
||||
|
||||
class CampaignWorkAssignmentReassignRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
expected_revision: int = Field(ge=1)
|
||||
assignee: CampaignWorkAssigneeInput
|
||||
reason: str | None = Field(default=None, max_length=500)
|
||||
mirror_to_tasks: bool = True
|
||||
|
||||
@field_validator("reason")
|
||||
@classmethod
|
||||
def strip_reassignment_reason(cls, value: str | None) -> str | None:
|
||||
clean = value.strip() if value is not None else None
|
||||
return clean or None
|
||||
|
||||
|
||||
class CampaignWorkAssignmentTransitionRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
expected_revision: int = Field(ge=1)
|
||||
action: Literal["accept", "start", "complete", "reject", "cancel"]
|
||||
reason: str | None = Field(default=None, max_length=500)
|
||||
|
||||
@field_validator("reason")
|
||||
@classmethod
|
||||
def strip_transition_reason(cls, value: str | None) -> str | None:
|
||||
clean = value.strip() if value is not None else None
|
||||
return clean or None
|
||||
|
||||
|
||||
class CampaignWorkAssignmentReferenceResponse(BaseModel):
|
||||
kind: CampaignCollaborationReferenceKind
|
||||
id: str
|
||||
label: str | None = None
|
||||
|
||||
|
||||
class CampaignWorkAssignmentResponse(BaseModel):
|
||||
id: str
|
||||
campaign_id: str
|
||||
purpose: str
|
||||
status: CampaignWorkAssignmentStatus
|
||||
due_at: datetime | None = None
|
||||
assignee_type: CampaignWorkAssigneeType
|
||||
assignee_id: str
|
||||
assignee_label_snapshot: str
|
||||
assignee_current_label: str | None = None
|
||||
assignee_resolution_state: CampaignWorkAssigneeResolutionState
|
||||
resolution_provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
resolution_checked_at: datetime
|
||||
assigned_by_user_id: str | None = None
|
||||
assigned_by_label: str
|
||||
reference: CampaignWorkAssignmentReferenceResponse | None = None
|
||||
completed_at: datetime | None = None
|
||||
cancelled_at: datetime | None = None
|
||||
task_mirror_id: str | None = None
|
||||
task_mirror_status: Literal["not_configured", "mirrored", "failed", "skipped"]
|
||||
task_mirror_error: str | None = None
|
||||
resource_revision: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class CampaignWorkAssignmentListResponse(BaseModel):
|
||||
items: list[CampaignWorkAssignmentResponse]
|
||||
next_cursor: str | None = None
|
||||
has_more: bool = False
|
||||
|
||||
|
||||
class CampaignWorkAssignmentEventResponse(BaseModel):
|
||||
id: str
|
||||
assignment_id: str
|
||||
event_kind: str
|
||||
actor_user_id: str | None = None
|
||||
actor_label: str
|
||||
status: CampaignWorkAssignmentStatus
|
||||
assignee_type: CampaignWorkAssigneeType
|
||||
assignee_id: str
|
||||
assignee_label: str
|
||||
resolution_state: CampaignWorkAssigneeResolutionState
|
||||
details: dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class CampaignWorkAssignmentHistoryResponse(BaseModel):
|
||||
items: list[CampaignWorkAssignmentEventResponse]
|
||||
next_cursor: str | None = None
|
||||
has_more: bool = False
|
||||
|
||||
|
||||
class CampaignWorkAssignmentReconcileResponse(BaseModel):
|
||||
checked: int
|
||||
changed: int
|
||||
assignments: list[CampaignWorkAssignmentResponse]
|
||||
|
||||
|
||||
class CampaignLifecycleMutationRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -52,6 +287,135 @@ 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)
|
||||
delivery_mode: Literal["manual", "autonomous"] = "manual"
|
||||
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
|
||||
idempotency_key: str | None = None
|
||||
generated_campaign_id: str | None = None
|
||||
generated_version_id: str | None = None
|
||||
error: str | None = None
|
||||
delivery_command_ids: list[str] = Field(default_factory=list)
|
||||
recovery_state: str = "none"
|
||||
evidence: dict[str, object] = Field(default_factory=dict)
|
||||
last_checked_at: datetime | 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
|
||||
delivery_mode: 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
|
||||
last_outcome: str | None = None
|
||||
last_recovery_state: 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):
|
||||
@@ -107,12 +471,22 @@ class CampaignReviewStateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
inspection_complete: bool = False
|
||||
reviewed_message_keys: list[str] = Field(default_factory=list)
|
||||
merge_progress: bool = False
|
||||
build_token: str | None = Field(default=None, min_length=1, max_length=256)
|
||||
base_revision: int | None = Field(default=None, ge=1)
|
||||
decision_category_key: str | None = Field(default=None, min_length=1, max_length=64)
|
||||
reviewed_message_keys: list[str] = Field(default_factory=list, max_length=100_000)
|
||||
issue_decisions: list[CampaignReviewDecisionRequest] = Field(
|
||||
default_factory=list,
|
||||
max_length=100_000,
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_progress_preconditions(self):
|
||||
if self.merge_progress and (self.build_token is None or self.base_revision is None):
|
||||
raise ValueError("Incremental review requires the current build_token and base_revision.")
|
||||
return self
|
||||
|
||||
|
||||
class CampaignPartialValidationRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
@@ -128,6 +502,7 @@ class CampaignVersionResponse(BaseModel):
|
||||
campaign_id: str
|
||||
version_number: int
|
||||
edit_revision: int = 1
|
||||
review_build_token: str | None = None
|
||||
strong_etag: str = ""
|
||||
schema_version: str
|
||||
source_filename: str | None = None
|
||||
@@ -161,10 +536,15 @@ class CampaignVersionResponse(BaseModel):
|
||||
def remove_unsupported_editor_state(
|
||||
cls, value: Any, info: ValidationInfo
|
||||
) -> dict[str, Any]:
|
||||
return public_campaign_editor_state(
|
||||
result = public_campaign_editor_state(
|
||||
value,
|
||||
include_diagnostics=bool((info.context or {}).get("include_diagnostics")),
|
||||
)
|
||||
if isinstance(value, dict) and isinstance(value.get("review_send"), dict) and isinstance(result.get("review_send"), dict):
|
||||
result["review_send"]["review_build_token"] = campaign_review_reference(
|
||||
str(info.data.get("id") or ""), value["review_send"].get("build_token")
|
||||
)
|
||||
return result
|
||||
|
||||
@field_validator("source_filename", mode="before")
|
||||
@classmethod
|
||||
@@ -232,6 +612,96 @@ class CampaignCreateResponse(BaseModel):
|
||||
version: CampaignVersionResponse
|
||||
|
||||
|
||||
CampaignTransferScope = Literal[
|
||||
"metadata",
|
||||
"template_config",
|
||||
"recipients",
|
||||
"attachments",
|
||||
"review_state",
|
||||
"delivery_history",
|
||||
]
|
||||
|
||||
|
||||
class CampaignExportRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
scopes: list[CampaignTransferScope] = Field(
|
||||
default_factory=lambda: ["metadata", "template_config"],
|
||||
min_length=1,
|
||||
max_length=6,
|
||||
)
|
||||
|
||||
@field_validator("scopes")
|
||||
@classmethod
|
||||
def normalize_scopes(
|
||||
cls, value: list[CampaignTransferScope]
|
||||
) -> list[CampaignTransferScope]:
|
||||
return list(dict.fromkeys(value))
|
||||
|
||||
|
||||
class CampaignPortablePackageResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
format: Literal["govoplan.campaign-portable"]
|
||||
format_version: str
|
||||
package_id: str
|
||||
exported_at: str
|
||||
source: dict[str, Any]
|
||||
scopes: list[CampaignTransferScope]
|
||||
manifest: dict[str, Any]
|
||||
payload: dict[str, Any]
|
||||
integrity: dict[str, str]
|
||||
|
||||
|
||||
class CampaignImportPreviewRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
package: dict[str, Any]
|
||||
selected_scopes: list[CampaignTransferScope] | None = Field(
|
||||
default=None,
|
||||
max_length=6,
|
||||
)
|
||||
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)
|
||||
|
||||
@field_validator("selected_scopes")
|
||||
@classmethod
|
||||
def normalize_selected_scopes(
|
||||
cls, value: list[CampaignTransferScope] | None
|
||||
) -> list[CampaignTransferScope] | None:
|
||||
return list(dict.fromkeys(value)) if value is not None else None
|
||||
|
||||
|
||||
class CampaignImportApplyRequest(CampaignImportPreviewRequest):
|
||||
expected_package_sha256: str = Field(min_length=64, max_length=64)
|
||||
|
||||
|
||||
class CampaignTransferPlanItem(BaseModel):
|
||||
scope: CampaignTransferScope
|
||||
code: str
|
||||
summary: str
|
||||
item_count: int | None = None
|
||||
|
||||
|
||||
class CampaignImportPreviewResponse(BaseModel):
|
||||
compatible: bool
|
||||
package_id: str | None = None
|
||||
package_sha256: str | None = None
|
||||
format_version: str | None = None
|
||||
source: dict[str, Any] = Field(default_factory=dict)
|
||||
available_scopes: list[CampaignTransferScope] = Field(default_factory=list)
|
||||
selected_scopes: list[CampaignTransferScope] = Field(default_factory=list)
|
||||
destination: dict[str, Any] = Field(default_factory=dict)
|
||||
will_create: list[CampaignTransferPlanItem] = Field(default_factory=list)
|
||||
will_skip: list[CampaignTransferPlanItem] = Field(default_factory=list)
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
errors: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CampaignImportApplyResponse(CampaignCreateResponse):
|
||||
receipt: dict[str, Any]
|
||||
|
||||
|
||||
class CampaignListResponse(BaseModel):
|
||||
campaigns: list[CampaignResponse]
|
||||
|
||||
@@ -640,6 +1110,7 @@ class CampaignRetryJobsRequest(BaseModel):
|
||||
include_permanent: bool = False
|
||||
force_max_attempts: bool = False
|
||||
enqueue_celery: bool = True
|
||||
run_inline: bool = False
|
||||
dry_run: bool = False
|
||||
|
||||
|
||||
@@ -649,6 +1120,7 @@ class CampaignSendUnattemptedRequest(BaseModel):
|
||||
version_id: str | None = None
|
||||
job_ids: list[str] = Field(default_factory=list)
|
||||
enqueue_celery: bool = True
|
||||
run_inline: bool = False
|
||||
dry_run: bool = False
|
||||
|
||||
|
||||
@@ -693,6 +1165,21 @@ class CampaignResolveOutcomeRequest(BaseModel):
|
||||
return self
|
||||
|
||||
|
||||
class CampaignRecoverClaimRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
channel: Literal["smtp", "imap"]
|
||||
expected_revision: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||
note: str = Field(min_length=1, max_length=2000)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_evidence(self):
|
||||
self.note = self.note.strip()
|
||||
if not self.note:
|
||||
raise ValueError("Claim recovery requires an evidence note")
|
||||
return self
|
||||
|
||||
|
||||
class ValidateCampaignRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -825,6 +1312,7 @@ class MockCampaignSendRequest(BaseModel):
|
||||
send: bool = False
|
||||
include_warnings: bool = True
|
||||
include_needs_review: bool = False
|
||||
use_reviewed_build: bool = False
|
||||
append_sent: bool = True
|
||||
clear_mailbox: bool = False
|
||||
check_files: bool = False
|
||||
@@ -837,6 +1325,7 @@ class MockCampaignSendResponse(BaseModel):
|
||||
class AppendSentRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
version_id: str | None = None
|
||||
enqueue_celery: bool = True
|
||||
run_inline: bool = False
|
||||
dry_run: bool = False
|
||||
|
||||
@@ -9,6 +9,10 @@ from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, JobValidationStatus
|
||||
from govoplan_campaign.backend.archive_encryption import (
|
||||
CampaignArchiveEncryptionError,
|
||||
assert_archive_encryption_allowed,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.models import (
|
||||
DeliveryChannelPolicy,
|
||||
DeliveryConfig,
|
||||
@@ -22,8 +26,8 @@ from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
||||
from govoplan_campaign.backend.integrations import MailProfileError, files_integration, mail_integration
|
||||
from govoplan_campaign.backend.path_security import CampaignPathSecurityError, assert_server_safe_campaign_paths
|
||||
|
||||
SNAPSHOT_VERSION = "8"
|
||||
SUPPORTED_SNAPSHOT_VERSIONS = {"6", "7", SNAPSHOT_VERSION}
|
||||
SNAPSHOT_VERSION = "9"
|
||||
SUPPORTED_SNAPSHOT_VERSIONS = {"6", "7", "8", SNAPSHOT_VERSION}
|
||||
|
||||
|
||||
class ExecutionSnapshotError(RuntimeError):
|
||||
@@ -57,6 +61,7 @@ class ExecutionSnapshot(BaseModel):
|
||||
queueable_job_count: int = 0
|
||||
job_manifest_sha256: str | None = None
|
||||
effective_policy_sha256: str | None = None
|
||||
archive_encryption: dict[str, Any] | None = None
|
||||
smtp_transport_revision: str | None = None
|
||||
imap_transport_revision: str | None = None
|
||||
uses_mail: bool = True
|
||||
@@ -263,6 +268,7 @@ def create_execution_snapshot(
|
||||
imap_credential_id: str | None = None,
|
||||
jobs: Iterable[CampaignJob] = (),
|
||||
build_summary: dict[str, Any] | None = None,
|
||||
archive_encryption: dict[str, Any] | None = None,
|
||||
) -> tuple[dict[str, Any], str]:
|
||||
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||
job_list = list(jobs)
|
||||
@@ -311,6 +317,7 @@ def create_execution_snapshot(
|
||||
delivery,
|
||||
snapshot_version=SNAPSHOT_VERSION,
|
||||
),
|
||||
archive_encryption=archive_encryption,
|
||||
smtp_transport_revision=smtp_transport_revision,
|
||||
imap_transport_revision=imap_transport_revision,
|
||||
uses_mail=uses_mail,
|
||||
@@ -355,6 +362,39 @@ def _assert_snapshot_matches_persisted_inputs(
|
||||
"Revalidate and rebuild the campaign before delivery."
|
||||
)
|
||||
|
||||
campaign = session.get(Campaign, version.campaign_id)
|
||||
if campaign is None:
|
||||
raise ExecutionSnapshotError("Execution snapshot Campaign no longer exists")
|
||||
try:
|
||||
current_archive_policy = assert_archive_encryption_allowed(
|
||||
session,
|
||||
campaign,
|
||||
raw_json,
|
||||
)
|
||||
except CampaignArchiveEncryptionError as exc:
|
||||
raise ExecutionSnapshotError(str(exc)) from exc
|
||||
archive_snapshot = snapshot.archive_encryption
|
||||
configured_archives = (
|
||||
((raw_json.get("attachments") or {}).get("zip") or {}).get("archives")
|
||||
if isinstance(raw_json.get("attachments"), dict)
|
||||
else None
|
||||
)
|
||||
if configured_archives and not isinstance(archive_snapshot, dict):
|
||||
raise ExecutionSnapshotError(
|
||||
"Execution snapshot has no governed archive-encryption evidence; rebuild before delivery."
|
||||
)
|
||||
if isinstance(archive_snapshot, dict):
|
||||
frozen_policy = archive_snapshot.get("policy")
|
||||
frozen_hash = (
|
||||
frozen_policy.get("policy_hash")
|
||||
if isinstance(frozen_policy, dict)
|
||||
else None
|
||||
)
|
||||
if frozen_hash != current_archive_policy.policy_hash:
|
||||
raise ExecutionSnapshotError(
|
||||
"The effective archive-encryption policy changed after build. Revalidate and rebuild before delivery."
|
||||
)
|
||||
|
||||
if effect_job is not None:
|
||||
if effect_job.campaign_version_id != version.id:
|
||||
raise ExecutionSnapshotError("Campaign job does not belong to the snapshotted version")
|
||||
@@ -494,6 +534,12 @@ def ensure_execution_snapshot(
|
||||
delivery=config.delivery,
|
||||
jobs=jobs,
|
||||
build_summary=version.build_summary if isinstance(version.build_summary, dict) else {},
|
||||
archive_encryption=(
|
||||
version.build_summary.get("archive_encryption")
|
||||
if isinstance(version.build_summary, dict)
|
||||
and isinstance(version.build_summary.get("archive_encryption"), dict)
|
||||
else None
|
||||
),
|
||||
)
|
||||
version.execution_snapshot = payload
|
||||
version.execution_snapshot_hash = digest
|
||||
|
||||
@@ -3,7 +3,8 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import json
|
||||
from collections import Counter
|
||||
from dataclasses import asdict, dataclass
|
||||
from contextlib import nullcontext
|
||||
from dataclasses import asdict, dataclass, replace
|
||||
from datetime import datetime, timezone
|
||||
from email import policy
|
||||
from email.parser import BytesParser
|
||||
@@ -225,6 +226,11 @@ class SendCampaignNowResult:
|
||||
failed_count: int
|
||||
outcome_unknown_count: int
|
||||
skipped_count: int
|
||||
paused_count: int = 0
|
||||
batch_state: str = "not_started"
|
||||
batch_pause_reason_code: str | None = None
|
||||
smtp_connection_count: int = 0
|
||||
smtp_reconnect_count: int = 0
|
||||
preflight_count: int = 0
|
||||
synchronous_send_policy: dict[str, Any] | None = None
|
||||
dry_run: bool = False
|
||||
@@ -239,7 +245,12 @@ class SendCampaignNowResult:
|
||||
"failed_count": self.failed_count,
|
||||
"outcome_unknown_count": self.outcome_unknown_count,
|
||||
"skipped_count": self.skipped_count,
|
||||
"paused_count": self.paused_count,
|
||||
"preflight_count": self.preflight_count,
|
||||
"batch_state": self.batch_state,
|
||||
"batch_pause_reason_code": self.batch_pause_reason_code,
|
||||
"smtp_connection_count": self.smtp_connection_count,
|
||||
"smtp_reconnect_count": self.smtp_reconnect_count,
|
||||
"delivery_mode": "synchronous",
|
||||
"synchronous_send_policy": self.synchronous_send_policy or {},
|
||||
"dry_run": self.dry_run,
|
||||
@@ -273,6 +284,9 @@ class AppendSentResult:
|
||||
dry_run: bool = False
|
||||
folder: str | None = None
|
||||
message: str | None = None
|
||||
connection_sequence: int | None = None
|
||||
session_reused: bool = False
|
||||
reconnect_count: int = 0
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
@@ -282,6 +296,9 @@ class AppendSentResult:
|
||||
"dry_run": self.dry_run,
|
||||
"folder": self.folder,
|
||||
"message": self.message,
|
||||
"connection_sequence": self.connection_sequence,
|
||||
"session_reused": self.session_reused,
|
||||
"reconnect_count": self.reconnect_count,
|
||||
}
|
||||
|
||||
|
||||
@@ -1063,6 +1080,32 @@ def send_campaign_now(
|
||||
# Repeat the hard bound against the post-queue set. This closes the window
|
||||
# where a concurrent queue operation could otherwise enlarge an immediate
|
||||
# run between the initial decision and the first provider effect.
|
||||
_ensure_synchronous_send_count_allowed(len(jobs), policy=synchronous_policy)
|
||||
return _send_synchronous_job_batch(
|
||||
session,
|
||||
campaign=campaign,
|
||||
version=version,
|
||||
jobs=jobs,
|
||||
synchronous_policy=synchronous_policy,
|
||||
skipped_count=queue_result.skipped_count + queue_result.blocked_count,
|
||||
use_rate_limit=use_rate_limit,
|
||||
enqueue_imap_task=enqueue_imap_task,
|
||||
)
|
||||
|
||||
|
||||
def _send_synchronous_job_batch(
|
||||
session: Session,
|
||||
*,
|
||||
campaign: Campaign,
|
||||
version: CampaignVersion,
|
||||
jobs: list[CampaignJob],
|
||||
synchronous_policy: SynchronousSendPolicy,
|
||||
skipped_count: int = 0,
|
||||
use_rate_limit: bool = True,
|
||||
enqueue_imap_task: bool = False,
|
||||
) -> SendCampaignNowResult:
|
||||
"""Shared immutable-job execution for initial delivery and explicit recovery."""
|
||||
|
||||
_ensure_synchronous_send_count_allowed(len(jobs), policy=synchronous_policy)
|
||||
delivery_contexts = _preflight_synchronous_send_batch(
|
||||
session,
|
||||
@@ -1070,48 +1113,123 @@ def send_campaign_now(
|
||||
jobs=jobs,
|
||||
policy=synchronous_policy,
|
||||
)
|
||||
# Queue state and its inbox notification become durable only after every
|
||||
# message and the selected transport revision have passed preflight. This
|
||||
# preserves late-ack recovery without leaving rejected work eligible for a
|
||||
# background worker.
|
||||
session.commit()
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
outcome_unknown_count = 0
|
||||
skipped_after_queue = 0
|
||||
for job in jobs:
|
||||
try:
|
||||
result = _deliver_job_with_recovery(
|
||||
session,
|
||||
job=job,
|
||||
context=delivery_contexts[job.id],
|
||||
use_rate_limit=use_rate_limit,
|
||||
enqueue_imap_task=enqueue_imap_task,
|
||||
attempted_count = 0
|
||||
paused_count = 0
|
||||
pause_reason_code: str | None = None
|
||||
batch_state = "ready"
|
||||
batch_manager = _synchronous_smtp_batch_manager(
|
||||
session,
|
||||
jobs=jobs,
|
||||
contexts=delivery_contexts,
|
||||
)
|
||||
batch_entered = False
|
||||
try:
|
||||
with batch_manager as smtp_batch:
|
||||
batch_entered = True
|
||||
# Queue state becomes durable only after local and SMTP
|
||||
# DNS/connectivity/TLS/auth preflight succeeds.
|
||||
session.commit()
|
||||
for index, job in enumerate(jobs):
|
||||
attempted_count += 1
|
||||
try:
|
||||
result = _deliver_job_with_recovery(
|
||||
session,
|
||||
job=job,
|
||||
context=delivery_contexts[job.id],
|
||||
use_rate_limit=use_rate_limit,
|
||||
enqueue_imap_task=enqueue_imap_task,
|
||||
)
|
||||
result_dict = result.as_dict()
|
||||
results.append(result_dict)
|
||||
if result.status in DELIVERY_ACCEPTED_STATUSES | {"already_accepted"}:
|
||||
sent_count += 1
|
||||
elif result.status == JobSendStatus.OUTCOME_UNKNOWN.value:
|
||||
outcome_unknown_count += 1
|
||||
elif result.status in {JobSendStatus.FAILED_TEMPORARY.value, JobSendStatus.FAILED_PERMANENT.value, "failed"}:
|
||||
failed_count += 1
|
||||
else:
|
||||
skipped_after_queue += 1
|
||||
except Exception as exc:
|
||||
failed_count += 1
|
||||
results.append({"job_id": job.id, "status": "failed", "message": str(exc)})
|
||||
if isinstance(exc, SmtpSendError) and exc.systemic:
|
||||
pause_reason_code = exc.reason_code or "smtp_systemic_failure"
|
||||
paused_count = _pause_jobs_after_systemic_smtp_failure(
|
||||
session,
|
||||
campaign_id=job.campaign_id,
|
||||
exclude_job_id=job.id,
|
||||
reason_code=pause_reason_code,
|
||||
)
|
||||
batch_state = "paused"
|
||||
for remaining in jobs[index + 1 :]:
|
||||
results.append(
|
||||
{
|
||||
"job_id": remaining.id,
|
||||
"status": "paused",
|
||||
"message": "Batch paused after a systemic SMTP failure.",
|
||||
}
|
||||
)
|
||||
break
|
||||
smtp_connection_count = int(getattr(smtp_batch, "connection_count", 0) or 0)
|
||||
smtp_reconnect_count = int(getattr(smtp_batch, "reconnect_count", 0) or 0)
|
||||
except (MailProfileError, SmtpConfigurationError, SmtpSendError, OSError) as exc:
|
||||
session.rollback()
|
||||
if batch_entered:
|
||||
# This catch also covers batch teardown. Once entry succeeded it
|
||||
# must not relabel a later error as a no-effect preflight failure.
|
||||
raise SendJobError(
|
||||
"Synchronous delivery was interrupted after SMTP preflight. "
|
||||
"Messages may already have been sent. Inspect the Campaign report "
|
||||
"and resolve uncertain outcomes before retrying."
|
||||
) from exc
|
||||
if isinstance(exc, MailProfileError):
|
||||
reason_code = "mail_profile_preflight_failed"
|
||||
explanation = (
|
||||
"Campaign delivery preflight was blocked by the selected Mail profile, "
|
||||
"credential selection, or effective Mail policy. Check the campaign's "
|
||||
"Mail settings and authorized references"
|
||||
)
|
||||
result_dict = result.as_dict()
|
||||
results.append(result_dict)
|
||||
if result.status in DELIVERY_ACCEPTED_STATUSES | {"already_accepted"}:
|
||||
sent_count += 1
|
||||
elif result.status == JobSendStatus.OUTCOME_UNKNOWN.value:
|
||||
outcome_unknown_count += 1
|
||||
else:
|
||||
skipped_after_queue += 1
|
||||
except Exception as exc: # keep sending other jobs and return per-job details
|
||||
failed_count += 1
|
||||
results.append({"job_id": job.id, "status": "failed", "message": str(exc)})
|
||||
elif isinstance(exc, SmtpConfigurationError):
|
||||
reason_code = "smtp_configuration_preflight_failed"
|
||||
explanation = (
|
||||
"Campaign delivery preflight could not use the selected SMTP configuration. "
|
||||
"Check its server, credentials, and outbound connection policy"
|
||||
)
|
||||
else:
|
||||
explanations = {
|
||||
"smtp_authentication_failed": "SMTP authentication failed for the campaign's selected credential",
|
||||
"smtp_preflight_rejected": "The SMTP server rejected the campaign's connection preflight",
|
||||
"smtp_connectivity_unavailable": "SMTP preflight could not establish DNS, connectivity, or TLS",
|
||||
}
|
||||
reason_code = getattr(exc, "reason_code", None)
|
||||
if reason_code not in explanations:
|
||||
reason_code = "smtp_connectivity_unavailable"
|
||||
explanation = explanations[reason_code]
|
||||
raise SynchronousSendRejected(
|
||||
f"{explanation}; no message was sent.",
|
||||
reason=reason_code,
|
||||
eligible_count=len(jobs),
|
||||
policy=synchronous_policy,
|
||||
) from exc
|
||||
|
||||
return SendCampaignNowResult(
|
||||
campaign_id=campaign.id,
|
||||
version_id=version.id,
|
||||
attempted_count=len(jobs),
|
||||
attempted_count=attempted_count,
|
||||
sent_count=sent_count,
|
||||
failed_count=failed_count,
|
||||
outcome_unknown_count=outcome_unknown_count,
|
||||
skipped_count=queue_result.skipped_count
|
||||
+ queue_result.blocked_count
|
||||
+ skipped_after_queue,
|
||||
skipped_count=skipped_count + skipped_after_queue,
|
||||
paused_count=paused_count,
|
||||
batch_state=batch_state,
|
||||
batch_pause_reason_code=pause_reason_code,
|
||||
smtp_connection_count=smtp_connection_count,
|
||||
smtp_reconnect_count=smtp_reconnect_count,
|
||||
preflight_count=len(delivery_contexts),
|
||||
synchronous_send_policy=synchronous_policy.as_dict(),
|
||||
dry_run=False,
|
||||
@@ -1193,6 +1311,100 @@ def _preflight_synchronous_send_batch(
|
||||
return contexts
|
||||
|
||||
|
||||
def _synchronous_smtp_batch_manager(
|
||||
session: Session,
|
||||
*,
|
||||
jobs: list[CampaignJob],
|
||||
contexts: dict[str, _SendJobDeliveryContext],
|
||||
):
|
||||
mail_items = [
|
||||
(job, contexts[job.id])
|
||||
for job in jobs
|
||||
if DeliveryChannelPolicy(job.delivery_channel_policy).uses_mail
|
||||
]
|
||||
if not mail_items:
|
||||
return nullcontext(None)
|
||||
first_job, first_context = mail_items[0]
|
||||
envelope_froms = {str(context.envelope_from or "") for _job, context in mail_items}
|
||||
transport_keys = {
|
||||
(
|
||||
context.snapshot.mail_profile_id,
|
||||
context.snapshot.smtp_transport_revision,
|
||||
context.snapshot.smtp_server_id,
|
||||
context.snapshot.smtp_credential_id,
|
||||
)
|
||||
for _job, context in mail_items
|
||||
}
|
||||
if len(envelope_froms) != 1 or "" in envelope_froms or len(transport_keys) != 1:
|
||||
raise SynchronousSendRejected(
|
||||
"A synchronous SMTP batch requires one frozen sender and transport selection.",
|
||||
reason="smtp_batch_transport_mismatch",
|
||||
eligible_count=len(jobs),
|
||||
)
|
||||
recipients = sorted(
|
||||
{
|
||||
recipient
|
||||
for _job, context in mail_items
|
||||
for recipient in context.envelope_recipients
|
||||
}
|
||||
)
|
||||
return mail_integration().campaign_smtp_batch(
|
||||
session,
|
||||
tenant_id=first_job.tenant_id,
|
||||
campaign_id=first_job.campaign_id,
|
||||
profile_id=first_context.snapshot.mail_profile_id,
|
||||
envelope_from=str(first_context.envelope_from),
|
||||
envelope_recipients=recipients,
|
||||
from_header=_from_header_from_job(first_job),
|
||||
expected_smtp_transport_revision=first_context.snapshot.smtp_transport_revision or "",
|
||||
smtp_server_id=first_context.snapshot.smtp_server_id,
|
||||
smtp_credential_id=first_context.snapshot.smtp_credential_id,
|
||||
)
|
||||
|
||||
|
||||
def _pause_jobs_after_systemic_smtp_failure(
|
||||
session: Session,
|
||||
*,
|
||||
campaign_id: str,
|
||||
exclude_job_id: str,
|
||||
reason_code: str,
|
||||
) -> int:
|
||||
reason = f"SMTP batch paused after systemic failure ({reason_code[:80]})."
|
||||
changed = (
|
||||
session.query(CampaignJob)
|
||||
.filter(
|
||||
CampaignJob.campaign_id == campaign_id,
|
||||
CampaignJob.id != exclude_job_id,
|
||||
CampaignJob.queue_status == JobQueueStatus.QUEUED.value,
|
||||
CampaignJob.send_status.in_(
|
||||
[JobSendStatus.QUEUED.value, JobSendStatus.FAILED_TEMPORARY.value]
|
||||
),
|
||||
)
|
||||
.update(
|
||||
{
|
||||
CampaignJob.queue_status: JobQueueStatus.PAUSED.value,
|
||||
CampaignJob.last_error: reason,
|
||||
},
|
||||
synchronize_session=False,
|
||||
)
|
||||
)
|
||||
campaign = session.get(Campaign, campaign_id)
|
||||
if changed and campaign is not None:
|
||||
campaign.status = CampaignStatus.READY_TO_QUEUE.value
|
||||
session.add(campaign)
|
||||
audit_event(
|
||||
session,
|
||||
tenant_id=campaign.tenant_id if campaign is not None else None,
|
||||
user_id=None,
|
||||
action="campaign.smtp_batch_paused",
|
||||
object_type="campaign",
|
||||
object_id=campaign_id,
|
||||
details={"reason_code": reason_code[:80], "paused_count": int(changed)},
|
||||
)
|
||||
session.commit()
|
||||
return int(changed)
|
||||
|
||||
|
||||
def enqueue_existing_queued_jobs(
|
||||
session: Session, *, tenant_id: str, campaign_id: str
|
||||
) -> int:
|
||||
@@ -1370,6 +1582,7 @@ def queue_failed_jobs_for_retry(
|
||||
include_permanent: bool = False,
|
||||
force_max_attempts: bool = False,
|
||||
enqueue_celery: bool = True,
|
||||
run_inline: bool = False,
|
||||
dry_run: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Queue known failures and incomplete multi-channel deliveries.
|
||||
@@ -1401,7 +1614,12 @@ def queue_failed_jobs_for_retry(
|
||||
version=version,
|
||||
job_ids=job_ids,
|
||||
):
|
||||
if job.send_status not in allowed:
|
||||
if (
|
||||
job.send_status not in allowed
|
||||
or job.claim_token is not None
|
||||
or job.build_status != JobBuildStatus.BUILT.value
|
||||
or not _single_job_validation_allowed(version, job, include_warnings=True)
|
||||
):
|
||||
skipped.append(
|
||||
{
|
||||
"job_id": job.id,
|
||||
@@ -1427,42 +1645,11 @@ def queue_failed_jobs_for_retry(
|
||||
)
|
||||
continue
|
||||
selected.append(job)
|
||||
if not dry_run:
|
||||
job.queue_status = JobQueueStatus.QUEUED.value
|
||||
job.send_status = JobSendStatus.QUEUED.value
|
||||
job.queued_at = _utcnow()
|
||||
job.claimed_at = None
|
||||
job.claim_token = None
|
||||
job.smtp_started_at = None
|
||||
job.outcome_unknown_at = None
|
||||
session.add(job)
|
||||
|
||||
if not dry_run:
|
||||
if selected:
|
||||
campaign.status = CampaignStatus.QUEUED.value
|
||||
version.workflow_state = CampaignVersionWorkflowState.QUEUED.value
|
||||
_set_version_delivery_mode(
|
||||
version,
|
||||
_asynchronous_delivery_mode(enqueue_celery),
|
||||
)
|
||||
session.add(campaign)
|
||||
session.add(version)
|
||||
session.commit()
|
||||
|
||||
enqueued = 0
|
||||
if _should_enqueue_celery(enqueue_celery) and not dry_run:
|
||||
for job in selected:
|
||||
_celery_enqueue_send_job(job.id)
|
||||
enqueued += 1
|
||||
return {
|
||||
"campaign_id": campaign.id,
|
||||
"version_id": version.id,
|
||||
"action": "retry_failed",
|
||||
"selected_count": len(selected),
|
||||
"enqueued_count": enqueued,
|
||||
"skipped": skipped,
|
||||
"dry_run": dry_run,
|
||||
}
|
||||
return _execute_explicit_delivery_selection(
|
||||
session, campaign=campaign, version=version, selected=selected,
|
||||
skipped=skipped, action="retry_failed", enqueue_celery=enqueue_celery,
|
||||
run_inline=run_inline, dry_run=dry_run,
|
||||
)
|
||||
|
||||
|
||||
def queue_unattempted_jobs(
|
||||
@@ -1473,6 +1660,7 @@ def queue_unattempted_jobs(
|
||||
version_id: str | None = None,
|
||||
job_ids: list[str] | None = None,
|
||||
enqueue_celery: bool = True,
|
||||
run_inline: bool = False,
|
||||
dry_run: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Explicitly queue built jobs that have never started an SMTP attempt."""
|
||||
@@ -1497,10 +1685,12 @@ def queue_unattempted_jobs(
|
||||
eligible = (
|
||||
job.attempt_count == 0
|
||||
and job.postbox_attempt_count == 0
|
||||
and job.print_attempt_count == 0
|
||||
and job.claim_token is None
|
||||
and job.send_status
|
||||
in {JobSendStatus.NOT_QUEUED.value, JobSendStatus.CANCELLED.value}
|
||||
in {JobSendStatus.NOT_QUEUED.value, JobSendStatus.CANCELLED.value, JobSendStatus.QUEUED.value}
|
||||
and job.build_status == JobBuildStatus.BUILT.value
|
||||
and job.validation_status in QUEUEABLE_VALIDATION_STATUSES
|
||||
and _single_job_validation_allowed(version, job, include_warnings=True)
|
||||
)
|
||||
if not eligible:
|
||||
skipped.append(
|
||||
@@ -1511,41 +1701,86 @@ def queue_unattempted_jobs(
|
||||
)
|
||||
continue
|
||||
selected.append(job)
|
||||
if not dry_run:
|
||||
job.queue_status = JobQueueStatus.QUEUED.value
|
||||
job.send_status = JobSendStatus.QUEUED.value
|
||||
job.queued_at = _utcnow()
|
||||
job.claimed_at = None
|
||||
job.claim_token = None
|
||||
job.smtp_started_at = None
|
||||
job.outcome_unknown_at = None
|
||||
job.last_error = None
|
||||
session.add(job)
|
||||
return _execute_explicit_delivery_selection(
|
||||
session, campaign=campaign, version=version, selected=selected,
|
||||
skipped=skipped, action="send_unattempted", enqueue_celery=enqueue_celery,
|
||||
run_inline=run_inline, dry_run=dry_run,
|
||||
)
|
||||
|
||||
|
||||
def _execute_explicit_delivery_selection(
|
||||
session: Session, *, campaign: Campaign, version: CampaignVersion,
|
||||
selected: list[CampaignJob], skipped: list[dict[str, str]], action: str,
|
||||
enqueue_celery: bool, run_inline: bool, dry_run: bool,
|
||||
) -> dict[str, Any]:
|
||||
"""Queue a compare-and-set selection; inline recovery uses normal delivery."""
|
||||
synchronous_policy = None
|
||||
remaining_count = 0
|
||||
if run_inline:
|
||||
try:
|
||||
synchronous_policy = effective_synchronous_send_policy(session, tenant_id=campaign.tenant_id)
|
||||
except CampaignDeliveryPolicyError as exc:
|
||||
raise QueueingError(f"Invalid synchronous Campaign delivery policy: {exc}") from exc
|
||||
if synchronous_policy.max_recipient_jobs == 0:
|
||||
raise QueueingError("Synchronous Campaign delivery is disabled by policy.")
|
||||
remaining_count = max(0, len(selected) - synchronous_policy.max_recipient_jobs)
|
||||
selected = selected[:synchronous_policy.max_recipient_jobs]
|
||||
if not dry_run:
|
||||
if selected:
|
||||
campaign.status = CampaignStatus.QUEUED.value
|
||||
version.workflow_state = CampaignVersionWorkflowState.QUEUED.value
|
||||
_set_version_delivery_mode(
|
||||
version,
|
||||
_asynchronous_delivery_mode(enqueue_celery),
|
||||
)
|
||||
session.add(campaign)
|
||||
session.add(version)
|
||||
session.commit()
|
||||
enqueued = 0
|
||||
if _should_enqueue_celery(enqueue_celery) and not dry_run:
|
||||
_ensure_campaign_approval_gate(session, tenant_id=campaign.tenant_id, version=version)
|
||||
claimed_selection = []
|
||||
for job in selected:
|
||||
_celery_enqueue_send_job(job.id)
|
||||
enqueued += 1
|
||||
return {
|
||||
"campaign_id": campaign.id,
|
||||
"version_id": version.id,
|
||||
"action": "send_unattempted",
|
||||
"selected_count": len(selected),
|
||||
"enqueued_count": enqueued,
|
||||
"skipped": skipped,
|
||||
"dry_run": dry_run,
|
||||
# A worker or another operator may have claimed the row after the
|
||||
# selection query. Never reset that claim or an accepted attempt.
|
||||
changed = session.query(CampaignJob).filter(
|
||||
CampaignJob.id == job.id,
|
||||
CampaignJob.tenant_id == campaign.tenant_id,
|
||||
CampaignJob.send_status == job.send_status,
|
||||
CampaignJob.queue_status == job.queue_status,
|
||||
CampaignJob.attempt_count == job.attempt_count,
|
||||
CampaignJob.postbox_attempt_count == job.postbox_attempt_count,
|
||||
CampaignJob.print_attempt_count == job.print_attempt_count,
|
||||
CampaignJob.claim_token.is_(None),
|
||||
).update({
|
||||
CampaignJob.queue_status: JobQueueStatus.QUEUED.value,
|
||||
CampaignJob.send_status: JobSendStatus.QUEUED.value,
|
||||
CampaignJob.queued_at: _utcnow(),
|
||||
CampaignJob.claimed_at: None,
|
||||
CampaignJob.smtp_started_at: None,
|
||||
CampaignJob.outcome_unknown_at: None,
|
||||
}, synchronize_session=False)
|
||||
if changed:
|
||||
session.refresh(job)
|
||||
claimed_selection.append(job)
|
||||
else:
|
||||
skipped.append({"job_id": job.id, "reason": "delivery state changed during selection"})
|
||||
selected = claimed_selection
|
||||
_persist_campaign_queue(
|
||||
session, campaign=campaign, version=version, queued=selected,
|
||||
delivery_mode=DELIVERY_MODE_SYNCHRONOUS if run_inline else _asynchronous_delivery_mode(enqueue_celery),
|
||||
commit=not run_inline,
|
||||
)
|
||||
result = {
|
||||
"campaign_id": campaign.id, "version_id": version.id, "action": action,
|
||||
"selected_count": len(selected), "remaining_count": remaining_count,
|
||||
"enqueued_count": 0, "skipped": skipped, "dry_run": dry_run,
|
||||
"run_inline": run_inline,
|
||||
}
|
||||
if run_inline and not dry_run and selected:
|
||||
assert synchronous_policy is not None
|
||||
try:
|
||||
outcome = _send_synchronous_job_batch(
|
||||
session, campaign=campaign, version=version, jobs=selected,
|
||||
synchronous_policy=synchronous_policy, skipped_count=len(skipped),
|
||||
)
|
||||
except Exception:
|
||||
# Local preflight may fail before the batch connection is entered.
|
||||
# No pending queue edits may leak into a caller's later commit.
|
||||
session.rollback()
|
||||
raise
|
||||
result.update(outcome.as_dict())
|
||||
elif not dry_run and _should_enqueue_celery(enqueue_celery) and not run_inline:
|
||||
result["enqueued_count"] = _enqueue_campaign_jobs(selected, enabled=True)
|
||||
return result
|
||||
|
||||
|
||||
def send_single_campaign_job(
|
||||
@@ -2583,6 +2818,19 @@ def reconcile_job_outcome(
|
||||
snapshot = ensure_execution_snapshot(session, version)
|
||||
now = _utcnow()
|
||||
attempt = _unfinished_attempt(session, job)
|
||||
if attempt is None:
|
||||
attempt = session.query(SendAttempt).filter(
|
||||
SendAttempt.job_id == job.id,
|
||||
SendAttempt.status == JobSendStatus.OUTCOME_UNKNOWN.value,
|
||||
).order_by(SendAttempt.attempt_number.desc()).first()
|
||||
if decision not in {"smtp_accepted", "not_sent"}:
|
||||
raise QueueingError("decision must be 'smtp_accepted' or 'not_sent'")
|
||||
from govoplan_campaign.backend.services.delivery_recovery import reconcile_campaign_delivery_operation
|
||||
reconcile_campaign_delivery_operation(
|
||||
session, job=job, channel="smtp", claim_token=getattr(attempt, "claim_token", None),
|
||||
effect_occurred=decision == "smtp_accepted", note=evidence_note,
|
||||
)
|
||||
_claim_unknown_reconciliation(session, job, channel="smtp", next_status="smtp_accepted" if decision == "smtp_accepted" else "failed_temporary")
|
||||
if decision == "smtp_accepted":
|
||||
job.send_status = JobSendStatus.SMTP_ACCEPTED.value
|
||||
job.queue_status = JobQueueStatus.DRAFT.value
|
||||
@@ -2778,6 +3026,8 @@ def _reconcile_imap_append_outcome(
|
||||
raise QueueingError(
|
||||
f"IMAP status {job.imap_status} does not require reconciliation"
|
||||
)
|
||||
if decision not in {"imap_appended", "imap_not_appended"}:
|
||||
raise QueueingError("IMAP decision must be 'imap_appended' or 'imap_not_appended'")
|
||||
|
||||
attempt = (
|
||||
session.query(ImapAppendAttempt)
|
||||
@@ -2785,6 +3035,12 @@ def _reconcile_imap_append_outcome(
|
||||
.order_by(ImapAppendAttempt.attempt_number.desc())
|
||||
.first()
|
||||
)
|
||||
from govoplan_campaign.backend.services.delivery_recovery import reconcile_campaign_delivery_operation
|
||||
reconcile_campaign_delivery_operation(
|
||||
session, job=job, channel="imap", claim_token=getattr(attempt, "claim_token", None),
|
||||
effect_occurred=decision == "imap_appended", note=evidence_note,
|
||||
)
|
||||
_claim_unknown_reconciliation(session, job, channel="imap", next_status="appended" if decision == "imap_appended" else "failed")
|
||||
if decision == "imap_appended":
|
||||
job.imap_status = JobImapStatus.APPENDED.value
|
||||
attempt_status = "reconciled_imap_appended"
|
||||
@@ -2823,6 +3079,22 @@ def _reconcile_imap_append_outcome(
|
||||
}
|
||||
|
||||
|
||||
def _claim_unknown_reconciliation(session: Session, job: CampaignJob, *, channel: str, next_status: str) -> None:
|
||||
"""Conflicting operators cannot overwrite an already reconciled outcome."""
|
||||
column = CampaignJob.send_status if channel == "smtp" else CampaignJob.imap_status
|
||||
claim_column = CampaignJob.claim_token if channel == "smtp" else CampaignJob.imap_claim_token
|
||||
claim_token = getattr(job, "claim_token" if channel == "smtp" else "imap_claim_token", None)
|
||||
changed = session.query(CampaignJob).filter(
|
||||
CampaignJob.id == job.id,
|
||||
CampaignJob.tenant_id == job.tenant_id,
|
||||
column == "outcome_unknown",
|
||||
claim_column == claim_token,
|
||||
).update({column: next_status}, synchronize_session=False)
|
||||
if changed != 1:
|
||||
raise QueueingError("The delivery outcome changed; reload its current evidence before reconciliation.")
|
||||
session.refresh(job)
|
||||
|
||||
|
||||
def _verify_eml_evidence(job: CampaignJob, payload: bytes) -> None:
|
||||
if job.eml_size_bytes is not None and len(payload) != job.eml_size_bytes:
|
||||
raise SendJobError(
|
||||
@@ -3467,10 +3739,10 @@ def _preflight_send_campaign_job(
|
||||
message="A delivery outcome is unresolved; reconcile it before any retry.",
|
||||
)
|
||||
if job.send_status == JobSendStatus.SENDING.value:
|
||||
return mark_job_outcome_unknown(
|
||||
session,
|
||||
job,
|
||||
reason="A delivery task resumed while the previous channel attempt was still marked in progress. Automatic redelivery was stopped.",
|
||||
return SendJobResult(
|
||||
job_id=job.id, status="already_sending", attempt_number=job.attempt_count,
|
||||
dry_run=dry_run,
|
||||
message="Another runtime owns the active delivery. A stopped runtime's claim requires explicit guarded recovery.",
|
||||
)
|
||||
if job.send_status == JobSendStatus.CLAIMED.value:
|
||||
return SendJobResult(
|
||||
@@ -3652,6 +3924,13 @@ def _send_claimed_mail_only_job(
|
||||
outcome_unknown = _record_smtp_send_error(
|
||||
session, job=job, attempt=attempt, exc=exc
|
||||
)
|
||||
if exc.systemic:
|
||||
_pause_jobs_after_systemic_smtp_failure(
|
||||
session,
|
||||
campaign_id=job.campaign_id,
|
||||
exclude_job_id=job.id,
|
||||
reason_code=exc.reason_code or "smtp_systemic_failure",
|
||||
)
|
||||
if outcome_unknown is not None:
|
||||
return outcome_unknown
|
||||
raise
|
||||
@@ -4828,13 +5107,19 @@ def _perform_imap_append(
|
||||
)
|
||||
raise ImapAppendError(reason, outcome_unknown=True) from None
|
||||
try:
|
||||
return _record_imap_append_success(
|
||||
outcome = _record_imap_append_success(
|
||||
session,
|
||||
job=claimed.job,
|
||||
attempt=claimed.attempt,
|
||||
claim_token=claimed.claim_token,
|
||||
folder=result.folder,
|
||||
)
|
||||
return replace(
|
||||
outcome,
|
||||
connection_sequence=getattr(result, "connection_sequence", None),
|
||||
session_reused=bool(getattr(result, "session_reused", False)),
|
||||
reconnect_count=int(getattr(result, "reconnect_count", 0) or 0),
|
||||
)
|
||||
except Exception:
|
||||
return _mark_imap_append_outcome_unknown_after_effect(
|
||||
session,
|
||||
@@ -5039,6 +5324,7 @@ def enqueue_pending_imap_appends(
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
version_id: str | None = None,
|
||||
enqueue_celery: bool = True,
|
||||
run_inline: bool = False,
|
||||
dry_run: bool = False,
|
||||
@@ -5046,11 +5332,13 @@ def enqueue_pending_imap_appends(
|
||||
campaign = _get_campaign_for_tenant(
|
||||
session, campaign_id=campaign_id, tenant_id=tenant_id
|
||||
)
|
||||
version = _get_version_for_campaign(session, campaign, version_id=version_id)
|
||||
jobs = (
|
||||
session.query(CampaignJob)
|
||||
.filter(
|
||||
CampaignJob.tenant_id == tenant_id,
|
||||
CampaignJob.campaign_id == campaign.id,
|
||||
CampaignJob.campaign_version_id == version.id,
|
||||
CampaignJob.imap_status.in_(
|
||||
[JobImapStatus.PENDING.value, JobImapStatus.FAILED.value]
|
||||
),
|
||||
@@ -5073,44 +5361,55 @@ def enqueue_pending_imap_appends(
|
||||
results: list[dict[str, Any]] = []
|
||||
appended_count = 0
|
||||
failed_count = 0
|
||||
outcome_unknown_count = 0
|
||||
skipped_count = 0
|
||||
connection_count = 0
|
||||
reconnect_count = 0
|
||||
if run_inline or dry_run:
|
||||
for job in jobs:
|
||||
try:
|
||||
result = append_sent_for_job(session, job_id=job.id, dry_run=dry_run)
|
||||
payload = result.as_dict()
|
||||
results.append(payload)
|
||||
if result.status == JobImapStatus.APPENDED.value:
|
||||
appended_count += 1
|
||||
elif result.status in {
|
||||
"skipped",
|
||||
"not_requested",
|
||||
"not_sent",
|
||||
"already_appended",
|
||||
"dry_run",
|
||||
}:
|
||||
skipped_count += 1
|
||||
except (
|
||||
Exception
|
||||
) as exc: # keep processing later jobs and expose per-job details
|
||||
failed_count += 1
|
||||
results.append(
|
||||
{"job_id": job.id, "status": "failed", "message": str(exc)}
|
||||
)
|
||||
batch_context = nullcontext() if dry_run else mail_integration().campaign_imap_batch(tenant_id=tenant_id, campaign_id=campaign.id)
|
||||
with batch_context as batch:
|
||||
for job in jobs:
|
||||
try:
|
||||
result = append_sent_for_job(session, job_id=job.id, dry_run=dry_run)
|
||||
payload = result.as_dict()
|
||||
results.append(payload)
|
||||
if result.status == JobImapStatus.APPENDED.value:
|
||||
appended_count += 1
|
||||
elif result.status == JobImapStatus.OUTCOME_UNKNOWN.value:
|
||||
outcome_unknown_count += 1
|
||||
elif result.status == JobImapStatus.FAILED.value:
|
||||
failed_count += 1
|
||||
else:
|
||||
skipped_count += 1
|
||||
except Exception as exc:
|
||||
# An uncertain append remains frozen by its per-job
|
||||
# pipeline. The batch never retries the same message.
|
||||
uncertain = bool(getattr(exc, "outcome_unknown", False))
|
||||
if uncertain:
|
||||
outcome_unknown_count += 1
|
||||
else:
|
||||
failed_count += 1
|
||||
results.append({"job_id": job.id, "status": "outcome_unknown" if uncertain else "failed", "message": str(exc)})
|
||||
connection_count = int(getattr(batch, "connection_count", 0) or 0)
|
||||
reconnect_count = int(getattr(batch, "reconnect_count", 0) or 0)
|
||||
elif should_enqueue:
|
||||
for job in jobs:
|
||||
_celery_enqueue_append_sent_job(job.id)
|
||||
|
||||
return {
|
||||
"campaign_id": campaign.id,
|
||||
"version_id": version.id,
|
||||
"pending_count": len(jobs),
|
||||
"enqueued_count": len(jobs) if should_enqueue else 0,
|
||||
"processed_count": len(results) if run_inline and not dry_run else 0,
|
||||
"appended_count": appended_count,
|
||||
"failed_count": failed_count,
|
||||
"outcome_unknown_count": outcome_unknown_count,
|
||||
"skipped_count": skipped_count,
|
||||
"dry_run": dry_run,
|
||||
"run_inline": run_inline,
|
||||
"imap_connection_count": connection_count,
|
||||
"imap_reconnect_count": reconnect_count,
|
||||
"results": results,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Small, address-free delivery counters for an explicitly selected version."""
|
||||
from collections import Counter
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.db.models import CampaignJob, SendAttempt
|
||||
from govoplan_campaign.backend.sending.jobs import _get_campaign_for_tenant, _get_version_for_campaign
|
||||
|
||||
|
||||
def campaign_delivery_progress(session: Session, *, tenant_id: str, campaign_id: str, version_id: str | None = None) -> dict[str, Any]:
|
||||
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
|
||||
version = _get_version_for_campaign(session, campaign, version_id=version_id)
|
||||
# Aggregate status columns only. Never load recipient/attachment JSON, EML,
|
||||
# issues, or attempts for a progress poll.
|
||||
accepted_attempt = session.query(SendAttempt.id).filter(
|
||||
SendAttempt.job_id == CampaignJob.id,
|
||||
SendAttempt.status.in_(("smtp_accepted", "smtp_accepted_with_refusals", "reconciled_smtp_accepted")),
|
||||
).exists()
|
||||
mail_attempted = session.query(SendAttempt.id).filter(SendAttempt.job_id == CampaignJob.id).exists()
|
||||
rows = session.query(
|
||||
CampaignJob.send_status, CampaignJob.queue_status,
|
||||
CampaignJob.imap_status, CampaignJob.delivery_channel_policy,
|
||||
CampaignJob.validation_status, accepted_attempt, mail_attempted, func.count(CampaignJob.id),
|
||||
).filter(
|
||||
CampaignJob.tenant_id == tenant_id,
|
||||
CampaignJob.campaign_id == campaign.id,
|
||||
CampaignJob.campaign_version_id == version.id,
|
||||
).group_by(
|
||||
CampaignJob.send_status, CampaignJob.queue_status,
|
||||
CampaignJob.imap_status, CampaignJob.delivery_channel_policy,
|
||||
CampaignJob.validation_status, accepted_attempt, mail_attempted,
|
||||
).all()
|
||||
smtp = dict.fromkeys(("total", "processed", "accepted", "active", "pending", "failed", "outcome_unknown", "excluded", "paused", "cancelled"), 0)
|
||||
imap = dict.fromkeys(("total", "processed", "appended", "active", "pending", "failed", "outcome_unknown", "excluded"), 0)
|
||||
raw = {"send": Counter(), "queue": Counter(), "imap": Counter()}
|
||||
total = 0
|
||||
for send, queue, append, channel, validation, mail_accepted, attempted_mail, count in rows:
|
||||
total += count
|
||||
raw["send"][send] += count
|
||||
raw["queue"][queue] += count
|
||||
raw["imap"][append] += count
|
||||
# Multi-channel jobs retain their channel policy. Pure Postbox/Print
|
||||
# jobs and policy-excluded mail are not SMTP work.
|
||||
fallback_completed = channel in {"mail_then_postbox", "postbox_then_mail", "mail_then_print"} and send in {"delivered", "sent", "postbox_accepted", "print_accepted"}
|
||||
if channel not in {"mail", "mail_and_postbox", "mail_then_postbox", "postbox_then_mail", "mail_then_print"} or send == "skipped" or validation in {"excluded", "inactive"} or (fallback_completed and not attempted_mail and not mail_accepted):
|
||||
smtp["excluded"] += count
|
||||
else:
|
||||
smtp["total"] += count
|
||||
if mail_accepted or send == "smtp_accepted" or (channel == "mail" and send in {"sent", "delivered"}):
|
||||
bucket = "accepted"
|
||||
elif send in {"claimed", "sending"}:
|
||||
bucket = "active"
|
||||
elif send == "outcome_unknown" or (channel != "mail" and send in {"sent", "delivered"}):
|
||||
bucket = "outcome_unknown"
|
||||
elif send in {"failed_temporary", "failed_permanent", "partially_accepted", "postbox_accepted", "print_accepted"}:
|
||||
bucket = "failed"
|
||||
elif send == "cancelled" or queue == "cancelled":
|
||||
bucket = "cancelled"
|
||||
elif queue == "paused":
|
||||
bucket = "paused"
|
||||
else:
|
||||
bucket = "pending"
|
||||
smtp[bucket] += count
|
||||
if append in {"not_requested", "skipped"}:
|
||||
imap["excluded"] += count
|
||||
else:
|
||||
imap["total"] += count
|
||||
bucket = {"appended": "appended", "appending": "active", "failed": "failed", "outcome_unknown": "outcome_unknown"}.get(append, "pending")
|
||||
imap[bucket] += count
|
||||
smtp["processed"] = sum(smtp[key] for key in ("accepted", "failed", "outcome_unknown", "cancelled"))
|
||||
imap["processed"] = sum(imap[key] for key in ("appended", "failed", "outcome_unknown"))
|
||||
return {
|
||||
"campaign_id": campaign.id, "version_id": version.id,
|
||||
"total_jobs": total, "generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"smtp": smtp, "imap": imap,
|
||||
"status_counts": {kind: dict(counts) for kind, counts in raw.items()},
|
||||
"workflow_state": version.workflow_state,
|
||||
"delivery_mode": version.delivery_mode,
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Explicit, fenced recovery of claims left by a proven stopped runtime."""
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.runtime_coordination import (
|
||||
DistributedLease, RuntimeNode, acquire_lease, release_lease, process_runtime_identity,
|
||||
)
|
||||
from govoplan_core.core.recovery import (
|
||||
RecoveryOperation, RecoveryStatus, record_recovery_checkpoint,
|
||||
transition_recovery_operation, verify_recovery_evidence_chain,
|
||||
)
|
||||
from govoplan_campaign.backend.db.models import CampaignJob, SendAttempt, ImapAppendAttempt
|
||||
from govoplan_campaign.backend.sending.jobs import QueueingError, _update_campaign_after_job
|
||||
|
||||
|
||||
class RecoveryStateConflict(QueueingError):
|
||||
pass
|
||||
|
||||
|
||||
def _utc(value: datetime) -> datetime:
|
||||
return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _key(job: CampaignJob, channel: str) -> str:
|
||||
return f"campaign:{'delivery' if channel == 'smtp' else 'imap'}:{job.tenant_id}:{job.id}"
|
||||
|
||||
|
||||
def _claim_metadata(job: CampaignJob, channel: str, lease: DistributedLease | None, node: RuntimeNode | None) -> dict[str, Any]:
|
||||
state = job.send_status if channel == "smtp" else job.imap_status
|
||||
claim = job.claim_token if channel == "smtp" else job.imap_claim_token
|
||||
active = state in ({"claimed", "sending"} if channel == "smtp" else {"appending"})
|
||||
reason = "not_active"
|
||||
if active:
|
||||
if lease is None or not claim or not lease.holder_node_id or node is None:
|
||||
reason = "owner_not_confirmed_stopped"
|
||||
elif _utc(lease.expires_at) > datetime.now(timezone.utc):
|
||||
reason = "live_claim"
|
||||
elif node.incarnation == lease.holder_incarnation and node.state != "stopped":
|
||||
# Heartbeat age alone is NOT proof that a slow worker is dead.
|
||||
reason = "owner_not_confirmed_stopped"
|
||||
else:
|
||||
reason = "recoverable"
|
||||
revision = hashlib.sha256(json.dumps({
|
||||
"job": job.id, "channel": channel, "state": state, "claim": claim,
|
||||
"attempts": job.attempt_count,
|
||||
"lease": [lease.id, lease.fencing_token, str(lease.expires_at), lease.holder_node_id, lease.holder_incarnation] if lease else None,
|
||||
"owner": [node.incarnation, node.state] if node else None,
|
||||
}, sort_keys=True).encode()).hexdigest()
|
||||
return {"eligible": reason == "recoverable", "revision": revision, "reason": reason}
|
||||
|
||||
|
||||
def job_recovery_metadata(session: Session, jobs: list[CampaignJob]) -> dict[str, dict[str, Any]]:
|
||||
"""Two bounded metadata reads per loaded page, not per recipient."""
|
||||
if not jobs:
|
||||
return {}
|
||||
installation_id = process_runtime_identity().installation_id
|
||||
keys = [_key(job, channel) for job in jobs for channel in ("smtp", "imap")]
|
||||
leases = session.query(DistributedLease).filter(
|
||||
DistributedLease.installation_id == installation_id,
|
||||
DistributedLease.resource_key.in_(keys),
|
||||
).all()
|
||||
by_key = {lease.resource_key: lease for lease in leases}
|
||||
owner_ids = {lease.holder_node_id for lease in leases if lease.holder_node_id}
|
||||
nodes = session.query(RuntimeNode).filter(
|
||||
RuntimeNode.installation_id == installation_id,
|
||||
RuntimeNode.node_id.in_(owner_ids),
|
||||
).all() if owner_ids else []
|
||||
by_owner = {node.node_id: node for node in nodes}
|
||||
result = {}
|
||||
for job in jobs:
|
||||
channels = {}
|
||||
for channel in ("smtp", "imap"):
|
||||
lease = by_key.get(_key(job, channel))
|
||||
channels[channel] = _claim_metadata(job, channel, lease, by_owner.get(lease.holder_node_id) if lease else None)
|
||||
result[job.id] = channels
|
||||
return result
|
||||
|
||||
|
||||
def recover_stale_delivery_claim(
|
||||
session: Session, *, tenant_id: str, campaign_id: str, job_id: str,
|
||||
channel: str, expected_revision: str, note: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Freeze an abandoned effect as unknown; NEVER infer that it was not sent."""
|
||||
if channel not in {"smtp", "imap"} or not note.strip():
|
||||
raise QueueingError("Claim recovery requires a channel and an evidence note.")
|
||||
job = session.get(CampaignJob, job_id)
|
||||
if job is None or job.tenant_id != tenant_id or job.campaign_id != campaign_id:
|
||||
raise QueueingError("Campaign job not found or not accessible")
|
||||
identity = process_runtime_identity()
|
||||
resource_key = _key(job, channel)
|
||||
# Same lock order as runtime authority: lease, operation, domain row.
|
||||
lease = session.query(DistributedLease).filter(
|
||||
DistributedLease.installation_id == identity.installation_id,
|
||||
DistributedLease.resource_key == resource_key,
|
||||
).with_for_update().populate_existing().one_or_none()
|
||||
node = session.query(RuntimeNode).filter(
|
||||
RuntimeNode.installation_id == identity.installation_id,
|
||||
RuntimeNode.node_id == lease.holder_node_id,
|
||||
).with_for_update().populate_existing().one_or_none() if lease and lease.holder_node_id else None
|
||||
session.refresh(job)
|
||||
metadata = _claim_metadata(job, channel, lease, node)
|
||||
if metadata["revision"] != expected_revision:
|
||||
raise RecoveryStateConflict("Delivery claim changed; reload its current evidence before recovery.")
|
||||
if not metadata["eligible"]:
|
||||
raise RecoveryStateConflict("Recovery is blocked until the lease expires and its owning runtime is confirmed stopped or replaced.")
|
||||
claim_token = job.claim_token if channel == "smtp" else job.imap_claim_token
|
||||
state = job.send_status if channel == "smtp" else job.imap_status
|
||||
assert claim_token is not None
|
||||
claim_sha = hashlib.sha256(claim_token.encode()).hexdigest()
|
||||
operation_key = f"campaign-{'delivery' if channel == 'smtp' else 'imap'}:{job.id}:{claim_sha[:32]}"
|
||||
operation = session.query(RecoveryOperation).filter(
|
||||
RecoveryOperation.installation_id == identity.installation_id,
|
||||
RecoveryOperation.module_id == "campaigns",
|
||||
RecoveryOperation.idempotency_key == operation_key,
|
||||
RecoveryOperation.lease_resource_key == resource_key,
|
||||
).with_for_update().one_or_none()
|
||||
if operation is None or operation.status not in {"running", "outcome_unknown"}:
|
||||
raise RecoveryStateConflict("The original durable delivery evidence cannot be recovered safely.")
|
||||
authority = acquire_lease(
|
||||
session, installation_id=identity.installation_id, resource_key=resource_key,
|
||||
holder_node_id=identity.node_id, holder_incarnation=identity.incarnation,
|
||||
ttl_seconds=300, metadata={"module_id": "campaigns", "recovery_operation_id": operation.id},
|
||||
)
|
||||
if authority is None:
|
||||
raise RecoveryStateConflict("Another runtime acquired this delivery claim.")
|
||||
operation.holder_node_id = authority.holder_node_id
|
||||
operation.holder_incarnation = authority.holder_incarnation
|
||||
operation.fencing_token = authority.fencing_token
|
||||
session.add(operation)
|
||||
session.flush()
|
||||
evidence = {"job_id": job.id, "channel": channel, "previous_state": state, "evidence_note_sha256": hashlib.sha256(note.strip().encode()).hexdigest(), "claim_sha256": claim_sha}
|
||||
record_recovery_checkpoint(session, operation, kind="campaign-claim-recovery", summary="An operator fenced a claim owned by a stopped runtime", evidence=evidence, lease_claim=authority)
|
||||
if operation.status != "outcome_unknown":
|
||||
transition_recovery_operation(session, operation, status=RecoveryStatus.OUTCOME_UNKNOWN, kind="campaign-claim-outcome-unknown", summary="The abandoned provider effect requires explicit reconciliation", evidence=evidence, failure_summary="The original runtime stopped before recording a final provider result", lease_claim=authority)
|
||||
if not verify_recovery_evidence_chain(session, operation.id):
|
||||
raise RecoveryStateConflict("Durable delivery evidence verification failed.")
|
||||
state_column = CampaignJob.send_status if channel == "smtp" else CampaignJob.imap_status
|
||||
claim_column = CampaignJob.claim_token if channel == "smtp" else CampaignJob.imap_claim_token
|
||||
changes = {state_column: "outcome_unknown", claim_column: None, CampaignJob.last_error: note.strip()}
|
||||
if channel == "smtp":
|
||||
changes.update({CampaignJob.queue_status: "draft", CampaignJob.outcome_unknown_at: datetime.now(timezone.utc)})
|
||||
else:
|
||||
changes[CampaignJob.imap_claimed_at] = None
|
||||
changed = session.query(CampaignJob).filter(
|
||||
CampaignJob.id == job.id, state_column == state, claim_column == claim_token,
|
||||
).update(changes, synchronize_session=False)
|
||||
if changed != 1:
|
||||
raise RecoveryStateConflict("The delivery claim changed before recovery could be recorded.")
|
||||
attempt_model = SendAttempt if channel == "smtp" else ImapAppendAttempt
|
||||
attempt = session.query(attempt_model).filter(attempt_model.job_id == job.id, attempt_model.claim_token == claim_token).order_by(attempt_model.attempt_number.desc()).first()
|
||||
if attempt is not None:
|
||||
attempt.status = "outcome_unknown"
|
||||
attempt.error_message = note.strip()
|
||||
if channel == "smtp":
|
||||
attempt.finished_at = datetime.now(timezone.utc)
|
||||
session.add(attempt)
|
||||
release_lease(session, authority)
|
||||
session.expire(job)
|
||||
_update_campaign_after_job(session, campaign_id, job.campaign_version_id)
|
||||
session.flush()
|
||||
return {"campaign_id": campaign_id, "version_id": job.campaign_version_id, "job_id": job.id, "channel": channel, "send_status": job.send_status, "imap_status": job.imap_status, "note": note.strip(), "reconciliation_required": True}
|
||||
|
||||
|
||||
def reconcile_campaign_delivery_operation(
|
||||
session: Session, *, job: CampaignJob, channel: str, claim_token: str | None,
|
||||
effect_occurred: bool, note: str,
|
||||
) -> None:
|
||||
"""Resolve only the original Campaign ledger in the caller's audit transaction.
|
||||
|
||||
Older jobs without a claim-bound operation remain supported. Mail's nested
|
||||
provider-effect ledgers are separate evidence and are never rewritten here.
|
||||
"""
|
||||
# A compound external-channel operation may include Postbox/Print effects.
|
||||
# One SMTP decision cannot verify or negate that entire operation.
|
||||
if not claim_token or (channel == "smtp" and getattr(job, "delivery_channel_policy", "mail") != "mail"):
|
||||
return
|
||||
identity = process_runtime_identity()
|
||||
key = _key(job, channel)
|
||||
claim_sha = hashlib.sha256(claim_token.encode()).hexdigest()
|
||||
operation_key = f"campaign-{'delivery' if channel == 'smtp' else 'imap'}:{job.id}:{claim_sha[:32]}"
|
||||
# Acquire the same lock order as effect execution and claim recovery.
|
||||
lease = session.query(DistributedLease).filter(
|
||||
DistributedLease.installation_id == identity.installation_id,
|
||||
DistributedLease.resource_key == key,
|
||||
).with_for_update().populate_existing().one_or_none()
|
||||
operation = session.query(RecoveryOperation).filter(
|
||||
RecoveryOperation.installation_id == identity.installation_id,
|
||||
RecoveryOperation.module_id == "campaigns",
|
||||
RecoveryOperation.idempotency_key == operation_key,
|
||||
RecoveryOperation.lease_resource_key == key,
|
||||
RecoveryOperation.resource_type == "campaign_job",
|
||||
RecoveryOperation.resource_id == job.id,
|
||||
).with_for_update().populate_existing().one_or_none()
|
||||
if operation is None:
|
||||
return
|
||||
if operation.status in {"succeeded", "recovered"}:
|
||||
if (operation.status == "succeeded") != effect_occurred:
|
||||
raise RecoveryStateConflict("The original durable operation already records a different verified outcome.")
|
||||
return
|
||||
if operation.status != "outcome_unknown" or lease is None:
|
||||
raise RecoveryStateConflict("The original durable operation requires guarded claim recovery before reconciliation.")
|
||||
# Even the same API process must not borrow another active operation's
|
||||
# lease merely because its runtime identity happens to match.
|
||||
if lease.holder_node_id is not None:
|
||||
raise RecoveryStateConflict("The original operation still has a runtime owner; recover its stopped claim before reconciliation.")
|
||||
authority = acquire_lease(session, installation_id=identity.installation_id, resource_key=key,
|
||||
holder_node_id=identity.node_id, holder_incarnation=identity.incarnation,
|
||||
ttl_seconds=300, metadata={"module_id": "campaigns", "recovery_operation_id": operation.id})
|
||||
if authority is None:
|
||||
raise RecoveryStateConflict("Another runtime owns the original delivery operation.")
|
||||
operation.holder_node_id = authority.holder_node_id
|
||||
operation.holder_incarnation = authority.holder_incarnation
|
||||
operation.fencing_token = authority.fencing_token
|
||||
session.add(operation)
|
||||
session.flush()
|
||||
evidence = {"verified": True, "checks": {"operator_provider_evidence_recorded": True, "matching_claim_attempt": True},
|
||||
"job_id": job.id, "channel": channel, "effect_occurred": effect_occurred,
|
||||
"claim_sha256": claim_sha, "evidence_note_sha256": hashlib.sha256(note.strip().encode()).hexdigest()}
|
||||
record_recovery_checkpoint(session, operation, kind="campaign-reconciliation-fence", summary="An operator acquired authority for the original Campaign attempt", evidence=evidence, lease_claim=authority)
|
||||
if effect_occurred:
|
||||
transition_recovery_operation(session, operation, status=RecoveryStatus.SUCCEEDED,
|
||||
kind="campaign-reconciled-provider-acceptance", summary="Operator evidence confirms the Campaign effect was accepted", evidence=evidence, lease_claim=authority)
|
||||
else:
|
||||
for next_status in (RecoveryStatus.RECOVERY_REQUIRED, RecoveryStatus.RECOVERING, RecoveryStatus.RECOVERED):
|
||||
transition_recovery_operation(session, operation, status=next_status,
|
||||
kind=f"campaign-reconciled-absence-{next_status.value}", summary="Operator evidence confirms the Campaign effect did not occur",
|
||||
evidence=evidence, failure_summary="The original external effect was verified absent" if next_status == RecoveryStatus.RECOVERY_REQUIRED else None, lease_claim=authority)
|
||||
if not verify_recovery_evidence_chain(session, operation.id):
|
||||
raise RecoveryStateConflict("Original Campaign recovery evidence verification failed.")
|
||||
release_lease(session, authority)
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
"""Deterministic collision naming shared by message and ZIP construction."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class FilenameAllocator:
|
||||
"""Append-only names with the original first-free, casefolded suffix rule."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.used: set[str] = set()
|
||||
self._next: dict[str, int] = {}
|
||||
|
||||
def allocate(self, filename: str) -> str:
|
||||
key = filename.casefold()
|
||||
candidate = filename
|
||||
path = Path(filename)
|
||||
counter = self._next.get(key, 2)
|
||||
while candidate.casefold() in self.used:
|
||||
candidate = f"{path.stem} ({counter}){path.suffix}"
|
||||
counter += 1
|
||||
self.used.add(candidate.casefold())
|
||||
self._next[key] = counter
|
||||
return candidate
|
||||
@@ -1,11 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_campaign.backend.services.review_decisions import review_decision_metadata
|
||||
from govoplan_campaign.backend.services.delivery_recovery import job_recovery_metadata
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import HTTPException, Query, status
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy import String, and_, cast, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.schemas import (
|
||||
@@ -68,11 +71,38 @@ def _job_review_key(job: CampaignJob) -> str:
|
||||
return str(job.entry_id or job.entry_index)
|
||||
|
||||
|
||||
def _public_recipient_groups(value: object) -> dict[str, list[dict[str, str]]]:
|
||||
"""Project only frozen recipient display fields, in their authored order.
|
||||
|
||||
The recipient-aware jobs endpoints enforce recipient-read before loading
|
||||
these rows. Do not project other arbitrary data from the frozen envelope.
|
||||
"""
|
||||
recipients = value if isinstance(value, dict) else {}
|
||||
groups: dict[str, list[dict[str, str]]] = {}
|
||||
for group in ("to", "cc", "bcc"):
|
||||
values = recipients.get(group)
|
||||
entries = values if isinstance(values, list) else [values]
|
||||
addresses: list[dict[str, str]] = []
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict) or not isinstance(entry.get("email"), str):
|
||||
continue
|
||||
email = entry["email"].strip()
|
||||
if not email:
|
||||
continue
|
||||
address = {"email": email}
|
||||
if isinstance(entry.get("name"), str) and entry["name"].strip():
|
||||
address["name"] = entry["name"].strip()
|
||||
addresses.append(address)
|
||||
groups[group] = addresses
|
||||
return groups
|
||||
|
||||
|
||||
def _job_summary_payload(
|
||||
job: CampaignJob,
|
||||
*,
|
||||
reviewed_keys: set[str] | None = None,
|
||||
calendar_invitation: dict[str, object] | None = None,
|
||||
recovery: dict[str, object] | None = None,
|
||||
) -> dict[str, object]:
|
||||
review_key = _job_review_key(job)
|
||||
return {
|
||||
@@ -81,6 +111,8 @@ def _job_summary_payload(
|
||||
"entry_index": job.entry_index,
|
||||
"entry_id": job.entry_id,
|
||||
"recipient_email": job.recipient_email,
|
||||
"resolved_recipients": _public_recipient_groups(getattr(job, "resolved_recipients", None)),
|
||||
"recovery": recovery or {},
|
||||
"subject": job.subject,
|
||||
"message_id_header": job.message_id_header,
|
||||
"build_status": job.build_status,
|
||||
@@ -114,6 +146,7 @@ def _job_summary_payload(
|
||||
"attachment_count": len(job.resolved_attachments or []),
|
||||
"review_key": review_key,
|
||||
"reviewed": review_key in reviewed_keys if reviewed_keys is not None else False,
|
||||
"review_decision": review_decision_metadata(job),
|
||||
"matched_file_count": sum(
|
||||
len(item.get("matches") or [])
|
||||
for item in (job.resolved_attachments or [])
|
||||
@@ -128,9 +161,10 @@ def _job_detail_payload(
|
||||
job: CampaignJob,
|
||||
*,
|
||||
calendar_invitation: dict[str, object] | None = None,
|
||||
recovery: dict[str, object] | None = None,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
**_job_summary_payload(job, calendar_invitation=calendar_invitation),
|
||||
**_job_summary_payload(job, calendar_invitation=calendar_invitation, recovery=recovery),
|
||||
"message_id_header": job.message_id_header,
|
||||
"issues": job.issues_snapshot or [],
|
||||
"attachments": public_campaign_payload(job.resolved_attachments or []),
|
||||
@@ -561,13 +595,13 @@ def _review_metadata_counts(
|
||||
bulk_acceptable_count = 0
|
||||
for entry_id, entry_index, build_status, validation_status in review_rows:
|
||||
key = str(entry_id or entry_index)
|
||||
if build_status != "built" or validation_status == "blocked":
|
||||
if validation_status == "blocked" or (build_status != "built" and validation_status not in {"excluded", "inactive"}):
|
||||
blocking_count += 1
|
||||
if validation_status == "needs_review":
|
||||
required_count += 1
|
||||
if key in reviewed_keys:
|
||||
reviewed_required_count += 1
|
||||
elif validation_status in {"warning", "excluded"}:
|
||||
elif validation_status == "warning":
|
||||
bulk_acceptable_count += 1
|
||||
|
||||
return {
|
||||
@@ -636,6 +670,22 @@ CAMPAIGN_JOB_GRID_LIST_FILTERS = {
|
||||
}
|
||||
|
||||
|
||||
def _campaign_recipient_search_expression(pattern: str):
|
||||
return or_(
|
||||
CampaignJob.recipient_email.ilike(pattern, escape="\\"),
|
||||
CampaignJob.entry_id.ilike(pattern, escape="\\"),
|
||||
*(
|
||||
cast(CampaignJob.resolved_recipients[group], String).ilike(pattern, escape="\\")
|
||||
for group in ("to", "cc", "bcc")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _job_page_recovery_metadata(session: Session, jobs: list[CampaignJob]) -> dict[str, dict[str, object]]:
|
||||
active = [job for job in jobs if job.send_status in {"claimed", "sending"} or job.imap_status == "appending"]
|
||||
return job_recovery_metadata(session, active) if active else {}
|
||||
|
||||
|
||||
def _campaign_jobs_grid_filter_expressions(
|
||||
grid_filters: dict[str, str] | None,
|
||||
) -> list[object]:
|
||||
@@ -645,10 +695,7 @@ def _campaign_jobs_grid_filter_expressions(
|
||||
if recipient:
|
||||
pattern = _contains_pattern(recipient)
|
||||
expressions.append(
|
||||
or_(
|
||||
CampaignJob.recipient_email.ilike(pattern, escape="\\"),
|
||||
CampaignJob.entry_id.ilike(pattern, escape="\\"),
|
||||
)
|
||||
_campaign_recipient_search_expression(pattern)
|
||||
)
|
||||
subject = values.get("subject", "").strip()
|
||||
if subject:
|
||||
@@ -801,12 +848,11 @@ def _campaign_jobs_query_context(
|
||||
if imap_status:
|
||||
filtered.append(CampaignJob.imap_status.in_(imap_status))
|
||||
if query_text and query_text.strip():
|
||||
pattern = f"%{query_text.strip()}%"
|
||||
pattern = _contains_pattern(query_text.strip())
|
||||
filtered.append(
|
||||
or_(
|
||||
CampaignJob.recipient_email.ilike(pattern),
|
||||
CampaignJob.subject.ilike(pattern),
|
||||
CampaignJob.entry_id.ilike(pattern),
|
||||
_campaign_recipient_search_expression(pattern),
|
||||
CampaignJob.subject.ilike(pattern, escape="\\"),
|
||||
)
|
||||
)
|
||||
filtered.extend(_campaign_jobs_grid_filter_expressions(grid_filters))
|
||||
@@ -875,12 +921,14 @@ def _campaign_jobs_page_response(
|
||||
if changed_job_ids is not None:
|
||||
jobs = [job for job in jobs if job.id in changed_job_ids]
|
||||
calendar_invitations = _calendar_invitations_for_jobs(session, jobs)
|
||||
recovery = _job_page_recovery_metadata(session, jobs)
|
||||
return CampaignJobsResponse(
|
||||
jobs=[
|
||||
_job_summary_payload(
|
||||
job,
|
||||
reviewed_keys=reviewed_keys,
|
||||
calendar_invitation=calendar_invitations.get(job.id),
|
||||
recovery=recovery.get(job.id),
|
||||
)
|
||||
for job in jobs
|
||||
],
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Shared, side-effect-free eligibility for individual and grouped review."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
def review_decision_metadata(job: Any) -> dict[str, Any]:
|
||||
issues = [item for item in (job.issues_snapshot or []) if isinstance(item, dict)]
|
||||
eligible = (
|
||||
getattr(job, "build_status", "built") == "built"
|
||||
and job.validation_status == "needs_review"
|
||||
and not any(str(item.get("behavior") or "").lower() == "block" for item in issues)
|
||||
)
|
||||
reviewable = [item for item in issues if str(item.get("behavior") or "").lower() == "ask"]
|
||||
evidence = reviewable or issues
|
||||
categories = sorted({
|
||||
(str(item.get("code") or ""), str(item.get("behavior") or ""), str(item.get("source") or ""))
|
||||
for item in evidence
|
||||
})
|
||||
return {
|
||||
"eligible": eligible,
|
||||
"category_key": hashlib.sha256(json.dumps(categories, separators=(",", ":")).encode()).hexdigest() if eligible else "",
|
||||
"reason_required": eligible and any(str(item.get("source") or "").startswith("attachments") for item in reviewable),
|
||||
"issue_codes": sorted({str(item.get("code")) for item in evidence if item.get("code")}),
|
||||
}
|
||||
@@ -2,6 +2,8 @@ from __future__ import annotations
|
||||
|
||||
import binascii
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
from importlib import metadata
|
||||
import secrets
|
||||
import stat
|
||||
import struct
|
||||
@@ -10,6 +12,8 @@ from pathlib import Path
|
||||
from typing import Iterable
|
||||
import zlib
|
||||
|
||||
from govoplan_campaign.backend.services.filenames import FilenameAllocator
|
||||
|
||||
try:
|
||||
import pyzipper
|
||||
except ImportError: # pragma: no cover
|
||||
@@ -22,18 +26,11 @@ ZIP_METHOD_STANDARD = "zip_standard"
|
||||
|
||||
def _normalized_members(files: Iterable[Path | ArchiveMember]) -> list[ArchiveMember]:
|
||||
members: list[ArchiveMember] = []
|
||||
used_names: set[str] = set()
|
||||
names = FilenameAllocator()
|
||||
for item in files:
|
||||
path, requested_name = item if isinstance(item, tuple) else (item, item.name)
|
||||
requested = Path(requested_name).name or path.name
|
||||
stem = Path(requested).stem
|
||||
suffix = Path(requested).suffix
|
||||
candidate = requested
|
||||
counter = 2
|
||||
while candidate.casefold() in used_names:
|
||||
candidate = f"{stem} ({counter}){suffix}"
|
||||
counter += 1
|
||||
used_names.add(candidate.casefold())
|
||||
candidate = names.allocate(requested)
|
||||
members.append((path, candidate))
|
||||
return members
|
||||
|
||||
@@ -49,6 +46,8 @@ def create_zip_archive(
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
members = _normalized_members(files)
|
||||
if password:
|
||||
if method not in {ZIP_METHOD_AES, ZIP_METHOD_STANDARD}:
|
||||
raise ValueError(f"Unsupported password-encryption method: {method}")
|
||||
if method == ZIP_METHOD_STANDARD:
|
||||
_create_zipcrypto_archive(output_path, members, password)
|
||||
return output_path
|
||||
@@ -61,6 +60,51 @@ def create_zip_archive(
|
||||
return output_path
|
||||
|
||||
|
||||
def zip_archive_evidence(
|
||||
output_path: Path,
|
||||
members: Iterable[Path | ArchiveMember],
|
||||
*,
|
||||
password_protected: bool,
|
||||
method: str,
|
||||
) -> dict[str, object]:
|
||||
"""Return password-free, content-addressed evidence for one built archive."""
|
||||
|
||||
normalized = _normalized_members(members)
|
||||
archive_bytes = output_path.read_bytes()
|
||||
if password_protected and method == ZIP_METHOD_AES:
|
||||
try:
|
||||
implementation_version = metadata.version("pyzipper")
|
||||
except metadata.PackageNotFoundError: # pragma: no cover - guarded by writer
|
||||
implementation_version = "unknown"
|
||||
implementation = "pyzipper"
|
||||
archive_format = "WinZip AES"
|
||||
elif password_protected and method == ZIP_METHOD_STANDARD:
|
||||
implementation = "govoplan-campaign.zipcrypto"
|
||||
implementation_version = "1"
|
||||
archive_format = "Legacy ZipCrypto"
|
||||
else:
|
||||
implementation = "python.zipfile"
|
||||
implementation_version = "stdlib"
|
||||
archive_format = "ZIP (unencrypted)"
|
||||
return {
|
||||
"format": archive_format,
|
||||
"method": method if password_protected else "none",
|
||||
"password_protected": password_protected,
|
||||
"implementation": implementation,
|
||||
"implementation_version": implementation_version,
|
||||
"archive_sha256": hashlib.sha256(archive_bytes).hexdigest(),
|
||||
"archive_size_bytes": len(archive_bytes),
|
||||
"members": [
|
||||
{
|
||||
"name": archive_name,
|
||||
"sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
|
||||
"size_bytes": path.stat().st_size,
|
||||
}
|
||||
for path, archive_name in normalized
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def create_encrypted_zip(output_path: Path, files: list[Path], password: str, method: str = ZIP_METHOD_AES) -> Path:
|
||||
"""Backward-compatible wrapper for the original per-rule ZIP helper."""
|
||||
|
||||
|
||||
@@ -0,0 +1,778 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import replace
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignVersion,
|
||||
CampaignWorkAssignment,
|
||||
)
|
||||
from govoplan_campaign.backend.persistence.versions import create_minimal_campaign
|
||||
from govoplan_campaign.backend.route_support import _get_campaign_for_principal
|
||||
from govoplan_campaign.backend.routes.assignments import (
|
||||
_actor_label,
|
||||
_mirror_assignment_to_tasks,
|
||||
_notify_assignment,
|
||||
_record_event,
|
||||
_require_resolved_assignee,
|
||||
_resolve_assignee,
|
||||
)
|
||||
from govoplan_campaign.backend.schemas import CampaignWorkAssigneeInput
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_core.auth import ApiPrincipal, has_scope
|
||||
from govoplan_core.core.automation import (
|
||||
ActionDefinition,
|
||||
ActionExecutionRequest,
|
||||
ActionExecutionResult,
|
||||
ActionPreview,
|
||||
EffectDefinition,
|
||||
EffectPreview,
|
||||
ObservedEffect,
|
||||
)
|
||||
from govoplan_core.core.campaigns import (
|
||||
CampaignWorkHandoffInspection,
|
||||
CampaignWorkHandoffRef,
|
||||
CampaignWorkHandoffRequest,
|
||||
)
|
||||
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH
|
||||
from govoplan_core.core.tasks import CAPABILITY_TASK_COMMANDS
|
||||
from govoplan_core.security.time import utc_now
|
||||
|
||||
|
||||
ACTION_KEY = "campaigns.work.prepare"
|
||||
ASSIGNMENT_EFFECT = "campaigns.work.assignment_created"
|
||||
CAMPAIGN_EFFECT = "campaigns.work.campaign_created"
|
||||
|
||||
|
||||
class SqlCampaignWorkOrchestrationProvider:
|
||||
"""Campaign-owned adapter used through optional Core capabilities only."""
|
||||
|
||||
def __init__(self, *, registry: object | None = None) -> None:
|
||||
self._registry = registry
|
||||
|
||||
def action_definitions(self) -> tuple[ActionDefinition, ...]:
|
||||
return (
|
||||
ActionDefinition(
|
||||
action_key=ACTION_KEY,
|
||||
owner_module="campaigns",
|
||||
description=(
|
||||
"Reference or create a Campaign and open one authorization-neutral "
|
||||
"accountable work hand-off."
|
||||
),
|
||||
input_schema_ref="govoplan/campaigns/work-handoff.v1",
|
||||
required_scopes=(
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:campaign:create",
|
||||
"campaigns:assignment:manage",
|
||||
),
|
||||
policy_checks=(
|
||||
"campaign access is checked independently of assignment",
|
||||
"the assignee must already have Campaign access",
|
||||
"the expected Campaign revision must still be current",
|
||||
),
|
||||
risk_level="moderate",
|
||||
reversibility="compensatable",
|
||||
expected_effect_keys=(ASSIGNMENT_EFFECT, CAMPAIGN_EFFECT),
|
||||
idempotency_strategy="caller_supplied",
|
||||
audit_event_types=(
|
||||
"campaign.assignment.created",
|
||||
"campaign.created_minimal",
|
||||
),
|
||||
preview_required=True,
|
||||
recovery_mode="atomic",
|
||||
recovery_verification=(
|
||||
"resolve the assignment by tenant and orchestration idempotency key",
|
||||
"verify the exact Campaign version and assignment revisions",
|
||||
"confirm the assigned principal still has independent Campaign access",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def effect_definitions(self) -> tuple[EffectDefinition, ...]:
|
||||
return (
|
||||
EffectDefinition(
|
||||
effect_key=ASSIGNMENT_EFFECT,
|
||||
owner_module="campaigns",
|
||||
operation="created",
|
||||
description="Create an accountable Campaign work assignment.",
|
||||
resource_types=("campaign_work_assignment",),
|
||||
audit_event_types=("campaign.assignment.created",),
|
||||
compensation_hint="Cancel the open assignment through Campaign work.",
|
||||
),
|
||||
EffectDefinition(
|
||||
effect_key=CAMPAIGN_EFFECT,
|
||||
owner_module="campaigns",
|
||||
operation="created",
|
||||
description="Create a minimal Campaign draft when no campaign is referenced.",
|
||||
resource_types=("campaign", "campaign_version"),
|
||||
audit_event_types=("campaign.created_minimal",),
|
||||
compensation_hint=(
|
||||
"Delete the untouched draft under the normal Campaign lifecycle policy."
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def preview_action(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: ActionExecutionRequest,
|
||||
) -> ActionPreview:
|
||||
if request.action_key != ACTION_KEY:
|
||||
return _blocked_preview("The Campaign work action is not supported.")
|
||||
try:
|
||||
sql_session, api_principal = _context(session, principal)
|
||||
handoff = _request(request)
|
||||
_preview_handoff(sql_session, api_principal, handoff)
|
||||
except (HTTPException, TypeError, ValueError) as exc:
|
||||
return _blocked_preview(_message(exc))
|
||||
creating = handoff.campaign_id is None
|
||||
effects = [
|
||||
EffectPreview(
|
||||
effect_key=ASSIGNMENT_EFFECT,
|
||||
summary="Open one revision-bearing Campaign work assignment.",
|
||||
)
|
||||
]
|
||||
if creating:
|
||||
effects.insert(
|
||||
0,
|
||||
EffectPreview(
|
||||
effect_key=CAMPAIGN_EFFECT,
|
||||
summary="Create one minimal Campaign draft and initial version.",
|
||||
),
|
||||
)
|
||||
return ActionPreview(
|
||||
action_key=ACTION_KEY,
|
||||
allowed=True,
|
||||
summary=(
|
||||
"Create a Campaign draft and open accountable work."
|
||||
if creating
|
||||
else "Reference the current Campaign revision and open accountable work."
|
||||
),
|
||||
risk_level="moderate",
|
||||
reversibility="compensatable",
|
||||
effects=tuple(effects),
|
||||
policy_provenance=(
|
||||
{
|
||||
"code": "campaign_assignment_does_not_grant_access",
|
||||
"assignment_authorization_neutral": True,
|
||||
"campaign_access_rechecked_on_resume": True,
|
||||
},
|
||||
),
|
||||
preview_ref=f"campaign-work-preview:{_request_hash(handoff)}",
|
||||
)
|
||||
|
||||
def execute_action(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: ActionExecutionRequest,
|
||||
) -> ActionExecutionResult:
|
||||
if request.action_key != ACTION_KEY:
|
||||
raise ValueError("The Campaign work action is not supported.")
|
||||
sql_session, api_principal = _context(session, principal)
|
||||
handoff = _request(request)
|
||||
ref = self.prepare_handoff(
|
||||
sql_session,
|
||||
api_principal,
|
||||
request=handoff,
|
||||
)
|
||||
effects = [
|
||||
ObservedEffect(
|
||||
effect_key=ASSIGNMENT_EFFECT,
|
||||
operation="created",
|
||||
resource_ref=ref.assignment_ref,
|
||||
summary=(
|
||||
"Reused the existing idempotent Campaign work assignment."
|
||||
if ref.replayed
|
||||
else "Created the Campaign work assignment."
|
||||
),
|
||||
metadata={"replayed": ref.replayed},
|
||||
)
|
||||
]
|
||||
if not ref.replayed and handoff.campaign_id is None:
|
||||
effects.insert(
|
||||
0,
|
||||
ObservedEffect(
|
||||
effect_key=CAMPAIGN_EFFECT,
|
||||
operation="created",
|
||||
resource_ref=ref.campaign_ref,
|
||||
summary="Created the minimal Campaign draft.",
|
||||
),
|
||||
)
|
||||
return ActionExecutionResult(
|
||||
state="completed",
|
||||
output=_ref_payload(ref),
|
||||
observed_effects=tuple(effects),
|
||||
audit_event_refs=(
|
||||
str(ref.provenance["audit_event_ref"]),
|
||||
)
|
||||
if ref.provenance.get("audit_event_ref")
|
||||
else (),
|
||||
)
|
||||
|
||||
def prepare_handoff(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: CampaignWorkHandoffRequest,
|
||||
) -> CampaignWorkHandoffRef:
|
||||
sql_session, api_principal = _context(session, principal)
|
||||
if api_principal.tenant_id != request.tenant_id:
|
||||
raise ValueError("Campaign hand-off tenant does not match the principal")
|
||||
request_hash = _request_hash(request)
|
||||
existing = (
|
||||
sql_session.query(CampaignWorkAssignment)
|
||||
.filter(
|
||||
CampaignWorkAssignment.tenant_id == request.tenant_id,
|
||||
CampaignWorkAssignment.orchestration_idempotency_key
|
||||
== request.idempotency_key,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if existing is not None:
|
||||
if existing.orchestration_request_sha256 != request_hash:
|
||||
raise ValueError(
|
||||
"Campaign hand-off idempotency key was already used for "
|
||||
"different input."
|
||||
)
|
||||
campaign = _get_campaign_for_principal(
|
||||
sql_session,
|
||||
existing.campaign_id,
|
||||
api_principal,
|
||||
)
|
||||
return _handoff_ref(
|
||||
sql_session,
|
||||
campaign=campaign,
|
||||
assignment=existing,
|
||||
registry=self._registry,
|
||||
replayed=True,
|
||||
)
|
||||
|
||||
campaign, version, created = _campaign_and_version(
|
||||
sql_session,
|
||||
api_principal,
|
||||
request,
|
||||
create=True,
|
||||
)
|
||||
resolution = _resolve_assignee(
|
||||
sql_session,
|
||||
campaign=campaign,
|
||||
assignee=CampaignWorkAssigneeInput(
|
||||
type=request.assignee_kind,
|
||||
id=request.assignee_id,
|
||||
),
|
||||
)
|
||||
_require_resolved_assignee(resolution)
|
||||
now = utc_now()
|
||||
assignment = CampaignWorkAssignment(
|
||||
tenant_id=campaign.tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
campaign_version_id=version.id,
|
||||
reference_kind="campaign_version",
|
||||
reference_id=version.id,
|
||||
reference_label=f"Campaign version {version.version_number}",
|
||||
purpose=request.purpose.strip(),
|
||||
status="open",
|
||||
due_at=request.due_at,
|
||||
assignee_type=request.assignee_kind,
|
||||
assignee_id=request.assignee_id.strip(),
|
||||
assignee_label_snapshot=(resolution.label or request.assignee_id)[:500],
|
||||
assignee_current_label=resolution.label,
|
||||
assignee_resolution_state=resolution.state,
|
||||
resolution_provenance={
|
||||
**resolution.provenance,
|
||||
"source": "workflow",
|
||||
"workflow_instance_id": request.workflow_instance_id,
|
||||
"workflow_step_id": request.workflow_step_id,
|
||||
"expected_campaign_revision": request.expected_campaign_revision,
|
||||
},
|
||||
resolution_checked_at=now,
|
||||
assigned_by_user_id=api_principal.user.id,
|
||||
assigned_by_label_snapshot=_actor_label(api_principal),
|
||||
orchestration_idempotency_key=request.idempotency_key,
|
||||
orchestration_request_sha256=request_hash,
|
||||
orchestration_correlation_id=request.correlation_id,
|
||||
workflow_instance_id=request.workflow_instance_id,
|
||||
workflow_step_id=request.workflow_step_id,
|
||||
)
|
||||
sql_session.add(assignment)
|
||||
sql_session.flush()
|
||||
_record_event(
|
||||
sql_session,
|
||||
assignment=assignment,
|
||||
principal=api_principal,
|
||||
event_kind="assigned",
|
||||
details={
|
||||
"source": "workflow",
|
||||
"workflow_instance_id": request.workflow_instance_id,
|
||||
"workflow_step_id": request.workflow_step_id,
|
||||
},
|
||||
)
|
||||
if request.mirror_to_tasks:
|
||||
_mirror_assignment_to_tasks(
|
||||
sql_session,
|
||||
campaign=campaign,
|
||||
assignment=assignment,
|
||||
principal=api_principal,
|
||||
)
|
||||
else:
|
||||
assignment.task_mirror_status = "skipped"
|
||||
_notify_assignment(
|
||||
sql_session,
|
||||
campaign=campaign,
|
||||
assignment=assignment,
|
||||
event_kind="assigned",
|
||||
)
|
||||
audit_ref = audit_from_principal(
|
||||
sql_session,
|
||||
api_principal,
|
||||
action="campaign.assignment.created",
|
||||
object_type="campaign_work_assignment",
|
||||
object_id=assignment.id,
|
||||
details={
|
||||
"campaign_id": campaign.id,
|
||||
"campaign_version_id": version.id,
|
||||
"campaign_revision": version.edit_revision,
|
||||
"resource_revision": assignment.resource_revision,
|
||||
"source": "workflow",
|
||||
"workflow_instance_id": request.workflow_instance_id,
|
||||
"workflow_step_id": request.workflow_step_id,
|
||||
"assignment_authorization_neutral": True,
|
||||
"purpose_disclosed": False,
|
||||
},
|
||||
correlation_id=request.correlation_id,
|
||||
causation_id=request.workflow_step_id,
|
||||
commit=False,
|
||||
)
|
||||
if created:
|
||||
audit_from_principal(
|
||||
sql_session,
|
||||
api_principal,
|
||||
action="campaign.created_minimal",
|
||||
object_type="campaign",
|
||||
object_id=campaign.id,
|
||||
details={
|
||||
"version_id": version.id,
|
||||
"external_id": campaign.external_id,
|
||||
"source": "workflow",
|
||||
"workflow_instance_id": request.workflow_instance_id,
|
||||
},
|
||||
correlation_id=request.correlation_id,
|
||||
causation_id=request.workflow_step_id,
|
||||
commit=False,
|
||||
)
|
||||
sql_session.flush()
|
||||
ref = _handoff_ref(
|
||||
sql_session,
|
||||
campaign=campaign,
|
||||
assignment=assignment,
|
||||
registry=self._registry,
|
||||
)
|
||||
return replace(
|
||||
ref,
|
||||
provenance={**dict(ref.provenance), "audit_event_ref": audit_ref.id},
|
||||
)
|
||||
|
||||
def inspect_handoff(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
assignment_id: str,
|
||||
expected_revision: int | None = None,
|
||||
) -> CampaignWorkHandoffInspection:
|
||||
try:
|
||||
sql_session, api_principal = _context(session, principal)
|
||||
except TypeError as exc:
|
||||
return CampaignWorkHandoffInspection(allowed=False, reason=str(exc))
|
||||
if api_principal.tenant_id != tenant_id:
|
||||
return CampaignWorkHandoffInspection(
|
||||
allowed=False,
|
||||
reason="Campaign hand-off tenant does not match the principal.",
|
||||
provenance={"code": "campaign_handoff_tenant_mismatch"},
|
||||
)
|
||||
assignment = sql_session.get(CampaignWorkAssignment, assignment_id)
|
||||
if assignment is None or assignment.tenant_id != tenant_id:
|
||||
return CampaignWorkHandoffInspection(
|
||||
allowed=False,
|
||||
reason="Campaign work assignment is unavailable.",
|
||||
provenance={"code": "campaign_handoff_missing"},
|
||||
)
|
||||
try:
|
||||
_get_campaign_for_principal(
|
||||
sql_session,
|
||||
assignment.campaign_id,
|
||||
api_principal,
|
||||
)
|
||||
except HTTPException as exc:
|
||||
return CampaignWorkHandoffInspection(
|
||||
allowed=False,
|
||||
status=assignment.status, # type: ignore[arg-type]
|
||||
assignment_revision=assignment.resource_revision,
|
||||
reason=_message(exc),
|
||||
provenance={
|
||||
"code": "campaign_handoff_access_revoked",
|
||||
"campaign_id": assignment.campaign_id,
|
||||
"assignment_does_not_grant_access": True,
|
||||
},
|
||||
)
|
||||
if (
|
||||
expected_revision is not None
|
||||
and assignment.resource_revision != expected_revision
|
||||
):
|
||||
return CampaignWorkHandoffInspection(
|
||||
allowed=False,
|
||||
status=assignment.status, # type: ignore[arg-type]
|
||||
assignment_revision=assignment.resource_revision,
|
||||
action_url=_action_url(assignment),
|
||||
assignment_ref=_assignment_ref(assignment),
|
||||
reason="Campaign work assignment revision changed; reload its event.",
|
||||
provenance={
|
||||
"code": "campaign_handoff_revision_conflict",
|
||||
"expected_revision": expected_revision,
|
||||
"current_revision": assignment.resource_revision,
|
||||
},
|
||||
)
|
||||
return CampaignWorkHandoffInspection(
|
||||
allowed=True,
|
||||
status=assignment.status, # type: ignore[arg-type]
|
||||
assignment_revision=assignment.resource_revision,
|
||||
action_url=_action_url(assignment),
|
||||
assignment_ref=_assignment_ref(assignment),
|
||||
provenance={
|
||||
"code": "campaign_handoff_access_rechecked",
|
||||
"campaign_id": assignment.campaign_id,
|
||||
"assignment_does_not_grant_access": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _context(
|
||||
session: object,
|
||||
principal: object,
|
||||
) -> tuple[Session, ApiPrincipal]:
|
||||
if not isinstance(session, Session):
|
||||
raise TypeError("Campaign work orchestration requires a SQLAlchemy Session.")
|
||||
if not isinstance(principal, ApiPrincipal):
|
||||
raise TypeError("Campaign work orchestration requires an API principal.")
|
||||
return session, principal
|
||||
|
||||
|
||||
def _request(request: ActionExecutionRequest) -> CampaignWorkHandoffRequest:
|
||||
value = request.input
|
||||
assignee = value.get("assignee")
|
||||
if not isinstance(assignee, Mapping):
|
||||
raise ValueError("Campaign work hand-offs require an assignee object.")
|
||||
create = value.get("create_campaign")
|
||||
if create is not None and not isinstance(create, Mapping):
|
||||
raise ValueError("Campaign creation input must be an object.")
|
||||
due_at = _date(value.get("due_at"))
|
||||
return CampaignWorkHandoffRequest(
|
||||
tenant_id=request.tenant_id,
|
||||
idempotency_key=request.idempotency_key,
|
||||
purpose=str(value.get("purpose") or ""),
|
||||
assignee_kind=str(assignee.get("kind") or ""), # type: ignore[arg-type]
|
||||
assignee_id=str(assignee.get("id") or ""),
|
||||
campaign_id=_optional(value.get("campaign_id")),
|
||||
create_external_id=_optional(create.get("external_id")) if create else None,
|
||||
create_name=_optional(create.get("name")) if create else None,
|
||||
create_description=(
|
||||
_optional(create.get("description")) if create else None
|
||||
),
|
||||
expected_campaign_revision=_integer(
|
||||
value.get("expected_campaign_revision")
|
||||
),
|
||||
due_at=due_at,
|
||||
mirror_to_tasks=bool(value.get("mirror_to_tasks", True)),
|
||||
correlation_id=request.invocation.correlation_id,
|
||||
workflow_instance_id=_reference_id(
|
||||
request.metadata.get("workflow_instance_ref"),
|
||||
"workflow-instance:",
|
||||
),
|
||||
workflow_step_id=_reference_id(
|
||||
request.metadata.get("workflow_step_ref"),
|
||||
"workflow-step:",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _preview_handoff(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
request: CampaignWorkHandoffRequest,
|
||||
) -> None:
|
||||
if principal.tenant_id != request.tenant_id:
|
||||
raise ValueError("Campaign hand-off tenant does not match the principal")
|
||||
for scope in (
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:campaign:create",
|
||||
"campaigns:assignment:manage",
|
||||
):
|
||||
if not has_scope(principal, scope):
|
||||
raise ValueError(f"Campaign work hand-off requires {scope}.")
|
||||
existing = (
|
||||
session.query(CampaignWorkAssignment)
|
||||
.filter(
|
||||
CampaignWorkAssignment.tenant_id == request.tenant_id,
|
||||
CampaignWorkAssignment.orchestration_idempotency_key
|
||||
== request.idempotency_key,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if existing is not None:
|
||||
if existing.orchestration_request_sha256 != _request_hash(request):
|
||||
raise ValueError(
|
||||
"Campaign hand-off idempotency key was already used for different input."
|
||||
)
|
||||
_get_campaign_for_principal(session, existing.campaign_id, principal)
|
||||
return
|
||||
if request.campaign_id is None:
|
||||
if request.assignee_kind != "account" or (
|
||||
request.assignee_id != principal.account_id
|
||||
):
|
||||
raise ValueError(
|
||||
"A newly created Campaign can initially be assigned only to its "
|
||||
"creating account; share it explicitly before assigning other principals."
|
||||
)
|
||||
duplicate = (
|
||||
session.query(Campaign.id)
|
||||
.filter(
|
||||
Campaign.tenant_id == request.tenant_id,
|
||||
Campaign.external_id == request.create_external_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if duplicate is not None:
|
||||
raise ValueError("Campaign external ID already exists for this tenant.")
|
||||
if request.expected_campaign_revision not in {None, 1}:
|
||||
raise ValueError("A new Campaign starts at revision one.")
|
||||
return
|
||||
campaign, _version, _created = _campaign_and_version(
|
||||
session,
|
||||
principal,
|
||||
request,
|
||||
create=False,
|
||||
)
|
||||
resolution = _resolve_assignee(
|
||||
session,
|
||||
campaign=campaign,
|
||||
assignee=CampaignWorkAssigneeInput(
|
||||
type=request.assignee_kind,
|
||||
id=request.assignee_id,
|
||||
),
|
||||
)
|
||||
_require_resolved_assignee(resolution)
|
||||
|
||||
|
||||
def _campaign_and_version(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
request: CampaignWorkHandoffRequest,
|
||||
*,
|
||||
create: bool,
|
||||
) -> tuple[Campaign, CampaignVersion, bool]:
|
||||
if request.campaign_id is None:
|
||||
if not create:
|
||||
raise ValueError("Campaign creation is not available during preview.")
|
||||
campaign, version = create_minimal_campaign(
|
||||
session,
|
||||
tenant_id=request.tenant_id,
|
||||
user_id=principal.user.id,
|
||||
external_id=str(request.create_external_id),
|
||||
name=str(request.create_name),
|
||||
description=request.create_description,
|
||||
current_flow="create",
|
||||
current_step="basics",
|
||||
commit=False,
|
||||
)
|
||||
return campaign, version, True
|
||||
campaign = _get_campaign_for_principal(
|
||||
session,
|
||||
request.campaign_id,
|
||||
principal,
|
||||
)
|
||||
version = session.get(CampaignVersion, campaign.current_version_id)
|
||||
if version is None or version.campaign_id != campaign.id:
|
||||
raise ValueError("The Campaign current version is unavailable.")
|
||||
if (
|
||||
request.expected_campaign_revision is not None
|
||||
and version.edit_revision != request.expected_campaign_revision
|
||||
):
|
||||
raise ValueError(
|
||||
"Campaign revision changed; reload the Campaign before opening work."
|
||||
)
|
||||
return campaign, version, False
|
||||
|
||||
|
||||
def _handoff_ref(
|
||||
session: Session,
|
||||
*,
|
||||
campaign: Campaign,
|
||||
assignment: CampaignWorkAssignment,
|
||||
registry: object | None,
|
||||
replayed: bool = False,
|
||||
) -> CampaignWorkHandoffRef:
|
||||
version = session.get(CampaignVersion, assignment.campaign_version_id)
|
||||
if version is None or version.campaign_id != campaign.id:
|
||||
raise ValueError("The pinned Campaign hand-off version is unavailable.")
|
||||
return CampaignWorkHandoffRef(
|
||||
tenant_id=assignment.tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
campaign_version_id=version.id,
|
||||
campaign_revision=version.edit_revision,
|
||||
assignment_id=assignment.id,
|
||||
assignment_revision=assignment.resource_revision,
|
||||
status=assignment.status, # type: ignore[arg-type]
|
||||
action_url=_action_url(assignment),
|
||||
campaign_ref=(
|
||||
f"campaign:{campaign.id}:version:{version.id}:r{version.edit_revision}"
|
||||
),
|
||||
assignment_ref=_assignment_ref(assignment),
|
||||
replayed=replayed,
|
||||
optional_capabilities={
|
||||
"tasks": _has_capability(registry, CAPABILITY_TASK_COMMANDS),
|
||||
"notifications": _has_capability(
|
||||
registry,
|
||||
CAPABILITY_NOTIFICATIONS_DISPATCH,
|
||||
),
|
||||
},
|
||||
provenance={
|
||||
"assignment_authorization_neutral": True,
|
||||
"campaign_access_checked": True,
|
||||
"workflow_instance_id": assignment.workflow_instance_id,
|
||||
"workflow_step_id": assignment.workflow_step_id,
|
||||
"correlation_id": assignment.orchestration_correlation_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _ref_payload(ref: CampaignWorkHandoffRef) -> dict[str, object]:
|
||||
return {
|
||||
"campaign_id": ref.campaign_id,
|
||||
"campaign_version_id": ref.campaign_version_id,
|
||||
"campaign_revision": ref.campaign_revision,
|
||||
"assignment_id": ref.assignment_id,
|
||||
"assignment_revision": ref.assignment_revision,
|
||||
"status": ref.status,
|
||||
"action_url": ref.action_url,
|
||||
"campaign_ref": ref.campaign_ref,
|
||||
"assignment_ref": ref.assignment_ref,
|
||||
"event_type": ref.event_type,
|
||||
"replayed": ref.replayed,
|
||||
"optional_capabilities": dict(ref.optional_capabilities),
|
||||
"provenance": dict(ref.provenance),
|
||||
"outcome": "success",
|
||||
}
|
||||
|
||||
|
||||
def _request_hash(request: CampaignWorkHandoffRequest) -> str:
|
||||
payload = {
|
||||
"tenant_id": request.tenant_id,
|
||||
"purpose": request.purpose.strip(),
|
||||
"assignee_kind": request.assignee_kind,
|
||||
"assignee_id": request.assignee_id.strip(),
|
||||
"campaign_id": request.campaign_id,
|
||||
"create_external_id": request.create_external_id,
|
||||
"create_name": request.create_name,
|
||||
"create_description": request.create_description,
|
||||
"expected_campaign_revision": request.expected_campaign_revision,
|
||||
"due_at": request.due_at.isoformat() if request.due_at else None,
|
||||
"mirror_to_tasks": request.mirror_to_tasks,
|
||||
"correlation_id": request.correlation_id,
|
||||
"workflow_instance_id": request.workflow_instance_id,
|
||||
"workflow_step_id": request.workflow_step_id,
|
||||
}
|
||||
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _blocked_preview(reason: str) -> ActionPreview:
|
||||
return ActionPreview(
|
||||
action_key=ACTION_KEY,
|
||||
allowed=False,
|
||||
summary=reason,
|
||||
risk_level="moderate",
|
||||
reversibility="compensatable",
|
||||
blockers=(reason,),
|
||||
policy_provenance=(
|
||||
{
|
||||
"code": "campaign_work_handoff_blocked",
|
||||
"reason": reason,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _message(exc: Exception) -> str:
|
||||
if isinstance(exc, HTTPException):
|
||||
detail = exc.detail
|
||||
if isinstance(detail, Mapping):
|
||||
return str(detail.get("explanation") or detail.get("code") or detail)
|
||||
return str(detail)
|
||||
return str(exc)
|
||||
|
||||
|
||||
def _date(value: object) -> datetime | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
try:
|
||||
return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise ValueError("Campaign hand-off due date must use ISO 8601.") from exc
|
||||
|
||||
|
||||
def _integer(value: object) -> int | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
raise ValueError("Campaign revisions must be integers.")
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("Campaign revisions must be integers.") from exc
|
||||
|
||||
|
||||
def _optional(value: object) -> str | None:
|
||||
candidate = str(value or "").strip()
|
||||
return candidate or None
|
||||
|
||||
|
||||
def _reference_id(value: object, prefix: str) -> str | None:
|
||||
candidate = str(value or "").strip()
|
||||
return candidate.removeprefix(prefix) or None if candidate.startswith(prefix) else None
|
||||
|
||||
|
||||
def _assignment_ref(assignment: CampaignWorkAssignment) -> str:
|
||||
return f"campaign-work-assignment:{assignment.id}:r{assignment.resource_revision}"
|
||||
|
||||
|
||||
def _action_url(assignment: CampaignWorkAssignment) -> str:
|
||||
return (
|
||||
f"/campaigns/{assignment.campaign_id}/work"
|
||||
f"?assignment={assignment.id}"
|
||||
)
|
||||
|
||||
|
||||
def _has_capability(registry: object | None, name: str) -> bool:
|
||||
return bool(
|
||||
registry is not None
|
||||
and hasattr(registry, "has_capability")
|
||||
and registry.has_capability(name)
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["ACTION_KEY", "SqlCampaignWorkOrchestrationProvider"]
|
||||
@@ -0,0 +1,205 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION
|
||||
from govoplan_core.core.workflows import WorkflowDefinitionContribution
|
||||
|
||||
|
||||
def campaign_workflow_definitions(
|
||||
*,
|
||||
module_version: str,
|
||||
) -> tuple[WorkflowDefinitionContribution, ...]:
|
||||
"""Return opt-in Campaign workflow templates owned by this module."""
|
||||
|
||||
return (
|
||||
WorkflowDefinitionContribution(
|
||||
origin_module_id="campaigns",
|
||||
origin_module_version=module_version,
|
||||
definition_key="accountable-campaign-work-handoff",
|
||||
name="Accountable Campaign work hand-off",
|
||||
description=(
|
||||
"Create or reference a Campaign, assign bounded work, and wait "
|
||||
"for its revision-bearing completion, rejection, cancellation, "
|
||||
"or timeout event."
|
||||
),
|
||||
graph=_campaign_work_handoff_graph(),
|
||||
definition_kind="template",
|
||||
scope_type="system",
|
||||
inherit_to_lower_scopes=True,
|
||||
allow_start=True,
|
||||
allow_reuse=True,
|
||||
allow_automation=False,
|
||||
execution_mode="guided",
|
||||
activate_on_install=False,
|
||||
required_capabilities=(CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION,),
|
||||
required_interfaces=("campaigns.work_orchestration",),
|
||||
metadata={
|
||||
"domain": "campaigns.accountable_work",
|
||||
"state_owner": "campaigns",
|
||||
"template_requires_configuration": True,
|
||||
},
|
||||
policy_metadata={
|
||||
"assignment_authorization_neutral": True,
|
||||
"campaign_access_rechecked_on_resume": True,
|
||||
"navigation_does_not_complete_work": True,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _campaign_work_handoff_graph() -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "start",
|
||||
"type": "workflow.start.manual",
|
||||
"label": "Campaign work requested",
|
||||
"position": {"x": 20, "y": 140},
|
||||
"config": {
|
||||
"input_schema_ref": "govoplan/campaigns/work-handoff.v1",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "prepare",
|
||||
"type": "workflow.capability",
|
||||
"label": "Prepare Campaign work",
|
||||
"position": {"x": 250, "y": 140},
|
||||
"config": {
|
||||
"capability": CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION,
|
||||
"operation": "campaigns.work.prepare",
|
||||
"input_mapping": {
|
||||
"campaign_id": "$input.campaign_id",
|
||||
"create_campaign": "$input.create_campaign",
|
||||
"expected_campaign_revision": (
|
||||
"$input.expected_campaign_revision"
|
||||
),
|
||||
"purpose": "$input.purpose",
|
||||
"assignee": "$input.assignee",
|
||||
"due_at": "$input.due_at",
|
||||
"mirror_to_tasks": "$input.mirror_to_tasks",
|
||||
},
|
||||
"idempotency_key": "workflow-step",
|
||||
"failure_policy": "manual",
|
||||
"view_surface_ids": ["campaigns.page.work"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "campaign_work",
|
||||
"type": "workflow.external_handoff",
|
||||
"label": "Complete Campaign work",
|
||||
"position": {"x": 510, "y": 140},
|
||||
"config": {
|
||||
"provider_capability": (
|
||||
CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION
|
||||
),
|
||||
"event_type": "campaign.work.changed",
|
||||
"event_filter": {
|
||||
"payload": {
|
||||
"assignment_id": (
|
||||
"$steps.prepare.execution.output.assignment_id"
|
||||
)
|
||||
}
|
||||
},
|
||||
"outcome_path": "payload.outcome",
|
||||
"terminal_outcomes": {
|
||||
"completed": "completed",
|
||||
"rejected": "rejected",
|
||||
"cancelled": "cancelled",
|
||||
},
|
||||
"observed_outcomes": [
|
||||
"assigned",
|
||||
"accepted",
|
||||
"started",
|
||||
"reassigned",
|
||||
],
|
||||
"external_id": (
|
||||
"$steps.prepare.execution.output.assignment_id"
|
||||
),
|
||||
"expected_revision": (
|
||||
"$steps.prepare.execution.output.assignment_revision"
|
||||
),
|
||||
"action_url": "$steps.prepare.execution.output.action_url",
|
||||
"immutable_ref": (
|
||||
"$steps.prepare.execution.output.assignment_ref"
|
||||
),
|
||||
"optional_capabilities": (
|
||||
"$steps.prepare.execution.output.optional_capabilities"
|
||||
),
|
||||
"timeout_after": "$input.timeout_after",
|
||||
"view_surface_ids": ["campaigns.page.work"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "completed",
|
||||
"type": "workflow.end.completed",
|
||||
"label": "Campaign work completed",
|
||||
"position": {"x": 790, "y": 20},
|
||||
"config": {"output_mapping": {}},
|
||||
},
|
||||
{
|
||||
"id": "rejected",
|
||||
"type": "workflow.end.cancelled",
|
||||
"label": "Campaign work rejected",
|
||||
"position": {"x": 790, "y": 120},
|
||||
"config": {"reason": "Campaign work was rejected"},
|
||||
},
|
||||
{
|
||||
"id": "cancelled",
|
||||
"type": "workflow.end.cancelled",
|
||||
"label": "Campaign work cancelled",
|
||||
"position": {"x": 790, "y": 220},
|
||||
"config": {"reason": "Campaign work was cancelled"},
|
||||
},
|
||||
{
|
||||
"id": "timed_out",
|
||||
"type": "workflow.end.cancelled",
|
||||
"label": "Campaign work timed out",
|
||||
"position": {"x": 790, "y": 320},
|
||||
"config": {"reason": "Campaign work timed out"},
|
||||
},
|
||||
],
|
||||
"edges": [
|
||||
{"id": "start-prepare", "source": "start", "target": "prepare"},
|
||||
{
|
||||
"id": "prepare-work",
|
||||
"source": "prepare",
|
||||
"source_port": "success",
|
||||
"target": "campaign_work",
|
||||
},
|
||||
{
|
||||
"id": "work-completed",
|
||||
"source": "campaign_work",
|
||||
"source_port": "completed",
|
||||
"target": "completed",
|
||||
},
|
||||
{
|
||||
"id": "work-rejected",
|
||||
"source": "campaign_work",
|
||||
"source_port": "rejected",
|
||||
"target": "rejected",
|
||||
},
|
||||
{
|
||||
"id": "work-cancelled",
|
||||
"source": "campaign_work",
|
||||
"source_port": "cancelled",
|
||||
"target": "cancelled",
|
||||
},
|
||||
{
|
||||
"id": "work-timeout",
|
||||
"source": "campaign_work",
|
||||
"source_port": "timed_out",
|
||||
"target": "timed_out",
|
||||
},
|
||||
],
|
||||
"metadata": {
|
||||
"notation": "govoplan.workflow.native",
|
||||
"domain": "campaigns.accountable_work",
|
||||
"configuration_notes": (
|
||||
"Provide either campaign_id or create_campaign and explicit null "
|
||||
"values for unused optional inputs."
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["campaign_workflow_definitions"]
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import json
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -10,17 +12,33 @@ from govoplan_access.backend.db.models import Account, Group, User
|
||||
from govoplan_campaign.backend.capabilities import (
|
||||
CampaignAccessService,
|
||||
CampaignOwnershipService,
|
||||
campaign_import_execution_resource_id,
|
||||
campaign_protocol_artifact_resource_id,
|
||||
campaign_report_resource_id,
|
||||
campaign_version_child_resource_id,
|
||||
)
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
AttachmentBlob,
|
||||
AttachmentInstance,
|
||||
Campaign,
|
||||
CampaignCollaborationEntry,
|
||||
CampaignIssue,
|
||||
CampaignJob,
|
||||
CampaignMessageAction,
|
||||
CampaignMessageActionAttempt,
|
||||
CampaignShare,
|
||||
CampaignVersion,
|
||||
CampaignWorkAssignment,
|
||||
CampaignWorkAssignmentEvent,
|
||||
ImapAppendAttempt,
|
||||
PostboxDeliveryAttempt,
|
||||
PrintOutputAttempt,
|
||||
RecipientImportMappingProfile,
|
||||
SendAttempt,
|
||||
)
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.core.ownership import OwnershipSubjectRef, OwnershipTransferError
|
||||
from govoplan_core.core.ownership import OwnershipSubjectRef, OwnershipTransfer, OwnershipTransferError
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
@@ -31,6 +49,136 @@ GROUP_ID = "group-1"
|
||||
|
||||
|
||||
class CampaignAccessProviderTests(unittest.TestCase):
|
||||
def test_work_assignment_provenance_keeps_accountability_separate_from_access(self) -> None:
|
||||
session = _session()
|
||||
self.addCleanup(_close_session, session)
|
||||
_seed_access_subjects(session)
|
||||
campaign = Campaign(
|
||||
id="campaign-work",
|
||||
tenant_id=TENANT_ID,
|
||||
owner_user_id=OTHER_USER_ID,
|
||||
external_id="work",
|
||||
name="Work campaign",
|
||||
)
|
||||
checked_at = datetime.now(UTC)
|
||||
assignment = CampaignWorkAssignment(
|
||||
id="assignment-1",
|
||||
tenant_id=TENANT_ID,
|
||||
campaign_id=campaign.id,
|
||||
purpose="Not disclosed by provenance",
|
||||
status="open",
|
||||
assignee_type="account",
|
||||
assignee_id="account-1",
|
||||
assignee_label_snapshot="Subject",
|
||||
assignee_resolution_state="resolved",
|
||||
resolution_provenance={"policy_code": "assignment_does_not_grant_access"},
|
||||
resolution_checked_at=checked_at,
|
||||
assigned_by_user_id=OTHER_USER_ID,
|
||||
assigned_by_label_snapshot="Other user",
|
||||
)
|
||||
event = CampaignWorkAssignmentEvent(
|
||||
id="assignment-event-1",
|
||||
tenant_id=TENANT_ID,
|
||||
campaign_id=campaign.id,
|
||||
assignment_id=assignment.id,
|
||||
event_kind="assigned",
|
||||
actor_user_id=OTHER_USER_ID,
|
||||
actor_label_snapshot="Other user",
|
||||
status_snapshot="open",
|
||||
assignee_type_snapshot="account",
|
||||
assignee_id_snapshot="account-1",
|
||||
assignee_label_snapshot="Subject",
|
||||
resolution_state_snapshot="resolved",
|
||||
)
|
||||
session.add_all(
|
||||
[
|
||||
campaign,
|
||||
assignment,
|
||||
event,
|
||||
CampaignShare(
|
||||
id="share-work",
|
||||
tenant_id=TENANT_ID,
|
||||
campaign_id=campaign.id,
|
||||
target_type="group",
|
||||
target_id=GROUP_ID,
|
||||
permission="read",
|
||||
),
|
||||
]
|
||||
)
|
||||
session.commit()
|
||||
|
||||
items = CampaignAccessService().explain_resource_provenance(
|
||||
session,
|
||||
_principal(
|
||||
scopes={"campaigns:campaign:read", "campaigns:assignment:read"},
|
||||
group_ids={GROUP_ID},
|
||||
),
|
||||
resource_type="campaign_work_assignment",
|
||||
resource_id=assignment.id,
|
||||
action="campaigns:assignment:read",
|
||||
)
|
||||
work = next(item for item in items if item.source == "campaigns.work_assignment")
|
||||
self.assertEqual("accountability_does_not_grant_access", work.details["authorization_mode"])
|
||||
self.assertEqual(["campaigns:assignment:read"], work.details["permission_actions"])
|
||||
self.assertFalse(work.details["purpose_disclosed"])
|
||||
self.assertNotIn("Not disclosed", repr(items))
|
||||
self.assertTrue(any(item.id == "share-work" for item in items))
|
||||
|
||||
def test_collaboration_access_is_explained_independently_from_campaign_edit(self) -> None:
|
||||
session = _session()
|
||||
self.addCleanup(_close_session, session)
|
||||
_seed_access_subjects(session)
|
||||
campaign = Campaign(
|
||||
id="campaign-collaboration",
|
||||
tenant_id=TENANT_ID,
|
||||
owner_user_id=OTHER_USER_ID,
|
||||
external_id="collaboration",
|
||||
name="Collaboration campaign",
|
||||
)
|
||||
entry = CampaignCollaborationEntry(
|
||||
id="discussion-1",
|
||||
tenant_id=TENANT_ID,
|
||||
campaign_id=campaign.id,
|
||||
actor_user_id=OTHER_USER_ID,
|
||||
actor_label_snapshot="Other user",
|
||||
visibility="collaborators",
|
||||
content="Not disclosed by provenance",
|
||||
content_sha256="a" * 64,
|
||||
)
|
||||
session.add_all(
|
||||
[
|
||||
campaign,
|
||||
entry,
|
||||
CampaignShare(
|
||||
id="share-collaboration",
|
||||
tenant_id=TENANT_ID,
|
||||
campaign_id=campaign.id,
|
||||
target_type="group",
|
||||
target_id=GROUP_ID,
|
||||
permission="read",
|
||||
),
|
||||
]
|
||||
)
|
||||
session.commit()
|
||||
|
||||
items = CampaignAccessService().explain_resource_provenance(
|
||||
session,
|
||||
_principal(
|
||||
scopes={"campaigns:campaign:read", "campaigns:discussion:post"},
|
||||
group_ids={GROUP_ID},
|
||||
),
|
||||
resource_type="campaign_collaboration_entry",
|
||||
resource_id=entry.id,
|
||||
action="campaigns:discussion:post",
|
||||
)
|
||||
|
||||
discussion = next(item for item in items if item.source == "campaigns.collaboration_entry")
|
||||
self.assertEqual(["campaigns:discussion:post"], discussion.details["permission_actions"])
|
||||
self.assertFalse(discussion.details["content_disclosed"])
|
||||
self.assertTrue(any(item.id == "share-collaboration" for item in items))
|
||||
self.assertNotIn("campaigns:campaign:update", repr(discussion.details))
|
||||
self.assertNotIn("Not disclosed", repr(items))
|
||||
|
||||
def test_campaign_access_provider_explains_owner_share_admin_and_missing_resources(self) -> None:
|
||||
session = _session()
|
||||
self.addCleanup(_close_session, session)
|
||||
@@ -181,6 +329,513 @@ class CampaignAccessProviderTests(unittest.TestCase):
|
||||
self.assertEqual(report.details["report_kind"], "delivery")
|
||||
self.assertFalse(report.details["persisted"])
|
||||
|
||||
def test_recipient_attachment_and_review_children_are_bounded_and_versioned(self) -> None:
|
||||
session = _session()
|
||||
self.addCleanup(_close_session, session)
|
||||
_seed_access_subjects(session)
|
||||
campaign = Campaign(
|
||||
id="campaign-sensitive",
|
||||
tenant_id=TENANT_ID,
|
||||
owner_user_id=OTHER_USER_ID,
|
||||
external_id="sensitive",
|
||||
name="Sensitive child evidence",
|
||||
)
|
||||
version = CampaignVersion(
|
||||
id="version-sensitive",
|
||||
campaign_id=campaign.id,
|
||||
version_number=4,
|
||||
raw_json={},
|
||||
execution_snapshot={"secret_source_rows": ["do-not-disclose"]},
|
||||
execution_snapshot_hash="a" * 64,
|
||||
)
|
||||
job = CampaignJob(
|
||||
id="job-sensitive",
|
||||
tenant_id=TENANT_ID,
|
||||
campaign_id=campaign.id,
|
||||
campaign_version_id=version.id,
|
||||
entry_index=7,
|
||||
recipient_email="hidden-recipient@example.test",
|
||||
execution_input_sha256="b" * 64,
|
||||
resolved_attachments=[{"filename": "hidden-file.pdf", "storage_key": "secret/key"}],
|
||||
issues_snapshot=[{
|
||||
"code": "attachment_override_required",
|
||||
"source": "attachments.policy",
|
||||
"behavior": "ask",
|
||||
"message": "hidden validation detail",
|
||||
}],
|
||||
)
|
||||
version.editor_state = {
|
||||
"review_send": {
|
||||
"issue_decisions": [{
|
||||
"job_id": job.id,
|
||||
"review_key": "opaque-review-key",
|
||||
"decision": "accept",
|
||||
"reason": "hidden operator reason",
|
||||
"decided_at": "2026-08-19T10:00:00+00:00",
|
||||
"issue_fingerprint": "c" * 64,
|
||||
}]
|
||||
}
|
||||
}
|
||||
issue = CampaignIssue(
|
||||
id="issue-sensitive",
|
||||
tenant_id=TENANT_ID,
|
||||
campaign_id=campaign.id,
|
||||
campaign_version_id=version.id,
|
||||
job_id=job.id,
|
||||
severity="warning",
|
||||
code="attachment_override_required",
|
||||
message="hidden validation detail",
|
||||
source="hidden/source/path",
|
||||
behavior="ask",
|
||||
)
|
||||
blob = AttachmentBlob(
|
||||
id="blob-sensitive",
|
||||
tenant_id=TENANT_ID,
|
||||
sha256="d" * 64,
|
||||
size_bytes=123,
|
||||
storage_bucket="secret-bucket",
|
||||
storage_key="secret/object",
|
||||
)
|
||||
attachment = AttachmentInstance(
|
||||
id="attachment-sensitive",
|
||||
tenant_id=TENANT_ID,
|
||||
owner_user_id=OTHER_USER_ID,
|
||||
campaign_id=campaign.id,
|
||||
blob_id=blob.id,
|
||||
filename="hidden-file.pdf",
|
||||
)
|
||||
session.add_all([
|
||||
campaign,
|
||||
version,
|
||||
job,
|
||||
issue,
|
||||
blob,
|
||||
attachment,
|
||||
CampaignShare(
|
||||
id="share-sensitive",
|
||||
tenant_id=TENANT_ID,
|
||||
campaign_id=campaign.id,
|
||||
target_type="group",
|
||||
target_id=GROUP_ID,
|
||||
permission="read",
|
||||
),
|
||||
])
|
||||
session.commit()
|
||||
|
||||
service = CampaignAccessService()
|
||||
principal = _principal(
|
||||
scopes={
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:recipient:read",
|
||||
"campaigns:campaign:review",
|
||||
"campaigns:diagnostic:read",
|
||||
},
|
||||
group_ids={GROUP_ID},
|
||||
)
|
||||
version_child_id = campaign_version_child_resource_id(
|
||||
version_id=version.id,
|
||||
child_id=job.id,
|
||||
)
|
||||
cases = (
|
||||
("campaign_recipient_source_snapshot", version.id, "campaigns.recipient_source_snapshot"),
|
||||
("campaign_recipient", version_child_id, "campaigns.recipient"),
|
||||
("campaign_attachment_binding", attachment.id, "campaigns.attachment_binding"),
|
||||
("campaign_attachment_resolution", version_child_id, "campaigns.attachment_resolution"),
|
||||
("campaign_validation_issue", issue.id, "campaigns.validation_issue"),
|
||||
("campaign_review_decision", version_child_id, "campaigns.review_decision"),
|
||||
("campaign_attachment_override", version_child_id, "campaigns.attachment_override"),
|
||||
)
|
||||
for resource_type, resource_id, source in cases:
|
||||
items = service.explain_resource_provenance(
|
||||
session,
|
||||
principal,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
action="campaigns:diagnostic:read",
|
||||
)
|
||||
child = next(item for item in items if item.source == source)
|
||||
self.assertEqual(campaign.id, child.details["campaign_id"])
|
||||
self.assertEqual(
|
||||
{"resource_type": "campaign", "resource_id": campaign.id},
|
||||
child.details["authorization_inherited_from"],
|
||||
)
|
||||
self.assertEqual("inherited_and_further_restricted", child.details["authorization_mode"])
|
||||
self.assertTrue(any(item.id == "share-sensitive" for item in items))
|
||||
serialized = json.dumps(child.details, sort_keys=True)
|
||||
for hidden in (
|
||||
"hidden-recipient@example.test",
|
||||
"hidden-file.pdf",
|
||||
"secret/object",
|
||||
"secret/key",
|
||||
"hidden validation detail",
|
||||
"hidden operator reason",
|
||||
"hidden/source/path",
|
||||
"do-not-disclose",
|
||||
):
|
||||
self.assertNotIn(hidden, serialized)
|
||||
|
||||
denied_items = service.explain_resource_provenance(
|
||||
session,
|
||||
_principal(scopes=set()),
|
||||
resource_type="campaign_recipient",
|
||||
resource_id=version_child_id,
|
||||
action="campaigns:recipient:read",
|
||||
)
|
||||
denied_child = next(item for item in denied_items if item.source == "campaigns.recipient")
|
||||
self.assertIn("campaigns:recipient:read", denied_child.details["permission_actions"])
|
||||
self.assertFalse(any(item.kind in {"owner", "share", "policy"} for item in denied_items))
|
||||
|
||||
missing = service.explain_resource_provenance(
|
||||
session,
|
||||
principal,
|
||||
resource_type="campaign_recipient",
|
||||
resource_id=f"{version.id}:missing-job",
|
||||
action="campaigns:recipient:read",
|
||||
)
|
||||
self.assertEqual("campaigns.not_found", missing[0].source)
|
||||
self.assertNotIn("hidden-recipient", json.dumps(missing[0].details))
|
||||
|
||||
stale = service.explain_resource_provenance(
|
||||
session,
|
||||
principal,
|
||||
resource_type="campaign_review_decision",
|
||||
resource_id=f"stale-version:{job.id}",
|
||||
action="campaigns:diagnostic:read",
|
||||
)
|
||||
self.assertEqual("stale_version_reference", stale[0].details["reason"])
|
||||
|
||||
hidden = service.explain_resource_provenance(
|
||||
session,
|
||||
_principal(tenant_id="tenant-2"),
|
||||
resource_type="campaign_validation_issue",
|
||||
resource_id=issue.id,
|
||||
action="campaigns:diagnostic:read",
|
||||
)
|
||||
self.assertEqual("campaigns.not_found", hidden[0].source)
|
||||
self.assertNotIn(issue.code, json.dumps(hidden[0].details))
|
||||
|
||||
def test_delivery_evidence_explanations_separate_permissions_and_hide_transport_data(self) -> None:
|
||||
session = _session()
|
||||
self.addCleanup(_close_session, session)
|
||||
_seed_access_subjects(session)
|
||||
campaign = Campaign(
|
||||
id="campaign-delivery-evidence",
|
||||
tenant_id=TENANT_ID,
|
||||
owner_user_id=USER_ID,
|
||||
external_id="delivery-evidence",
|
||||
name="Delivery evidence",
|
||||
)
|
||||
version = CampaignVersion(
|
||||
id="version-delivery-evidence",
|
||||
campaign_id=campaign.id,
|
||||
version_number=1,
|
||||
raw_json={},
|
||||
)
|
||||
job = CampaignJob(
|
||||
id="job-delivery-evidence",
|
||||
tenant_id=TENANT_ID,
|
||||
campaign_id=campaign.id,
|
||||
campaign_version_id=version.id,
|
||||
entry_index=1,
|
||||
recipient_email="hidden-delivery-recipient@example.test",
|
||||
send_status="sent",
|
||||
postbox_status="accepted",
|
||||
imap_status="appended",
|
||||
last_error="hidden reconciliation note",
|
||||
)
|
||||
send_attempt = SendAttempt(
|
||||
id="send-attempt-evidence",
|
||||
job_id=job.id,
|
||||
attempt_number=1,
|
||||
status="reconciled_not_sent",
|
||||
claim_token="hidden-claim-token",
|
||||
smtp_response="hidden-smtp-response",
|
||||
)
|
||||
imap_attempt = ImapAppendAttempt(
|
||||
id="imap-attempt-evidence",
|
||||
job_id=job.id,
|
||||
attempt_number=1,
|
||||
status="reconciled_imap_appended",
|
||||
folder="Hidden/Sent",
|
||||
error_message="hidden imap note",
|
||||
)
|
||||
postbox_attempt = PostboxDeliveryAttempt(
|
||||
id="postbox-attempt-evidence",
|
||||
tenant_id=TENANT_ID,
|
||||
job_id=job.id,
|
||||
target_key="opaque-target",
|
||||
target_index=0,
|
||||
attempt_number=1,
|
||||
idempotency_key="opaque-postbox-key",
|
||||
status="accepted",
|
||||
target_snapshot={"address": "hidden postbox address"},
|
||||
evidence={
|
||||
"operator_reconciliation": {
|
||||
"decision": "accepted",
|
||||
"note": "hidden postbox note",
|
||||
}
|
||||
},
|
||||
)
|
||||
print_attempt = PrintOutputAttempt(
|
||||
id="print-attempt-evidence",
|
||||
tenant_id=TENANT_ID,
|
||||
job_id=job.id,
|
||||
attempt_number=1,
|
||||
idempotency_key="opaque-print-key",
|
||||
status="accepted",
|
||||
render_id="hidden-render-id",
|
||||
evidence={"storage_key": "hidden print locator"},
|
||||
)
|
||||
message_action = CampaignMessageAction(
|
||||
id="message-action-evidence",
|
||||
tenant_id=TENANT_ID,
|
||||
campaign_id=campaign.id,
|
||||
campaign_version_id=version.id,
|
||||
job_id=job.id,
|
||||
kind="retry",
|
||||
idempotency_key="opaque-action-key",
|
||||
canonical_request_hash="e" * 64,
|
||||
reason="hidden action reason",
|
||||
message_sha256="f" * 64,
|
||||
recipient_manifest_sha256="1" * 64,
|
||||
recipient_count=1,
|
||||
prior_send_status="failed",
|
||||
prior_attempt_count=1,
|
||||
status="completed",
|
||||
)
|
||||
action_attempt = CampaignMessageActionAttempt(
|
||||
id="message-action-attempt-evidence",
|
||||
action_id=message_action.id,
|
||||
attempt_number=1,
|
||||
status="completed",
|
||||
started_at=datetime.now(UTC),
|
||||
diagnostic_summary="hidden diagnostic summary",
|
||||
)
|
||||
session.add_all([
|
||||
campaign,
|
||||
version,
|
||||
job,
|
||||
send_attempt,
|
||||
imap_attempt,
|
||||
postbox_attempt,
|
||||
print_attempt,
|
||||
message_action,
|
||||
action_attempt,
|
||||
])
|
||||
session.commit()
|
||||
|
||||
service = CampaignAccessService()
|
||||
principal = _principal(scopes={
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:report:read",
|
||||
"campaigns:report:export",
|
||||
"campaigns:diagnostic:read",
|
||||
"campaigns:campaign:reconcile",
|
||||
})
|
||||
cases = (
|
||||
("campaign_send_attempt", send_attempt.id, "campaigns.send_attempt"),
|
||||
("campaign_imap_append_attempt", imap_attempt.id, "campaigns.imap_append_attempt"),
|
||||
("campaign_postbox_attempt", postbox_attempt.id, "campaigns.postbox_attempt"),
|
||||
("campaign_print_attempt", print_attempt.id, "campaigns.print_attempt"),
|
||||
("campaign_message_action", message_action.id, "campaigns.message_action"),
|
||||
("campaign_message_action_attempt", action_attempt.id, "campaigns.message_action_attempt"),
|
||||
)
|
||||
for resource_type, resource_id, source in cases:
|
||||
items = service.explain_resource_provenance(
|
||||
session,
|
||||
principal,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
action="campaigns:diagnostic:read",
|
||||
)
|
||||
child = next(item for item in items if item.source == source)
|
||||
self.assertEqual(campaign.id, child.details["campaign_id"])
|
||||
self.assertEqual(version.id, child.details["campaign_version_id"])
|
||||
self.assertEqual(job.id, child.details["job_id"])
|
||||
self.assertEqual(
|
||||
{
|
||||
"read": ["campaigns:campaign:read"],
|
||||
"report": ["campaigns:report:read"],
|
||||
"diagnostic": ["campaigns:diagnostic:read"],
|
||||
"export": ["campaigns:report:export"],
|
||||
},
|
||||
child.details["permission_classes"],
|
||||
)
|
||||
serialized = json.dumps(child.details, sort_keys=True)
|
||||
for hidden_value in (
|
||||
"hidden-delivery-recipient@example.test",
|
||||
"hidden-claim-token",
|
||||
"hidden-smtp-response",
|
||||
"Hidden/Sent",
|
||||
"hidden imap note",
|
||||
"hidden postbox address",
|
||||
"hidden postbox note",
|
||||
"hidden-render-id",
|
||||
"hidden print locator",
|
||||
"hidden action reason",
|
||||
"hidden diagnostic summary",
|
||||
"hidden reconciliation note",
|
||||
):
|
||||
self.assertNotIn(hidden_value, serialized)
|
||||
|
||||
reconciliation = service.explain_resource_provenance(
|
||||
session,
|
||||
principal,
|
||||
resource_type="campaign_reconciliation_decision",
|
||||
resource_id=job.id,
|
||||
action="campaigns:campaign:reconcile",
|
||||
)
|
||||
decision = next(item for item in reconciliation if item.source == "campaigns.reconciliation_decision")
|
||||
self.assertEqual(["smtp", "imap", "postbox"], decision.details["channels"])
|
||||
self.assertTrue(decision.details["evidence_note_recorded"])
|
||||
self.assertFalse(decision.details["evidence_note_disclosed"])
|
||||
|
||||
unavailable = service.explain_resource_provenance(
|
||||
session,
|
||||
principal,
|
||||
resource_type="campaign_postbox_attempt",
|
||||
resource_id="missing-postbox-attempt",
|
||||
action="campaigns:diagnostic:read",
|
||||
)
|
||||
self.assertEqual("postbox", unavailable[0].details["optional_module"])
|
||||
self.assertEqual("unavailable_or_hidden", unavailable[0].details["evidence_availability"])
|
||||
self.assertNotIn("hidden postbox address", json.dumps(unavailable[0].details))
|
||||
|
||||
def test_governance_import_and_protocol_explanation_matrix(self) -> None:
|
||||
session = _session()
|
||||
self.addCleanup(_close_session, session)
|
||||
_seed_access_subjects(session)
|
||||
now = datetime.now(UTC)
|
||||
campaign = Campaign(
|
||||
id="campaign-governed-children", tenant_id=TENANT_ID,
|
||||
owner_user_id=USER_ID, external_id="governed-children", name="Governed children",
|
||||
)
|
||||
share = CampaignShare(
|
||||
id="share-governed-child", tenant_id=TENANT_ID, campaign_id=campaign.id,
|
||||
target_type="user", target_id=OTHER_USER_ID, permission="read",
|
||||
)
|
||||
version = CampaignVersion(
|
||||
id="version-governed-children", campaign_id=campaign.id, version_number=2,
|
||||
raw_json={"entries": {"inline": [], "imports": [{
|
||||
"id": "import-stable-id", "imported_at": "2026-08-20T08:00:00+00:00",
|
||||
"mode": "replace", "source_type": "addresses", "source_id": "address-source-42",
|
||||
"source_revision": "revision-7",
|
||||
"source_provenance": {"secret_source_row": "do-not-disclose-row"},
|
||||
"filename": "hidden-import.csv",
|
||||
"mapping": [{"header": "Secret column", "field_name": "secret"}],
|
||||
}]}},
|
||||
validation_summary={"secret_issue": "do-not-disclose-validation"},
|
||||
build_summary={"secret_recipient": "do-not-disclose-build"},
|
||||
execution_snapshot={"rows": ["do-not-disclose-snapshot"]},
|
||||
execution_snapshot_hash="a" * 64, execution_snapshot_at=now,
|
||||
editor_state={"review_send": {"issue_decisions": [
|
||||
{"reason": "do-not-disclose-review-reason"}
|
||||
]}},
|
||||
)
|
||||
profile = RecipientImportMappingProfile(
|
||||
id="mapping-profile-governed-child", tenant_id=TENANT_ID, owner_user_id=USER_ID,
|
||||
name="Hidden profile name", column_count=2,
|
||||
headers=["Secret A", "Secret B"], normalized_headers=["secret a", "secret b"],
|
||||
ordered_header_fingerprint="b" * 64, unordered_header_fingerprint="c" * 64,
|
||||
delimiter=";", header_rows=1, quoted=True, value_separators=",;|",
|
||||
mappings=[{"header": "Secret A", "field": "hidden"}],
|
||||
)
|
||||
transfer = OwnershipTransfer(
|
||||
id="transfer-governed-child", tenant_id=TENANT_ID,
|
||||
resource_module="campaigns", resource_type="campaign", resource_id=campaign.id,
|
||||
kind="transfer", status="pending_target",
|
||||
current_owner_type="user", current_owner_id=USER_ID,
|
||||
target_owner_type="user", target_owner_id=OTHER_USER_ID,
|
||||
initiated_by_type="user", initiated_by_id=USER_ID,
|
||||
reason="do-not-disclose-transfer-reason", required_approvals=1,
|
||||
approvals=[], decisions=[], idempotency_key="transfer-idempotency",
|
||||
canonical_request_hash="d" * 64, expires_at=now + timedelta(days=1),
|
||||
revision=3, metadata_={"secret": "do-not-disclose-transfer-metadata"},
|
||||
)
|
||||
session.add_all([campaign, share, version, profile, transfer])
|
||||
session.commit()
|
||||
|
||||
service = CampaignAccessService()
|
||||
principal = _principal(scopes={
|
||||
"campaigns:campaign:read", "campaigns:campaign:share",
|
||||
"campaigns:recipient:read", "campaigns:recipient:import",
|
||||
"campaigns:campaign:review", "campaigns:diagnostic:read",
|
||||
"campaigns:report:read",
|
||||
})
|
||||
import_id = campaign_import_execution_resource_id(
|
||||
version_id=version.id, import_id="import-stable-id",
|
||||
)
|
||||
protocol_ids = [
|
||||
campaign_protocol_artifact_resource_id(version_id=version.id, artifact_kind=kind)
|
||||
for kind in ("validation", "build", "execution_snapshot", "review")
|
||||
]
|
||||
cases = [
|
||||
("campaign_share", share.id, "campaigns.share_record"),
|
||||
("campaign_ownership_transfer", transfer.id, "campaigns.ownership_transfer"),
|
||||
("campaign_import_execution", import_id, "campaigns.import_execution"),
|
||||
*(("campaign_protocol_artifact", item, "campaigns.protocol_artifact") for item in protocol_ids),
|
||||
]
|
||||
hidden_values = (
|
||||
OTHER_USER_ID, "do-not-disclose-row", "hidden-import.csv", "Secret column",
|
||||
"do-not-disclose-validation", "do-not-disclose-build", "do-not-disclose-snapshot",
|
||||
"do-not-disclose-review-reason", "do-not-disclose-transfer-reason",
|
||||
"do-not-disclose-transfer-metadata",
|
||||
)
|
||||
for resource_type, resource_id, source in cases:
|
||||
items = service.explain_resource_provenance(
|
||||
session, principal, resource_type=resource_type, resource_id=resource_id,
|
||||
action="campaigns:diagnostic:read",
|
||||
)
|
||||
child = next(item for item in items if item.source == source)
|
||||
self.assertEqual(campaign.id, child.details["campaign_id"])
|
||||
serialized = json.dumps(child.details, sort_keys=True)
|
||||
for hidden_value in hidden_values:
|
||||
self.assertNotIn(hidden_value, serialized)
|
||||
|
||||
import_item = next(item for item in service.explain_resource_provenance(
|
||||
session, principal, resource_type="campaign_import_execution",
|
||||
resource_id=import_id, action="campaigns:recipient:import",
|
||||
) if item.source == "campaigns.import_execution")
|
||||
self.assertEqual("address-source-42", import_item.details["source_id"])
|
||||
self.assertEqual("revision-7", import_item.details["source_revision"])
|
||||
self.assertFalse(import_item.details["source_rows_disclosed"])
|
||||
|
||||
profile_items = service.explain_resource_provenance(
|
||||
session, principal, resource_type="campaign_import_mapping_profile",
|
||||
resource_id=profile.id, action="campaigns:recipient:import",
|
||||
)
|
||||
profile_item = next(item for item in profile_items if item.source == "campaigns.import_mapping_profile")
|
||||
self.assertEqual("independently_owned", profile_item.details["authorization_mode"])
|
||||
self.assertTrue(any(item.kind == "owner" for item in profile_items))
|
||||
self.assertNotIn("Secret A", json.dumps(profile_item.details))
|
||||
self.assertNotIn("Hidden profile name", json.dumps(profile_item.to_dict()))
|
||||
|
||||
for resource_type, optional_module in (
|
||||
("campaign_template", "templates"),
|
||||
("campaign_template_revision", "templates"),
|
||||
("campaign_export_package", "reporting"),
|
||||
):
|
||||
unavailable = service.explain_resource_provenance(
|
||||
session, principal, resource_type=resource_type,
|
||||
resource_id="opaque-optional-resource", action="campaigns:diagnostic:read",
|
||||
)[0]
|
||||
self.assertEqual("campaigns.not_found", unavailable.source)
|
||||
self.assertEqual(optional_module, unavailable.details["optional_module"])
|
||||
self.assertEqual("independently_governed_by_optional_module", unavailable.details["reason"])
|
||||
self.assertEqual("unavailable_or_hidden", unavailable.details["evidence_availability"])
|
||||
|
||||
unsupported = service.explain_resource_provenance(
|
||||
session, principal, resource_type="campaign_protocol_artifact",
|
||||
resource_id=f"{version.id}:unsupported", action="campaigns:diagnostic:read",
|
||||
)
|
||||
self.assertEqual("unsupported_artifact_kind", unsupported[0].details["reason"])
|
||||
cross_tenant = service.explain_resource_provenance(
|
||||
session, _principal(tenant_id="tenant-2"),
|
||||
resource_type="campaign_import_mapping_profile", resource_id=profile.id,
|
||||
action="campaigns:recipient:import",
|
||||
)
|
||||
self.assertEqual("campaigns.not_found", cross_tenant[0].source)
|
||||
|
||||
def test_campaign_ownership_provider_requires_group_acceptance_authority(self) -> None:
|
||||
session = _session()
|
||||
self.addCleanup(_close_session, session)
|
||||
@@ -396,8 +1051,22 @@ def _session():
|
||||
Group.__table__,
|
||||
Campaign.__table__,
|
||||
CampaignShare.__table__,
|
||||
CampaignCollaborationEntry.__table__,
|
||||
CampaignWorkAssignment.__table__,
|
||||
CampaignWorkAssignmentEvent.__table__,
|
||||
CampaignVersion.__table__,
|
||||
CampaignJob.__table__,
|
||||
CampaignIssue.__table__,
|
||||
AttachmentBlob.__table__,
|
||||
AttachmentInstance.__table__,
|
||||
SendAttempt.__table__,
|
||||
CampaignMessageAction.__table__,
|
||||
CampaignMessageActionAttempt.__table__,
|
||||
ImapAppendAttempt.__table__,
|
||||
PostboxDeliveryAttempt.__table__,
|
||||
PrintOutputAttempt.__table__,
|
||||
RecipientImportMappingProfile.__table__,
|
||||
OwnershipTransfer.__table__,
|
||||
ChangeSequenceEntry.__table__,
|
||||
],
|
||||
)
|
||||
@@ -421,11 +1090,18 @@ def _seed_access_subjects(session) -> None:
|
||||
session.commit()
|
||||
|
||||
|
||||
def _principal(*, scopes: set[str] | None = None, group_ids: set[str] | None = None) -> PrincipalRef:
|
||||
def _principal(
|
||||
*,
|
||||
scopes: set[str] | None = None,
|
||||
group_ids: set[str] | None = None,
|
||||
tenant_id: str = TENANT_ID,
|
||||
) -> PrincipalRef:
|
||||
return PrincipalRef(
|
||||
account_id="account-1",
|
||||
membership_id=USER_ID,
|
||||
tenant_id=TENANT_ID,
|
||||
scopes=frozenset(scopes or {"campaigns:campaign:read"}),
|
||||
tenant_id=tenant_id,
|
||||
scopes=frozenset(
|
||||
{"campaigns:campaign:read"} if scopes is None else scopes
|
||||
),
|
||||
group_ids=frozenset(group_ids or set()),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.archive_encryption import (
|
||||
CampaignArchiveEncryptionError,
|
||||
LEGACY_ZIPCRYPTO_SCOPE,
|
||||
assert_archive_encryption_allowed,
|
||||
effective_archive_encryption_policy,
|
||||
stamp_legacy_zipcrypto_acknowledgements,
|
||||
)
|
||||
from govoplan_campaign.backend.db.models import Campaign
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.policy import (
|
||||
CAPABILITY_POLICY_CAMPAIGN_ARCHIVE_ENCRYPTION,
|
||||
CampaignArchiveEncryptionDecision,
|
||||
PolicySourceStep,
|
||||
)
|
||||
|
||||
|
||||
class _PolicyProvider:
|
||||
def __init__(self, methods: set[str]) -> None:
|
||||
self.methods = methods
|
||||
|
||||
def resolve_campaign_archive_encryption(self, session=None, *, request):
|
||||
del session, request
|
||||
return CampaignArchiveEncryptionDecision(
|
||||
allowed_password_encryption_methods=frozenset(self.methods),
|
||||
allowed_password_delivery_channels=frozenset(
|
||||
{"separate_mail", "sms", "letter", "phone", "in_person"}
|
||||
),
|
||||
policy_hash="f" * 64,
|
||||
source_path=(
|
||||
PolicySourceStep(
|
||||
scope_type="system",
|
||||
label="System archive-encryption policy",
|
||||
applied_fields=("allowed_password_encryption_methods",),
|
||||
policy={"allowed_password_encryption_methods": sorted(self.methods)},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, provider) -> None:
|
||||
self.provider = provider
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return name == CAPABILITY_POLICY_CAMPAIGN_ARCHIVE_ENCRYPTION
|
||||
|
||||
def capability(self, name: str):
|
||||
return self.provider if self.has_capability(name) else None
|
||||
|
||||
|
||||
class CampaignArchiveEncryptionGovernanceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
self.session = Session(self.engine)
|
||||
self.campaign = Campaign(
|
||||
id="campaign-1",
|
||||
tenant_id="tenant-1",
|
||||
external_id="example",
|
||||
name="Example",
|
||||
owner_user_id="user-1",
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def test_unavailable_policy_keeps_aes_and_fails_closed_for_legacy(self) -> None:
|
||||
with patch("govoplan_campaign.backend.archive_encryption.get_registry", return_value=None):
|
||||
policy = effective_archive_encryption_policy(self.session, self.campaign)
|
||||
self.assertFalse(policy.available)
|
||||
self.assertEqual(frozenset({"aes"}), policy.allowed_password_encryption_methods)
|
||||
assert_archive_encryption_allowed(
|
||||
self.session,
|
||||
self.campaign,
|
||||
_raw_archive("aes"),
|
||||
)
|
||||
with self.assertRaisesRegex(CampaignArchiveEncryptionError, "blocked"):
|
||||
assert_archive_encryption_allowed(
|
||||
self.session,
|
||||
self.campaign,
|
||||
_raw_archive("zip_standard", stamped=True),
|
||||
)
|
||||
|
||||
def test_existing_password_archive_inherits_separate_mail_channel(self) -> None:
|
||||
raw = _raw_archive("aes")
|
||||
raw["attachments"]["zip"]["archives"][0].pop("password_delivery_channel")
|
||||
with patch(
|
||||
"govoplan_campaign.backend.archive_encryption.get_registry",
|
||||
return_value=None,
|
||||
):
|
||||
decision = assert_archive_encryption_allowed(
|
||||
self.session,
|
||||
self.campaign,
|
||||
raw,
|
||||
)
|
||||
self.assertIn(
|
||||
"separate_mail",
|
||||
decision.allowed_password_delivery_channels,
|
||||
)
|
||||
|
||||
def test_legacy_selection_requires_permission_and_gets_server_stamp(self) -> None:
|
||||
registry = _Registry(_PolicyProvider({"aes", "zip_standard"}))
|
||||
candidate = _raw_archive("zip_standard")
|
||||
candidate["attachments"]["zip"]["archives"][0].update(
|
||||
{
|
||||
"legacy_zipcrypto_acknowledged": True,
|
||||
"legacy_zipcrypto_reason": "Recipient requires built-in Windows extraction",
|
||||
}
|
||||
)
|
||||
with patch("govoplan_campaign.backend.archive_encryption.get_registry", return_value=registry):
|
||||
with self.assertRaisesRegex(CampaignArchiveEncryptionError, "Missing scope"):
|
||||
stamp_legacy_zipcrypto_acknowledgements(
|
||||
self.session,
|
||||
self.campaign,
|
||||
{},
|
||||
candidate,
|
||||
principal=_principal(set()),
|
||||
)
|
||||
stamped, evidence = stamp_legacy_zipcrypto_acknowledgements(
|
||||
self.session,
|
||||
self.campaign,
|
||||
{},
|
||||
candidate,
|
||||
principal=_principal({LEGACY_ZIPCRYPTO_SCOPE}),
|
||||
)
|
||||
archive = stamped["attachments"]["zip"]["archives"][0]
|
||||
self.assertEqual("user-1", archive["legacy_zipcrypto_acknowledged_by"])
|
||||
self.assertTrue(archive["legacy_zipcrypto_acknowledged_at"])
|
||||
self.assertEqual("f" * 64, evidence[0]["policy_hash"])
|
||||
assert_archive_encryption_allowed(
|
||||
self.session,
|
||||
self.campaign,
|
||||
stamped,
|
||||
principal=_principal({LEGACY_ZIPCRYPTO_SCOPE}),
|
||||
)
|
||||
|
||||
|
||||
def _raw_archive(method: str, *, stamped: bool = False) -> dict:
|
||||
archive = {
|
||||
"id": "archive-1",
|
||||
"method": method,
|
||||
"password_enabled": True,
|
||||
"password_delivery_channel": "separate_mail",
|
||||
"legacy_zipcrypto_acknowledged": method == "zip_standard",
|
||||
"legacy_zipcrypto_reason": "Windows recipient compatibility required"
|
||||
if method == "zip_standard"
|
||||
else None,
|
||||
}
|
||||
if stamped:
|
||||
archive.update(
|
||||
{
|
||||
"legacy_zipcrypto_acknowledged_by": "user-1",
|
||||
"legacy_zipcrypto_acknowledged_at": "2026-08-20T10:00:00+00:00",
|
||||
}
|
||||
)
|
||||
return {"attachments": {"zip": {"enabled": True, "archives": [archive]}}}
|
||||
|
||||
|
||||
def _principal(scopes: set[str]) -> ApiPrincipal:
|
||||
return ApiPrincipal(
|
||||
principal=PrincipalRef(
|
||||
account_id="account-1",
|
||||
membership_id="user-1",
|
||||
tenant_id="tenant-1",
|
||||
scopes=frozenset(scopes),
|
||||
),
|
||||
account=SimpleNamespace(id="account-1"),
|
||||
user=SimpleNamespace(id="user-1"),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
|
||||
@@ -7,6 +7,7 @@ import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_campaign.backend.campaign.models import CampaignConfig
|
||||
from govoplan_campaign.backend.campaign.loader import validate_against_schema
|
||||
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
|
||||
from govoplan_campaign.backend.messages.builder import build_campaign_messages
|
||||
|
||||
@@ -56,6 +57,64 @@ class CampaignAttachmentBuildTests(unittest.TestCase):
|
||||
"delivery": {"imap_append_sent": {"enabled": False}},
|
||||
})
|
||||
|
||||
def _attachment_reuse_config(
|
||||
self,
|
||||
*,
|
||||
action: str,
|
||||
allow_within: str = "none",
|
||||
recipient_emails: tuple[str, ...] = (
|
||||
"first@example.org",
|
||||
"second@example.org",
|
||||
),
|
||||
duplicate_rules: bool = False,
|
||||
) -> CampaignConfig:
|
||||
rules = [{
|
||||
"id": "shared-file",
|
||||
"base_dir": "documents",
|
||||
"file_filter": "shared.pdf",
|
||||
"required": True,
|
||||
}]
|
||||
if duplicate_rules:
|
||||
rules.append({**rules[0], "id": "shared-file-again"})
|
||||
return CampaignConfig.model_validate({
|
||||
"version": "1.0",
|
||||
"campaign": {
|
||||
"id": f"reuse-{action}-{allow_within}",
|
||||
"name": "Attachment reuse",
|
||||
"mode": "test",
|
||||
},
|
||||
"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": "Subject", "text": "Body"},
|
||||
"attachments": {
|
||||
"global": rules,
|
||||
"reuse_policy": {
|
||||
"action": action,
|
||||
"allow_within": allow_within,
|
||||
},
|
||||
},
|
||||
"entries": {
|
||||
"inline": [
|
||||
{
|
||||
"id": f"recipient-{index}",
|
||||
"to": [{"email": email, "type": "to"}],
|
||||
}
|
||||
for index, email in enumerate(recipient_emails, start=1)
|
||||
]
|
||||
},
|
||||
"validation_policy": {
|
||||
"missing_email": "block",
|
||||
"template_error": "block",
|
||||
},
|
||||
"delivery": {"imap_append_sent": {"enabled": False}},
|
||||
})
|
||||
|
||||
def test_send_without_attachments_policy_does_not_block_when_no_rules_are_configured(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
@@ -100,9 +159,9 @@ class CampaignAttachmentBuildTests(unittest.TestCase):
|
||||
cases = {
|
||||
"block": ("build_failed", "blocked", 0, "block", False),
|
||||
"ask": ("built", "needs_review", 0, "ask", True),
|
||||
"drop": ("built", "needs_review", 0, "ask", True),
|
||||
"drop": ("built", "excluded", 0, "drop", True),
|
||||
"warn": ("built", "warning", 1, "warn", True),
|
||||
"continue": ("built", "warning", 1, None, True),
|
||||
"continue": ("built", "ready", 1, None, True),
|
||||
}
|
||||
for behavior, (build_status, validation_status, queueable_count, issue_behavior, has_mime) in cases.items():
|
||||
with self.subTest(behavior=behavior):
|
||||
@@ -198,6 +257,41 @@ class CampaignAttachmentBuildTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(issue.behavior, "warn")
|
||||
|
||||
def test_explicit_optional_empty_rule_is_information_unless_hard_blocked(self) -> None:
|
||||
for policy, expected in (("warn", "ready"), ("ask", "ready"), ("block", "blocked")):
|
||||
with self.subTest(policy=policy), tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
campaign_file = root / "campaign.json"
|
||||
campaign_file.write_text("{}", encoding="utf-8")
|
||||
config = self._no_attachment_config(behavior="continue", configure_missing_rule=True)
|
||||
config.validation_policy.missing_optional_attachment = policy
|
||||
result = build_campaign_messages(config, campaign_file=campaign_file, output_dir=root / "out", write_eml=True)
|
||||
message = result.report.messages[0]
|
||||
self.assertEqual(message.validation_status.value, expected)
|
||||
issue = next(item for item in message.issues if item.code == "missing_optional_attachment")
|
||||
self.assertEqual(issue.behavior, "block" if policy == "block" else "continue")
|
||||
self.assertEqual(issue.severity, "error" if policy == "block" else "info")
|
||||
self.assertEqual(message.attachments[0].matches, [])
|
||||
|
||||
def test_deliberate_rule_drop_is_excluded_but_cannot_override_required_block(self) -> None:
|
||||
for required, expected in ((False, "excluded"), (True, "blocked")):
|
||||
with self.subTest(required=required), tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
campaign_file = root / "campaign.json"
|
||||
campaign_file.write_text("{}", encoding="utf-8")
|
||||
config = self._no_attachment_config(behavior="continue", configure_missing_rule=True)
|
||||
rule = config.attachments.global_[0]
|
||||
rule.missing_behavior = "drop"
|
||||
rule.required = required
|
||||
result = build_campaign_messages(config, campaign_file=campaign_file, output_dir=root / "out", write_eml=True)
|
||||
message = result.report.messages[0]
|
||||
self.assertEqual(message.validation_status.value, expected)
|
||||
self.assertEqual(result.report.queueable_count, 0)
|
||||
if not required:
|
||||
self.assertEqual(message.send_status.value, "skipped")
|
||||
self.assertEqual(message.imap_status.value, "skipped")
|
||||
self.assertEqual(message.issues[0].behavior, "drop")
|
||||
|
||||
def test_missing_pattern_does_not_create_zip_member_or_count_as_attachment(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
@@ -275,6 +369,264 @@ class CampaignAttachmentBuildTests(unittest.TestCase):
|
||||
self.assertEqual(archive.namelist(), ["matched.xlsx"])
|
||||
self.assertEqual(archive.read("matched.xlsx"), b"matched workbook")
|
||||
|
||||
def test_attachment_reuse_action_controls_message_validation(self) -> None:
|
||||
expected = {
|
||||
"allow": ("ready", None, 0, 1),
|
||||
"warn": ("warning", "warn", 1, 0),
|
||||
"review": ("needs_review", "ask", 1, 0),
|
||||
"block": ("blocked", "block", 1, 0),
|
||||
}
|
||||
for action, (status, behavior, violation_count, allowed_count) in expected.items():
|
||||
with self.subTest(action=action), tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
documents = root / "documents"
|
||||
documents.mkdir()
|
||||
(documents / "shared.pdf").write_bytes(b"shared")
|
||||
campaign_file = root / "campaign.json"
|
||||
campaign_file.write_text("{}", encoding="utf-8")
|
||||
config = self._attachment_reuse_config(action=action)
|
||||
|
||||
result = build_campaign_messages(
|
||||
config,
|
||||
campaign_file=campaign_file,
|
||||
output_dir=root / "out",
|
||||
write_eml=False,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
[status, status],
|
||||
[message.validation_status.value for message in result.report.messages],
|
||||
)
|
||||
report = result.report.attachment_reuse
|
||||
self.assertEqual(1, report["duplicate_file_count"])
|
||||
self.assertEqual(violation_count, report["violation_file_count"])
|
||||
self.assertEqual(allowed_count, report["allowed_file_count"])
|
||||
finding = report["findings"][0]
|
||||
self.assertEqual("shared.pdf", finding["file_name"])
|
||||
self.assertNotIn(str(root), str(report))
|
||||
issues = [
|
||||
issue
|
||||
for message in result.report.messages
|
||||
for issue in message.issues
|
||||
if issue.code == "duplicate_attachment_reuse"
|
||||
]
|
||||
if behavior is None:
|
||||
self.assertEqual([], issues)
|
||||
else:
|
||||
self.assertEqual([behavior, behavior], [issue.behavior for issue in issues])
|
||||
self.assertEqual(
|
||||
{"action": action, "allow_within": "none"},
|
||||
issues[0].details["policy"],
|
||||
)
|
||||
|
||||
def test_attachment_reuse_can_be_allowed_within_recipient_or_message(self) -> None:
|
||||
cases = (
|
||||
("same_recipient", ("same@example.org", "same@example.org"), False, 2),
|
||||
("same_message", ("same@example.org",), True, 2),
|
||||
)
|
||||
for allow_within, recipients, duplicate_rules, expected_use_count in cases:
|
||||
with self.subTest(allow_within=allow_within), tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
documents = root / "documents"
|
||||
documents.mkdir()
|
||||
(documents / "shared.pdf").write_bytes(b"shared")
|
||||
campaign_file = root / "campaign.json"
|
||||
campaign_file.write_text("{}", encoding="utf-8")
|
||||
|
||||
result = build_campaign_messages(
|
||||
self._attachment_reuse_config(
|
||||
action="block",
|
||||
allow_within=allow_within,
|
||||
recipient_emails=recipients,
|
||||
duplicate_rules=duplicate_rules,
|
||||
),
|
||||
campaign_file=campaign_file,
|
||||
output_dir=root / "out",
|
||||
write_eml=False,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
["ready"] * len(recipients),
|
||||
[message.validation_status.value for message in result.report.messages],
|
||||
)
|
||||
report = result.report.attachment_reuse
|
||||
self.assertEqual(1, report["allowed_file_count"])
|
||||
self.assertEqual(0, report["violation_file_count"])
|
||||
self.assertEqual(expected_use_count, report["findings"][0]["use_count"])
|
||||
|
||||
def test_attachment_reuse_policy_is_part_of_the_json_schema(self) -> None:
|
||||
config = self._attachment_reuse_config(
|
||||
action="review",
|
||||
allow_within="same_recipient",
|
||||
)
|
||||
|
||||
payload = config.model_dump(
|
||||
mode="json",
|
||||
by_alias=True,
|
||||
exclude_none=True,
|
||||
exclude_defaults=True,
|
||||
)
|
||||
payload["server"].pop("profile_capabilities", None)
|
||||
|
||||
validate_against_schema(payload)
|
||||
|
||||
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)
|
||||
self.assertEqual(
|
||||
{
|
||||
"contract_version": "1",
|
||||
"action": "route_report" if mode == "report" else "route_with_files",
|
||||
"routing_mode": mode,
|
||||
"validation_behavior": "block",
|
||||
"watched_source_count": 1,
|
||||
"residual_file_count": 1,
|
||||
"recipient": {
|
||||
"email": "operator@example.org",
|
||||
"name": "Operator",
|
||||
"type": "to",
|
||||
},
|
||||
},
|
||||
result.report.residual_file_disposition,
|
||||
)
|
||||
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 [])
|
||||
|
||||
def test_residual_file_policy_evidence_normalizes_block_and_ignore(self) -> None:
|
||||
for behavior, action in (("block", "block"), ("continue", "ignore")):
|
||||
with self.subTest(behavior=behavior), tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
watched = root / "watched"
|
||||
watched.mkdir()
|
||||
(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-{behavior}", "name": "Residual", "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", "text": "Body"},
|
||||
"attachments": {
|
||||
"base_paths": [{
|
||||
"id": "watched",
|
||||
"name": "Watched folder",
|
||||
"path": "watched",
|
||||
"unsent_warning": True,
|
||||
}],
|
||||
},
|
||||
"entries": {"inline": [{
|
||||
"id": "recipient-1",
|
||||
"to": [{"email": "recipient@example.org", "type": "to"}],
|
||||
}]},
|
||||
"validation_policy": {"unsent_attachment_files": behavior},
|
||||
"delivery": {"imap_append_sent": {"enabled": False}},
|
||||
})
|
||||
|
||||
result = build_campaign_messages(
|
||||
config,
|
||||
campaign_file=campaign_file,
|
||||
output_dir=root / "out",
|
||||
write_eml=False,
|
||||
)
|
||||
|
||||
self.assertEqual(action, result.report.residual_file_disposition["action"])
|
||||
self.assertEqual(1, result.report.residual_file_disposition["residual_file_count"])
|
||||
issue_codes = {
|
||||
issue.code
|
||||
for message in result.report.messages
|
||||
for issue in message.issues
|
||||
}
|
||||
self.assertEqual(behavior == "block", "unsent_attachment_files" in issue_codes)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -15,7 +15,12 @@ from govoplan_campaign.backend.persistence.campaigns import (
|
||||
_verify_build_storage_manifest,
|
||||
_verify_storage_keys_absent,
|
||||
)
|
||||
from govoplan_campaign.backend.routes.versions import _campaign_build_recovery_plan
|
||||
from govoplan_campaign.backend.routes.versions import (
|
||||
_attachment_reuse_audit_evidence,
|
||||
_campaign_build_recovery_plan,
|
||||
_residual_file_audit_evidence,
|
||||
_review_decision_audit_evidence,
|
||||
)
|
||||
|
||||
|
||||
def test_object_only_and_managed_output_builds_use_distinct_recovery_modes() -> None:
|
||||
@@ -34,6 +39,68 @@ def test_object_only_and_managed_output_builds_use_distinct_recovery_modes() ->
|
||||
)
|
||||
|
||||
|
||||
def test_residual_file_audit_evidence_omits_recipient_identity() -> None:
|
||||
assert _residual_file_audit_evidence({
|
||||
"contract_version": "1",
|
||||
"action": "route_report",
|
||||
"routing_mode": "report",
|
||||
"validation_behavior": "warn",
|
||||
"watched_source_count": 2,
|
||||
"residual_file_count": 3,
|
||||
"recipient": {"email": "operator@example.org"},
|
||||
}) == {
|
||||
"contract_version": "1",
|
||||
"action": "route_report",
|
||||
"routing_mode": "report",
|
||||
"validation_behavior": "warn",
|
||||
"watched_source_count": 2,
|
||||
"residual_file_count": 3,
|
||||
}
|
||||
|
||||
|
||||
def test_attachment_reuse_audit_evidence_keeps_policy_but_not_file_names() -> None:
|
||||
assert _attachment_reuse_audit_evidence({
|
||||
"contract_version": "1",
|
||||
"policy": {"action": "review", "allow_within": "same_recipient"},
|
||||
"duplicate_file_count": 4,
|
||||
"allowed_file_count": 1,
|
||||
"violation_file_count": 3,
|
||||
"affected_message_count": 5,
|
||||
"findings": [{"file_name": "personal.pdf"}],
|
||||
}) == {
|
||||
"contract_version": "1",
|
||||
"policy": {"action": "review", "allow_within": "same_recipient"},
|
||||
"duplicate_file_count": 4,
|
||||
"allowed_file_count": 1,
|
||||
"violation_file_count": 3,
|
||||
"affected_message_count": 5,
|
||||
}
|
||||
|
||||
|
||||
def test_review_decision_audit_evidence_is_aggregate_and_integrity_sealed() -> None:
|
||||
version = type("Version", (), {
|
||||
"editor_state": {
|
||||
"review_send": {
|
||||
"issue_decisions": [{
|
||||
"decision": "accept",
|
||||
"reason": "Approved exception",
|
||||
"issue_codes": ["duplicate_attachment_reuse"],
|
||||
"issue_fingerprint": "f" * 64,
|
||||
"message_sha256": "m" * 64,
|
||||
}]
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
evidence = _review_decision_audit_evidence(version)
|
||||
|
||||
assert evidence["count"] == 1
|
||||
assert evidence["with_reason_count"] == 1
|
||||
assert evidence["issue_codes"] == ["duplicate_attachment_reuse"]
|
||||
assert len(str(evidence["evidence_sha256"])) == 64
|
||||
assert "Approved exception" not in str(evidence)
|
||||
|
||||
|
||||
def test_generated_object_manifest_verifies_exact_bytes(tmp_path: Path) -> None:
|
||||
storage = LocalFilesystemStorageBackend(tmp_path)
|
||||
payload = b"Message-ID: <build@example.test>\r\n\r\nbody"
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from govoplan_access.backend.db.models import Account, Group, User
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignCollaborationEntry,
|
||||
CampaignJob,
|
||||
CampaignShare,
|
||||
CampaignVersion,
|
||||
)
|
||||
from govoplan_campaign.backend.routes.collaboration import (
|
||||
create_campaign_collaboration_entry,
|
||||
list_campaign_collaboration,
|
||||
redact_campaign_collaboration_entry,
|
||||
withdraw_campaign_collaboration_entry,
|
||||
)
|
||||
from govoplan_campaign.backend.schemas import (
|
||||
CampaignCollaborationCreateRequest,
|
||||
CampaignCollaborationModerationRequest,
|
||||
CampaignCollaborationReferenceInput,
|
||||
)
|
||||
from govoplan_core.core.access import GroupRef, UserRef
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
TENANT_ID = "tenant-1"
|
||||
|
||||
|
||||
class _Principal:
|
||||
tenant_id = TENANT_ID
|
||||
api_key = None
|
||||
|
||||
def __init__(self, user_id: str, *scopes: str) -> None:
|
||||
self.user = SimpleNamespace(
|
||||
id=user_id,
|
||||
display_name=f"User {user_id}",
|
||||
email=f"{user_id}@example.test",
|
||||
)
|
||||
self.scopes = frozenset(scopes)
|
||||
|
||||
def has(self, scope: str) -> bool:
|
||||
return scope in self.scopes or "tenant:*" in self.scopes
|
||||
|
||||
|
||||
class _Directory:
|
||||
users = (
|
||||
UserRef(id="user-1", account_id="account-1", tenant_id=TENANT_ID, display_name="Author"),
|
||||
UserRef(id="user-2", account_id="account-2", tenant_id=TENANT_ID, display_name="Collaborator"),
|
||||
UserRef(id="user-3", account_id="account-3", tenant_id=TENANT_ID, display_name="Unrelated"),
|
||||
)
|
||||
|
||||
def users_for_tenant(self, tenant_id: str):
|
||||
return self.users if tenant_id == TENANT_ID else ()
|
||||
|
||||
def groups_for_user(self, user_id: str, *, tenant_id: str):
|
||||
if tenant_id == TENANT_ID and user_id == "user-2":
|
||||
return (GroupRef(id="group-1", tenant_id=TENANT_ID, name="Collaborators"),)
|
||||
return ()
|
||||
|
||||
|
||||
class _Notifications:
|
||||
def __init__(self) -> None:
|
||||
self.requests = []
|
||||
|
||||
def enqueue_notification(self, _session, request, *, enqueue_delivery: bool = True):
|
||||
self.requests.append((request, enqueue_delivery))
|
||||
return {"id": f"notification-{len(self.requests)}"}
|
||||
|
||||
|
||||
class _UnavailableNotifications:
|
||||
def enqueue_notification(self, _session, _request, *, enqueue_delivery: bool = True):
|
||||
raise RuntimeError("Notifications is temporarily unavailable")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def session() -> Session:
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
engine,
|
||||
tables=[
|
||||
Account.__table__,
|
||||
User.__table__,
|
||||
Group.__table__,
|
||||
ChangeSequenceEntry.__table__,
|
||||
Campaign.__table__,
|
||||
CampaignVersion.__table__,
|
||||
CampaignJob.__table__,
|
||||
CampaignShare.__table__,
|
||||
CampaignCollaborationEntry.__table__,
|
||||
],
|
||||
)
|
||||
session_factory = sessionmaker(bind=engine, class_=Session, expire_on_commit=False)
|
||||
database = session_factory()
|
||||
database.add_all(
|
||||
[
|
||||
Account(id=f"account-{number}", email=f"user-{number}@example.test", normalized_email=f"user-{number}@example.test")
|
||||
for number in range(1, 4)
|
||||
]
|
||||
)
|
||||
database.flush()
|
||||
database.add_all(
|
||||
[
|
||||
User(id=f"user-{number}", tenant_id=TENANT_ID, account_id=f"account-{number}", email=f"user-{number}@example.test")
|
||||
for number in range(1, 4)
|
||||
]
|
||||
)
|
||||
database.add(Group(id="group-1", tenant_id=TENANT_ID, slug="collaborators", name="Collaborators"))
|
||||
campaign = Campaign(
|
||||
id="campaign-1",
|
||||
tenant_id=TENANT_ID,
|
||||
owner_user_id="user-1",
|
||||
external_id="campaign-1",
|
||||
name="Campaign One",
|
||||
current_version_id="version-1",
|
||||
)
|
||||
version = CampaignVersion(
|
||||
id="version-1",
|
||||
campaign_id=campaign.id,
|
||||
version_number=1,
|
||||
raw_json={
|
||||
"version": "1.0",
|
||||
"entries": {"imports": [{"id": "import-1", "source_type": "csv"}]},
|
||||
"attachments": {"global": [{"label": "Notice"}]},
|
||||
},
|
||||
)
|
||||
database.add_all(
|
||||
[
|
||||
campaign,
|
||||
version,
|
||||
CampaignShare(
|
||||
id="share-1",
|
||||
tenant_id=TENANT_ID,
|
||||
campaign_id=campaign.id,
|
||||
target_type="group",
|
||||
target_id="group-1",
|
||||
permission="read",
|
||||
),
|
||||
CampaignJob(
|
||||
id="job-1",
|
||||
tenant_id=TENANT_ID,
|
||||
campaign_id=campaign.id,
|
||||
campaign_version_id=version.id,
|
||||
entry_index=0,
|
||||
),
|
||||
]
|
||||
)
|
||||
database.commit()
|
||||
try:
|
||||
yield database
|
||||
finally:
|
||||
database.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _commit_audit(session: Session, *_args, **_kwargs) -> None:
|
||||
session.commit()
|
||||
|
||||
|
||||
def _principal(user_id: str = "user-1", *, moderate: bool = False) -> _Principal:
|
||||
scopes = [
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:discussion:read",
|
||||
"campaigns:discussion:post",
|
||||
]
|
||||
if moderate:
|
||||
scopes.append("campaigns:discussion:moderate")
|
||||
return _Principal(user_id, *scopes)
|
||||
|
||||
|
||||
def test_post_is_append_only_references_a_version_and_notifies_authorized_mentions(session: Session) -> None:
|
||||
notifications = _Notifications()
|
||||
original_version = dict(session.get(CampaignVersion, "version-1").raw_json)
|
||||
with (
|
||||
patch("govoplan_campaign.backend.routes.collaboration._access_directory", return_value=_Directory()),
|
||||
patch("govoplan_campaign.backend.routes.collaboration.notification_dispatch_provider", return_value=notifications),
|
||||
patch("govoplan_campaign.backend.routes.collaboration.audit_from_principal", side_effect=_commit_audit) as audit,
|
||||
):
|
||||
response = create_campaign_collaboration_entry(
|
||||
"campaign-1",
|
||||
CampaignCollaborationCreateRequest(
|
||||
content="Please review the frozen version.",
|
||||
mention_user_ids=["user-2", "user-1", "user-2"],
|
||||
reference=CampaignCollaborationReferenceInput(
|
||||
kind="campaign_version",
|
||||
id="version-1",
|
||||
),
|
||||
),
|
||||
session,
|
||||
_principal(),
|
||||
)
|
||||
|
||||
assert response.content == "Please review the frozen version."
|
||||
assert response.reference is not None
|
||||
assert response.reference.label == "Version 1"
|
||||
assert response.mention_user_ids == ["user-2"]
|
||||
assert session.get(CampaignVersion, "version-1").raw_json == original_version
|
||||
assert len(notifications.requests) == 1
|
||||
notification, enqueue_delivery = notifications.requests[0]
|
||||
assert notification.recipient_id == "user-2"
|
||||
assert notification.payload["content_disclosed"] is False
|
||||
assert enqueue_delivery is False
|
||||
details = audit.call_args.kwargs["details"]
|
||||
assert details["content_disclosed"] is False
|
||||
assert "Please review" not in repr(details)
|
||||
|
||||
|
||||
def test_mentions_reject_users_without_campaign_access(session: Session) -> None:
|
||||
with patch("govoplan_campaign.backend.routes.collaboration._access_directory", return_value=_Directory()):
|
||||
with pytest.raises(HTTPException) as raised:
|
||||
create_campaign_collaboration_entry(
|
||||
"campaign-1",
|
||||
CampaignCollaborationCreateRequest(content="No leak", mention_user_ids=["user-3"]),
|
||||
session,
|
||||
_principal(),
|
||||
)
|
||||
assert raised.value.status_code == 422
|
||||
assert session.query(CampaignCollaborationEntry).count() == 0
|
||||
|
||||
|
||||
def test_notification_failure_does_not_block_or_roll_back_collaboration(session: Session) -> None:
|
||||
with (
|
||||
patch("govoplan_campaign.backend.routes.collaboration._access_directory", return_value=_Directory()),
|
||||
patch(
|
||||
"govoplan_campaign.backend.routes.collaboration.notification_dispatch_provider",
|
||||
return_value=_UnavailableNotifications(),
|
||||
),
|
||||
patch("govoplan_campaign.backend.routes.collaboration.audit_from_principal", side_effect=_commit_audit),
|
||||
):
|
||||
response = create_campaign_collaboration_entry(
|
||||
"campaign-1",
|
||||
CampaignCollaborationCreateRequest(
|
||||
content="This discussion entry must survive an optional integration outage.",
|
||||
mention_user_ids=["user-2"],
|
||||
),
|
||||
session,
|
||||
_principal(),
|
||||
)
|
||||
|
||||
stored = session.get(CampaignCollaborationEntry, response.id)
|
||||
assert stored is not None
|
||||
assert stored.content == response.content
|
||||
assert stored.mention_user_ids == ["user-2"]
|
||||
|
||||
|
||||
def test_collaboration_migration_is_repeatable_and_creates_thread_index() -> None:
|
||||
migration = importlib.import_module(
|
||||
"govoplan_campaign.backend.migrations.versions."
|
||||
"c7d8e9f0a1b2_v0120_campaign_collaboration"
|
||||
)
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text("CREATE TABLE access_users (id VARCHAR(36) PRIMARY KEY)"))
|
||||
connection.execute(text("CREATE TABLE campaigns (id VARCHAR(36) PRIMARY KEY)"))
|
||||
connection.execute(
|
||||
text(
|
||||
"CREATE TABLE campaign_versions ("
|
||||
"id VARCHAR(36) PRIMARY KEY, campaign_id VARCHAR(36) NOT NULL)"
|
||||
)
|
||||
)
|
||||
context = MigrationContext.configure(connection)
|
||||
with patch.object(migration, "op", Operations(context)):
|
||||
migration.upgrade()
|
||||
migration.upgrade()
|
||||
|
||||
inspector = inspect(connection)
|
||||
columns = {
|
||||
column["name"]
|
||||
for column in inspector.get_columns("campaign_collaboration_entries")
|
||||
}
|
||||
indexes = {
|
||||
index["name"]
|
||||
for index in inspector.get_indexes("campaign_collaboration_entries")
|
||||
}
|
||||
|
||||
assert {
|
||||
"campaign_id",
|
||||
"campaign_version_id",
|
||||
"content_sha256",
|
||||
"mention_user_ids",
|
||||
"withdrawn_at",
|
||||
"redacted_at",
|
||||
}.issubset(columns)
|
||||
assert "ix_campaign_collaboration_entries_thread" in indexes
|
||||
|
||||
with patch.object(migration, "op", Operations(context)):
|
||||
migration.downgrade()
|
||||
assert not inspect(connection).has_table("campaign_collaboration_entries")
|
||||
|
||||
|
||||
def test_visibility_pagination_withdrawal_and_redaction_leave_tombstones(session: Session) -> None:
|
||||
with (
|
||||
patch("govoplan_campaign.backend.routes.collaboration.notification_dispatch_provider", return_value=None),
|
||||
patch("govoplan_campaign.backend.routes.collaboration.audit_from_principal", side_effect=_commit_audit),
|
||||
):
|
||||
public_entry = create_campaign_collaboration_entry(
|
||||
"campaign-1",
|
||||
CampaignCollaborationCreateRequest(content="Visible discussion"),
|
||||
session,
|
||||
_principal(),
|
||||
)
|
||||
moderator_entry = create_campaign_collaboration_entry(
|
||||
"campaign-1",
|
||||
CampaignCollaborationCreateRequest(content="Restricted discussion", visibility="moderators"),
|
||||
session,
|
||||
_principal(moderate=True),
|
||||
)
|
||||
ordinary = list_campaign_collaboration("campaign-1", 1, None, session, _principal())
|
||||
moderated = list_campaign_collaboration("campaign-1", 1, None, session, _principal(moderate=True))
|
||||
withdrawn = withdraw_campaign_collaboration_entry(
|
||||
"campaign-1",
|
||||
public_entry.id,
|
||||
CampaignCollaborationModerationRequest(reason="Posted in error"),
|
||||
session,
|
||||
_principal(),
|
||||
)
|
||||
redacted = redact_campaign_collaboration_entry(
|
||||
"campaign-1",
|
||||
moderator_entry.id,
|
||||
CampaignCollaborationModerationRequest(reason="Contains restricted material"),
|
||||
session,
|
||||
_principal(moderate=True),
|
||||
)
|
||||
|
||||
assert [entry.id for entry in ordinary.items] == [public_entry.id]
|
||||
assert ordinary.has_more is False
|
||||
assert [entry.id for entry in moderated.items] == [moderator_entry.id]
|
||||
assert moderated.has_more is True
|
||||
assert moderated.next_cursor
|
||||
second_page = list_campaign_collaboration(
|
||||
"campaign-1",
|
||||
1,
|
||||
moderated.next_cursor,
|
||||
session,
|
||||
_principal(moderate=True),
|
||||
)
|
||||
assert [entry.id for entry in second_page.items] == [public_entry.id]
|
||||
assert withdrawn.tombstone == "withdrawn"
|
||||
assert withdrawn.content is None
|
||||
assert redacted.tombstone == "redacted"
|
||||
assert redacted.content is None
|
||||
assert session.get(CampaignCollaborationEntry, public_entry.id).content is None
|
||||
assert session.get(CampaignCollaborationEntry, redacted.id).content_sha256 == redacted.content_sha256
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("kind", "reference_id"),
|
||||
[
|
||||
("recipient_import_batch", "version-1:import-1"),
|
||||
("attachment_rule", "version-1:attachments.global[0]"),
|
||||
("delivery_job", "job-1"),
|
||||
("report", "campaign-1:version-1:delivery"),
|
||||
],
|
||||
)
|
||||
def test_supported_reference_contexts_are_validated(session: Session, kind: str, reference_id: str) -> None:
|
||||
with (
|
||||
patch("govoplan_campaign.backend.routes.collaboration.notification_dispatch_provider", return_value=None),
|
||||
patch("govoplan_campaign.backend.routes.collaboration.audit_from_principal", side_effect=_commit_audit),
|
||||
):
|
||||
response = create_campaign_collaboration_entry(
|
||||
"campaign-1",
|
||||
CampaignCollaborationCreateRequest(
|
||||
content="Reference context",
|
||||
reference=CampaignCollaborationReferenceInput(kind=kind, id=reference_id), # type: ignore[arg-type]
|
||||
),
|
||||
session,
|
||||
_principal(),
|
||||
)
|
||||
assert response.reference is not None
|
||||
assert response.reference.kind == kind
|
||||
assert response.reference.id == reference_id
|
||||
@@ -0,0 +1,148 @@
|
||||
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,
|
||||
_campaign_content_required_fields,
|
||||
)
|
||||
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
|
||||
assert payload["required_fields"] == []
|
||||
|
||||
|
||||
def test_campaign_content_required_fields_are_normalized_and_deduplicated() -> None:
|
||||
payload = CampaignContentLibrarySaveRequest(
|
||||
name="Greeting",
|
||||
kind="campaign_part",
|
||||
subject="Hello {{ local:display_name }}",
|
||||
text="Reference ${global.case_reference}; hello {{local::display_name}}",
|
||||
html="<p>{{fields.salutation}} {{local:to[2].email}} {{unsupported namespace}}</p>",
|
||||
)
|
||||
|
||||
requirements = _campaign_content_required_fields(payload)
|
||||
|
||||
assert [item.path for item in requirements] == [
|
||||
"global.case_reference",
|
||||
"local.display_name",
|
||||
"local.to.2.email",
|
||||
"salutation",
|
||||
]
|
||||
|
||||
|
||||
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")
|
||||
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_IMPORT_PROGRAM = """
|
||||
import importlib
|
||||
import socket
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, sys.argv[1])
|
||||
|
||||
def deny_network(*args, **kwargs):
|
||||
raise AssertionError("Campaign imports must not connect to external services")
|
||||
|
||||
class NoNetworkSocket(socket.socket):
|
||||
connect = deny_network
|
||||
connect_ex = deny_network
|
||||
|
||||
socket.create_connection = deny_network
|
||||
socket.socket = NoNetworkSocket
|
||||
|
||||
importlib.import_module(sys.argv[2])
|
||||
from govoplan_campaign.backend.campaign import (
|
||||
CampaignConfig, SemanticIssue, SemanticReport,
|
||||
load_campaign_config, load_campaign_json, validate_campaign_config,
|
||||
)
|
||||
from govoplan_campaign.backend.attachments.resolver import resolve_campaign_attachments
|
||||
|
||||
assert all(callable(value) for value in (
|
||||
CampaignConfig, SemanticIssue, SemanticReport,
|
||||
load_campaign_config, load_campaign_json, validate_campaign_config,
|
||||
resolve_campaign_attachments,
|
||||
))
|
||||
"""
|
||||
|
||||
|
||||
class CampaignImportOrderTests(unittest.TestCase):
|
||||
def test_model_validation_and_attachment_entry_points_import_independently(self):
|
||||
source_root = Path(__file__).resolve().parents[1] / "src"
|
||||
for first_module in (
|
||||
"govoplan_campaign.backend.attachments.resolver",
|
||||
"govoplan_campaign.backend.campaign.validation",
|
||||
"govoplan_campaign.backend.campaign.entries",
|
||||
):
|
||||
with self.subTest(first_module=first_module):
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "-I", "-c", _IMPORT_PROGRAM, str(source_root), first_module],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=30,
|
||||
)
|
||||
self.assertEqual(0, completed.returncode, completed.stderr or completed.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
|
||||
@@ -160,6 +160,42 @@ class CampaignOptimisticConcurrencyTests(unittest.TestCase):
|
||||
assert current is not None
|
||||
self.assertEqual(current.raw_json["campaign"]["name"], "First writer")
|
||||
|
||||
def test_metadata_save_does_not_erase_recorded_review(self) -> None:
|
||||
review = {
|
||||
"build_token": "server-build-token",
|
||||
"inspection_complete": True,
|
||||
"reviewed_message_keys": ["recipient-1"],
|
||||
"issue_decisions": [],
|
||||
"updated_at": "2026-07-21T00:00:00+00:00",
|
||||
"updated_by_user_id": "reviewer-1",
|
||||
}
|
||||
with self.SessionLocal() as session:
|
||||
version = session.get(CampaignVersion, "version-1")
|
||||
assert version is not None
|
||||
version.editor_state = {
|
||||
"created_from": "minimal_campaign",
|
||||
"review_send": review,
|
||||
}
|
||||
session.commit()
|
||||
saved = update_campaign_version(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
campaign_id="campaign-1",
|
||||
version_id="version-1",
|
||||
editor_state={"opt_ins": {"inline_guidance": False}},
|
||||
expected_revision=version.edit_revision,
|
||||
autosave=True,
|
||||
)
|
||||
self.assertEqual(saved.editor_state["review_send"], review)
|
||||
|
||||
with self.SessionLocal() as verification:
|
||||
current = verification.get(CampaignVersion, "version-1")
|
||||
assert current is not None
|
||||
self.assertEqual(current.editor_state, {
|
||||
"opt_ins": {"inline_guidance": False},
|
||||
"review_send": review,
|
||||
})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import nullcontext
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, 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,
|
||||
CampaignJob,
|
||||
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__,
|
||||
CampaignJob.__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
|
||||
|
||||
def test_autonomous_occurrences_allocate_commands_once_and_complete_bound(self):
|
||||
context = SimpleNamespace(
|
||||
snapshot=SimpleNamespace(
|
||||
mail_profile_id="profile-1",
|
||||
smtp_transport_revision="transport-1",
|
||||
smtp_server_id="smtp-1",
|
||||
smtp_credential_id="credential-1",
|
||||
),
|
||||
message_bytes=b"From: Sender <sender@example.test>\r\nTo: one@example.test\r\n\r\nHello",
|
||||
envelope_from="sender@example.test",
|
||||
envelope_recipients=["one@example.test"],
|
||||
)
|
||||
job = SimpleNamespace(
|
||||
id="job-1",
|
||||
resolved_recipients={"from": {"email": "sender@example.test"}},
|
||||
)
|
||||
mail = Mock()
|
||||
mail.durable_delivery_available = True
|
||||
mail.delivery_command_summary.return_value = {
|
||||
"id": "command-1",
|
||||
"status": "accepted",
|
||||
"accepted_count": 1,
|
||||
"refused_count": 0,
|
||||
"failure_code": None,
|
||||
}
|
||||
mail.submit_delivery_command.side_effect = [
|
||||
{"id": "command-1", "status": "pending", "duplicate": False},
|
||||
{"id": "command-2", "status": "pending", "duplicate": False},
|
||||
]
|
||||
with self.SessionLocal() as session:
|
||||
schedule = session.get(CampaignSchedule, "schedule-1")
|
||||
assert schedule is not None
|
||||
schedule.delivery_mode = "autonomous"
|
||||
schedule.approved_execution_snapshot_hash = "a" * 64
|
||||
session.commit()
|
||||
|
||||
validation = {
|
||||
"execution_snapshot_hash": "a" * 64,
|
||||
"approval_request_id": "approval-1",
|
||||
"approval_subject_digest": "b" * 64,
|
||||
"job_count": 1,
|
||||
"job_manifest_sha256": "c" * 64,
|
||||
}
|
||||
patches = (
|
||||
patch(
|
||||
"govoplan_campaign.backend.campaign.scheduling.validate_autonomous_schedule_source",
|
||||
return_value=validation,
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.campaign.scheduling._autonomous_source_jobs",
|
||||
return_value=[job],
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.campaign.scheduling._send_job_delivery_context",
|
||||
return_value=context,
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.campaign.scheduling._synchronous_smtp_batch_manager",
|
||||
return_value=nullcontext(None),
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.campaign.scheduling.mail_integration",
|
||||
return_value=mail,
|
||||
),
|
||||
patch("govoplan_campaign.backend.campaign.scheduling.audit_event"),
|
||||
)
|
||||
with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5]:
|
||||
first = dispatch_due_campaign_schedules(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
now=datetime(2026, 8, 7, 8, tzinfo=UTC),
|
||||
)
|
||||
session.commit()
|
||||
second = dispatch_due_campaign_schedules(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
now=datetime(2026, 8, 8, 8, tzinfo=UTC),
|
||||
)
|
||||
session.commit()
|
||||
|
||||
assert first["autonomous_prepared"] == 1
|
||||
assert second["autonomous_prepared"] == 1
|
||||
assert schedule.active is False
|
||||
occurrences = (
|
||||
session.query(CampaignScheduleOccurrence)
|
||||
.order_by(CampaignScheduleOccurrence.scheduled_for)
|
||||
.all()
|
||||
)
|
||||
assert [item.status for item in occurrences] == ["accepted", "prepared"]
|
||||
assert [item.delivery_command_ids for item in occurrences] == [
|
||||
["command-1"],
|
||||
["command-2"],
|
||||
]
|
||||
assert len({item.idempotency_key for item in occurrences}) == 2
|
||||
assert [item.recovery_state for item in occurrences] == [
|
||||
"complete",
|
||||
"pending",
|
||||
]
|
||||
assert occurrences[0].evidence["source_campaign_id"] == "campaign-1"
|
||||
assert occurrences[0].evidence["source_version_id"] == "version-1"
|
||||
assert (
|
||||
occurrences[0].evidence["source_snapshot_hash"]
|
||||
== schedule.source_snapshot_hash
|
||||
)
|
||||
assert mail.submit_delivery_command.call_count == 2
|
||||
|
||||
def test_autonomous_unknown_outcome_pauses_without_resubmission(self):
|
||||
mail = Mock()
|
||||
mail.durable_delivery_available = True
|
||||
mail.delivery_command_summary.return_value = {
|
||||
"id": "command-1",
|
||||
"status": "outcome_unknown",
|
||||
"accepted_count": 0,
|
||||
"refused_count": 0,
|
||||
"failure_code": "smtp_outcome_unknown",
|
||||
}
|
||||
with self.SessionLocal() as session:
|
||||
schedule = session.get(CampaignSchedule, "schedule-1")
|
||||
assert schedule is not None
|
||||
schedule.delivery_mode = "autonomous"
|
||||
occurrence = CampaignScheduleOccurrence(
|
||||
tenant_id="tenant-1",
|
||||
schedule_id=schedule.id,
|
||||
scheduled_for=datetime(2026, 8, 6, 8, tzinfo=UTC),
|
||||
status="prepared",
|
||||
idempotency_key="occurrence-1",
|
||||
delivery_command_ids=["command-1"],
|
||||
recovery_state="pending",
|
||||
)
|
||||
session.add(occurrence)
|
||||
session.commit()
|
||||
with patch(
|
||||
"govoplan_campaign.backend.campaign.scheduling.mail_integration",
|
||||
return_value=mail,
|
||||
), patch(
|
||||
"govoplan_campaign.backend.campaign.scheduling._notify_schedule_operator"
|
||||
) as notify:
|
||||
result = dispatch_due_campaign_schedules(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
now=datetime(2026, 8, 6, 9, tzinfo=UTC),
|
||||
)
|
||||
session.commit()
|
||||
|
||||
assert result["refreshed"]["uncertain"] == 1
|
||||
assert result["selected"] == 0
|
||||
assert occurrence.status == "uncertain"
|
||||
assert occurrence.recovery_state == "operator_required"
|
||||
assert schedule.active is False
|
||||
assert schedule.last_outcome == "uncertain"
|
||||
notify.assert_called_once()
|
||||
mail.submit_delivery_command.assert_not_called()
|
||||
|
||||
def test_autonomous_pending_occurrence_defers_the_next_delivery(self):
|
||||
mail = Mock()
|
||||
mail.durable_delivery_available = True
|
||||
mail.delivery_command_summary.return_value = {
|
||||
"id": "command-1",
|
||||
"status": "pending",
|
||||
"accepted_count": 0,
|
||||
"refused_count": 0,
|
||||
"failure_code": None,
|
||||
}
|
||||
with self.SessionLocal() as session:
|
||||
schedule = session.get(CampaignSchedule, "schedule-1")
|
||||
assert schedule is not None
|
||||
schedule.delivery_mode = "autonomous"
|
||||
session.add(
|
||||
CampaignScheduleOccurrence(
|
||||
tenant_id="tenant-1",
|
||||
schedule_id=schedule.id,
|
||||
scheduled_for=datetime(2026, 8, 6, 8, tzinfo=UTC),
|
||||
status="prepared",
|
||||
idempotency_key="occurrence-1",
|
||||
delivery_command_ids=["command-1"],
|
||||
recovery_state="pending",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
with patch(
|
||||
"govoplan_campaign.backend.campaign.scheduling.mail_integration",
|
||||
return_value=mail,
|
||||
):
|
||||
result = dispatch_due_campaign_schedules(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
now=datetime(2026, 8, 7, 8, tzinfo=UTC),
|
||||
)
|
||||
|
||||
assert result["refreshed"]["checked"] == 1
|
||||
assert result["deferred"] == 1
|
||||
assert result["autonomous_prepared"] == 0
|
||||
assert schedule.active is True
|
||||
assert schedule.occurrence_count == 0
|
||||
mail.submit_delivery_command.assert_not_called()
|
||||
|
||||
def test_missing_mail_recovery_capability_pauses_an_open_occurrence(self):
|
||||
mail = Mock()
|
||||
mail.durable_delivery_available = False
|
||||
with self.SessionLocal() as session:
|
||||
schedule = session.get(CampaignSchedule, "schedule-1")
|
||||
assert schedule is not None
|
||||
schedule.delivery_mode = "autonomous"
|
||||
occurrence = CampaignScheduleOccurrence(
|
||||
tenant_id="tenant-1",
|
||||
schedule_id=schedule.id,
|
||||
scheduled_for=datetime(2026, 8, 6, 8, tzinfo=UTC),
|
||||
status="prepared",
|
||||
idempotency_key="occurrence-1",
|
||||
delivery_command_ids=["command-1"],
|
||||
recovery_state="pending",
|
||||
)
|
||||
session.add(occurrence)
|
||||
session.commit()
|
||||
|
||||
with patch(
|
||||
"govoplan_campaign.backend.campaign.scheduling.mail_integration",
|
||||
return_value=mail,
|
||||
), patch(
|
||||
"govoplan_campaign.backend.campaign.scheduling._notify_schedule_operator"
|
||||
) as notify:
|
||||
result = dispatch_due_campaign_schedules(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
now=datetime(2026, 8, 7, 8, tzinfo=UTC),
|
||||
)
|
||||
|
||||
assert result["refreshed"]["uncertain"] == 1
|
||||
assert occurrence.status == "uncertain"
|
||||
assert occurrence.evidence["recovery_reason"] == (
|
||||
"mail_delivery_outbox_unavailable"
|
||||
)
|
||||
assert schedule.active is False
|
||||
notify.assert_called_once()
|
||||
|
||||
def test_autonomous_source_requires_an_explicit_approval(self):
|
||||
with self.SessionLocal() as session, patch(
|
||||
"govoplan_campaign.backend.campaign.scheduling.campaign_approval_gate",
|
||||
return_value=None,
|
||||
):
|
||||
campaign = session.get(Campaign, "campaign-1")
|
||||
version = session.get(CampaignVersion, "version-1")
|
||||
assert campaign is not None and version is not None
|
||||
from govoplan_campaign.backend.campaign.scheduling import (
|
||||
validate_autonomous_schedule_source,
|
||||
)
|
||||
|
||||
try:
|
||||
validate_autonomous_schedule_source(
|
||||
session,
|
||||
campaign=campaign,
|
||||
version=version,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
assert "explicit Approval request" in str(exc)
|
||||
else: # pragma: no cover - defensive assertion
|
||||
raise AssertionError("Autonomous source validation unexpectedly passed")
|
||||
|
||||
def test_duplicate_occurrence_recovers_schedule_without_another_effect(self):
|
||||
with self.SessionLocal() as session:
|
||||
schedule = session.get(CampaignSchedule, "schedule-1")
|
||||
assert schedule is not None
|
||||
recorded = CampaignScheduleOccurrence(
|
||||
tenant_id="tenant-1",
|
||||
schedule_id=schedule.id,
|
||||
scheduled_for=schedule.next_fire_at,
|
||||
status="prepared",
|
||||
idempotency_key="existing-key",
|
||||
recovery_state="pending",
|
||||
)
|
||||
session.add(recorded)
|
||||
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["duplicates"] == 1
|
||||
assert result["failed"] == 0
|
||||
assert schedule.occurrence_count == 1
|
||||
assert schedule.next_fire_at == datetime(2026, 8, 8, 8, tzinfo=UTC)
|
||||
assert session.query(CampaignScheduleOccurrence).count() == 1
|
||||
@@ -0,0 +1,528 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import Column, String, Table, create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from govoplan_campaign.backend.campaign.transfers import (
|
||||
DEFAULT_PORTABLE_CAMPAIGN_SCOPES,
|
||||
build_campaign_portable_package,
|
||||
canonical_sha256,
|
||||
inspect_campaign_portable_package,
|
||||
)
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignIssue,
|
||||
CampaignJob,
|
||||
CampaignShare,
|
||||
CampaignVersion,
|
||||
)
|
||||
from govoplan_campaign.backend.persistence.versions import minimal_campaign_json
|
||||
from govoplan_campaign.backend.routes.transfers import (
|
||||
export_campaign_package,
|
||||
import_campaign_package,
|
||||
preview_campaign_import,
|
||||
)
|
||||
from govoplan_campaign.backend.schemas import (
|
||||
CampaignExportRequest,
|
||||
CampaignImportApplyRequest,
|
||||
CampaignImportPreviewRequest,
|
||||
CampaignPortablePackageResponse,
|
||||
)
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
def _source() -> tuple[Campaign, CampaignVersion]:
|
||||
raw_json = minimal_campaign_json(
|
||||
external_id="monthly-notice",
|
||||
name="Monthly notice",
|
||||
description="Portable source",
|
||||
)
|
||||
raw_json["fields"] = [
|
||||
{"name": "case_id", "type": "string"},
|
||||
{"name": "private_code", "type": "password"},
|
||||
]
|
||||
raw_json["global_values"] = {
|
||||
"office": "Permits",
|
||||
"private_code": "must-not-leave-the-source",
|
||||
}
|
||||
raw_json["server"] = {
|
||||
"mail_profile_id": "mail-profile-source",
|
||||
"smtp_server_id": "smtp-source",
|
||||
"smtp_credential_id": "credential-source",
|
||||
}
|
||||
raw_json["template"] = {
|
||||
"subject": "Case {{case_id}}",
|
||||
"text": "Hello",
|
||||
"html": None,
|
||||
}
|
||||
raw_json["attachments"]["global"] = [
|
||||
{"base_dir": ".", "file_filter": "notice.pdf", "required": True}
|
||||
]
|
||||
raw_json["entries"]["inline"] = [
|
||||
{
|
||||
"id": "recipient-1",
|
||||
"to": [{"email": "person@example.test"}],
|
||||
"fields": {
|
||||
"case_id": "A-1",
|
||||
"private_code": "recipient-secret",
|
||||
},
|
||||
"attachments": [
|
||||
{"base_dir": ".", "file_filter": "A-1.pdf", "required": True}
|
||||
],
|
||||
}
|
||||
]
|
||||
campaign = Campaign(
|
||||
id="campaign-source",
|
||||
tenant_id="tenant-source",
|
||||
external_id="monthly-notice",
|
||||
name="Monthly notice",
|
||||
description="Portable source",
|
||||
status="completed",
|
||||
settings={
|
||||
"retention_days": 90,
|
||||
"provider_token": "must-not-export",
|
||||
},
|
||||
mail_profile_policy={
|
||||
"profile_id": "mail-profile-source",
|
||||
"credential_id": "credential-source",
|
||||
},
|
||||
)
|
||||
version = CampaignVersion(
|
||||
id="version-source",
|
||||
campaign_id=campaign.id,
|
||||
version_number=4,
|
||||
raw_json=raw_json,
|
||||
schema_version="1.0",
|
||||
workflow_state="completed",
|
||||
validation_summary={"ok": True, "error_count": 0},
|
||||
build_summary={"built_count": 1},
|
||||
editor_state={
|
||||
"review_send": {
|
||||
"inspection_complete": True,
|
||||
"reviewed_message_keys": ["message-1"],
|
||||
"issue_decisions": [
|
||||
{
|
||||
"decision": "accept",
|
||||
"issue_codes": ["attachment_warning"],
|
||||
"issue_fingerprint": "fingerprint-1",
|
||||
"message_sha256": "a" * 64,
|
||||
"reason": "Verified manually",
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
)
|
||||
return campaign, version
|
||||
|
||||
|
||||
def _job(campaign: Campaign, version: CampaignVersion) -> CampaignJob:
|
||||
return CampaignJob(
|
||||
id="job-1",
|
||||
tenant_id=campaign.tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
campaign_version_id=version.id,
|
||||
entry_index=0,
|
||||
entry_id="recipient-1",
|
||||
recipient_email="person@example.test",
|
||||
message_id_header="<message@example.test>",
|
||||
eml_sha256="b" * 64,
|
||||
build_status="built",
|
||||
validation_status="ready",
|
||||
queue_status="cancelled",
|
||||
send_status="smtp_accepted",
|
||||
postbox_status="not_requested",
|
||||
print_status="not_requested",
|
||||
imap_status="appended",
|
||||
attempt_count=1,
|
||||
delivery_provenance={"route": "mail", "storage_key": "hidden"},
|
||||
)
|
||||
|
||||
|
||||
def test_privacy_default_export_is_configuration_only_and_redacts_secrets() -> None:
|
||||
campaign, version = _source()
|
||||
|
||||
package = build_campaign_portable_package(
|
||||
campaign=campaign,
|
||||
version=version,
|
||||
scopes=DEFAULT_PORTABLE_CAMPAIGN_SCOPES,
|
||||
module_version="0.1.24",
|
||||
)
|
||||
|
||||
assert package["scopes"] == ["metadata", "template_config"]
|
||||
assert set(package["payload"]) == {"metadata", "template_config"}
|
||||
assert package["manifest"]["secrets_included"] is False
|
||||
assert package["manifest"]["redactions"] == {
|
||||
"deployment_credential_reference": 1,
|
||||
"password_field_value": 2,
|
||||
"sensitive_setting": 2,
|
||||
}
|
||||
template = package["payload"]["template_config"]
|
||||
assert "private_code" not in template["configuration"]["global_values"]
|
||||
assert "smtp_credential_id" not in template["configuration"]["server"]
|
||||
assert "provider_token" not in template["campaign_settings"]
|
||||
assert "credential_id" not in template["mail_profile_policy"]
|
||||
serialized = CampaignPortablePackageResponse.model_validate(package).model_dump(
|
||||
mode="json"
|
||||
)
|
||||
assert inspect_campaign_portable_package(
|
||||
serialized,
|
||||
selected_scopes=None,
|
||||
external_id="serialized-import",
|
||||
name="Serialized import",
|
||||
).preview["compatible"] is True
|
||||
|
||||
|
||||
def test_full_export_import_applies_configuration_but_never_replays_evidence() -> None:
|
||||
campaign, version = _source()
|
||||
package = build_campaign_portable_package(
|
||||
campaign=campaign,
|
||||
version=version,
|
||||
scopes=(
|
||||
"metadata",
|
||||
"template_config",
|
||||
"recipients",
|
||||
"attachments",
|
||||
"review_state",
|
||||
"delivery_history",
|
||||
),
|
||||
jobs=(_job(campaign, version),),
|
||||
issues=(
|
||||
CampaignIssue(
|
||||
id="issue-1",
|
||||
tenant_id=campaign.tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
campaign_version_id=version.id,
|
||||
severity="warning",
|
||||
code="attachment_warning",
|
||||
message="Review attachment",
|
||||
),
|
||||
),
|
||||
module_version="0.1.24",
|
||||
)
|
||||
|
||||
inspection = inspect_campaign_portable_package(
|
||||
package,
|
||||
selected_scopes=None,
|
||||
external_id="monthly-notice-import",
|
||||
name="Imported monthly notice",
|
||||
)
|
||||
|
||||
assert inspection.preview["compatible"] is True
|
||||
assert inspection.configuration is not None
|
||||
assert inspection.configuration["campaign"] == {
|
||||
"id": "monthly-notice-import",
|
||||
"name": "Imported monthly notice",
|
||||
"description": "Portable source",
|
||||
"mode": "draft",
|
||||
}
|
||||
assert inspection.configuration["server"] == {}
|
||||
assert inspection.configuration["entries"]["inline"][0]["to"] == [
|
||||
{"email": "person@example.test"}
|
||||
]
|
||||
assert inspection.configuration["entries"]["inline"][0]["attachments"][0][
|
||||
"file_filter"
|
||||
] == "A-1.pdf"
|
||||
assert "private_code" not in inspection.configuration["entries"]["inline"][0][
|
||||
"fields"
|
||||
]
|
||||
skipped_codes = {item["code"] for item in inspection.preview["will_skip"]}
|
||||
assert skipped_codes == {
|
||||
"deployment_bound_mail_profile",
|
||||
"operational_evidence_not_replayed",
|
||||
}
|
||||
assert package["payload"]["review_state"]["decision_count"] == 1
|
||||
assert package["payload"]["delivery_history"]["jobs"][0][
|
||||
"recipient_email"
|
||||
] == "person@example.test"
|
||||
assert "storage_key" not in package["payload"]["delivery_history"]["jobs"][0][
|
||||
"delivery_provenance"
|
||||
]
|
||||
|
||||
|
||||
def test_import_preview_reports_unselected_recipient_attachment_rules() -> None:
|
||||
campaign, version = _source()
|
||||
package = build_campaign_portable_package(
|
||||
campaign=campaign,
|
||||
version=version,
|
||||
scopes=("metadata", "attachments", "recipients"),
|
||||
module_version="0.1.24",
|
||||
)
|
||||
|
||||
inspection = inspect_campaign_portable_package(
|
||||
package,
|
||||
selected_scopes=("metadata", "attachments"),
|
||||
external_id="attachment-import",
|
||||
name="Attachment import",
|
||||
)
|
||||
|
||||
assert inspection.preview["compatible"] is True
|
||||
skipped = {item["code"]: item for item in inspection.preview["will_skip"]}
|
||||
assert skipped["recipient_scope_required"]["item_count"] == 1
|
||||
assert skipped["scope_not_selected"]["scope"] == "recipients"
|
||||
assert inspection.configuration is not None
|
||||
assert inspection.configuration["entries"]["inline"] == []
|
||||
|
||||
|
||||
def test_import_preview_fails_closed_when_package_is_tampered() -> None:
|
||||
campaign, version = _source()
|
||||
package = build_campaign_portable_package(
|
||||
campaign=campaign,
|
||||
version=version,
|
||||
scopes=DEFAULT_PORTABLE_CAMPAIGN_SCOPES,
|
||||
module_version="0.1.24",
|
||||
)
|
||||
tampered = copy.deepcopy(package)
|
||||
tampered["payload"]["metadata"]["name"] = "Tampered"
|
||||
|
||||
inspection = inspect_campaign_portable_package(
|
||||
tampered,
|
||||
selected_scopes=None,
|
||||
external_id="tampered-import",
|
||||
name="Tampered",
|
||||
)
|
||||
|
||||
assert inspection.preview["compatible"] is False
|
||||
assert inspection.configuration is None
|
||||
assert any("integrity checksum" in error for error in inspection.preview["errors"])
|
||||
|
||||
|
||||
def test_import_preview_rejects_unsupported_campaign_schema_even_with_valid_checksum() -> None:
|
||||
campaign, version = _source()
|
||||
package = build_campaign_portable_package(
|
||||
campaign=campaign,
|
||||
version=version,
|
||||
scopes=DEFAULT_PORTABLE_CAMPAIGN_SCOPES,
|
||||
module_version="0.1.24",
|
||||
)
|
||||
package["source"]["campaign_schema_version"] = "2.0"
|
||||
package["integrity"]["package_sha256"] = canonical_package_hash(package)
|
||||
|
||||
inspection = inspect_campaign_portable_package(
|
||||
package,
|
||||
selected_scopes=None,
|
||||
external_id="future-import",
|
||||
name="Future import",
|
||||
)
|
||||
|
||||
assert inspection.preview["compatible"] is False
|
||||
assert any("schema version" in error for error in inspection.preview["errors"])
|
||||
|
||||
|
||||
def canonical_package_hash(package: dict[str, object]) -> str:
|
||||
content = copy.deepcopy(package)
|
||||
content.pop("integrity", None)
|
||||
return canonical_sha256(content)
|
||||
|
||||
|
||||
class _Principal:
|
||||
tenant_id = "tenant-1"
|
||||
api_key = None
|
||||
|
||||
def __init__(self, *scopes: str) -> None:
|
||||
self.user = SimpleNamespace(id="user-1", display_name="Importer")
|
||||
self.scopes = frozenset(scopes)
|
||||
|
||||
def has(self, scope: str) -> bool:
|
||||
return scope in self.scopes or "tenant:*" in self.scopes
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def route_session() -> Session:
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
access_users = Base.metadata.tables.get("access_users")
|
||||
if access_users is None:
|
||||
access_users = Table(
|
||||
"access_users",
|
||||
Base.metadata,
|
||||
Column("id", String(36), primary_key=True),
|
||||
)
|
||||
access_groups = Base.metadata.tables.get("access_groups")
|
||||
if access_groups is None:
|
||||
access_groups = Table(
|
||||
"access_groups",
|
||||
Base.metadata,
|
||||
Column("id", String(36), primary_key=True),
|
||||
)
|
||||
Base.metadata.create_all(
|
||||
engine,
|
||||
tables=[
|
||||
access_users,
|
||||
access_groups,
|
||||
Campaign.__table__,
|
||||
CampaignVersion.__table__,
|
||||
CampaignShare.__table__,
|
||||
CampaignJob.__table__,
|
||||
CampaignIssue.__table__,
|
||||
ChangeSequenceEntry.__table__,
|
||||
],
|
||||
)
|
||||
session_factory = sessionmaker(bind=engine, class_=Session, expire_on_commit=False)
|
||||
database = session_factory()
|
||||
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",
|
||||
)
|
||||
database.execute(access_users.insert().values(**user_values))
|
||||
raw_json = minimal_campaign_json(external_id="source", name="Source")
|
||||
raw_json["entries"]["inline"] = [
|
||||
{"id": "one", "to": [{"email": "one@example.test"}]}
|
||||
]
|
||||
source = Campaign(
|
||||
id="source-campaign",
|
||||
tenant_id="tenant-1",
|
||||
created_by_user_id="user-1",
|
||||
owner_user_id="user-1",
|
||||
external_id="source",
|
||||
name="Source",
|
||||
status="draft",
|
||||
current_version_id="source-version",
|
||||
)
|
||||
source_version = CampaignVersion(
|
||||
id="source-version",
|
||||
campaign_id=source.id,
|
||||
version_number=1,
|
||||
raw_json=raw_json,
|
||||
)
|
||||
database.add_all((source, source_version))
|
||||
database.commit()
|
||||
try:
|
||||
yield database
|
||||
finally:
|
||||
database.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_export_route_enforces_recipient_export_scope(route_session: Session) -> None:
|
||||
principal = _Principal(
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:campaign:export",
|
||||
"campaigns:recipient:read",
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as denied:
|
||||
export_campaign_package(
|
||||
"source-campaign",
|
||||
"source-version",
|
||||
CampaignExportRequest(scopes=["metadata", "recipients"]),
|
||||
session=route_session,
|
||||
principal=principal,
|
||||
)
|
||||
|
||||
assert denied.value.status_code == 403
|
||||
assert denied.value.detail == "Missing scope: campaigns:recipient:export"
|
||||
|
||||
|
||||
def test_export_preview_and_apply_routes_keep_matching_provenance(
|
||||
route_session: Session,
|
||||
) -> None:
|
||||
exporter = _Principal(
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:campaign:export",
|
||||
"campaigns:recipient:read",
|
||||
"campaigns:recipient:export",
|
||||
)
|
||||
|
||||
def commit_audit(active_session: Session, *_args, **_kwargs) -> None:
|
||||
active_session.commit()
|
||||
|
||||
with patch(
|
||||
"govoplan_campaign.backend.routes.transfers.audit_from_principal",
|
||||
side_effect=commit_audit,
|
||||
):
|
||||
package = export_campaign_package(
|
||||
"source-campaign",
|
||||
"source-version",
|
||||
CampaignExportRequest(scopes=["metadata", "template_config", "recipients"]),
|
||||
session=route_session,
|
||||
principal=exporter,
|
||||
)
|
||||
|
||||
limited_importer = _Principal(
|
||||
"campaigns:campaign:create",
|
||||
"campaigns:campaign:import",
|
||||
)
|
||||
limited_preview = preview_campaign_import(
|
||||
CampaignImportPreviewRequest(package=package),
|
||||
session=route_session,
|
||||
principal=limited_importer,
|
||||
)
|
||||
assert limited_preview["compatible"] is True
|
||||
assert "recipients" in limited_preview["selected_scopes"]
|
||||
|
||||
importer = _Principal(
|
||||
"campaigns:campaign:create",
|
||||
"campaigns:campaign:import",
|
||||
"campaigns:recipient:write",
|
||||
"campaigns:recipient:import",
|
||||
)
|
||||
preview = preview_campaign_import(
|
||||
CampaignImportPreviewRequest(package=package),
|
||||
session=route_session,
|
||||
principal=importer,
|
||||
)
|
||||
assert preview["compatible"] is True
|
||||
assert preview["destination"]["external_id"] == "source-import"
|
||||
|
||||
def create_import(active_session: Session, **kwargs):
|
||||
raw_json = kwargs["raw_json"]
|
||||
destination = Campaign(
|
||||
id="imported-campaign",
|
||||
tenant_id="tenant-1",
|
||||
created_by_user_id="user-1",
|
||||
owner_user_id="user-1",
|
||||
external_id=raw_json["campaign"]["id"],
|
||||
name=raw_json["campaign"]["name"],
|
||||
status="draft",
|
||||
current_version_id="imported-version",
|
||||
)
|
||||
version = CampaignVersion(
|
||||
id="imported-version",
|
||||
campaign_id=destination.id,
|
||||
version_number=1,
|
||||
raw_json=raw_json,
|
||||
)
|
||||
active_session.add_all((destination, version))
|
||||
active_session.flush()
|
||||
return destination, version
|
||||
|
||||
with (
|
||||
patch(
|
||||
"govoplan_campaign.backend.routes.transfers.create_campaign_version_from_json",
|
||||
side_effect=create_import,
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.routes.transfers.audit_from_principal",
|
||||
side_effect=commit_audit,
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.routes.transfers._write_current_version_snapshot_if_available"
|
||||
),
|
||||
):
|
||||
response = import_campaign_package(
|
||||
CampaignImportApplyRequest(
|
||||
package=package,
|
||||
selected_scopes=preview["selected_scopes"],
|
||||
external_id=preview["destination"]["external_id"],
|
||||
name=preview["destination"]["name"],
|
||||
expected_package_sha256=preview["package_sha256"],
|
||||
),
|
||||
session=route_session,
|
||||
principal=importer,
|
||||
)
|
||||
|
||||
assert response.campaign.external_id == "source-import"
|
||||
assert response.receipt["package_id"] == package["package_id"]
|
||||
assert response.receipt["package_sha256"] == package["integrity"]["package_sha256"]
|
||||
imported = route_session.get(Campaign, "imported-campaign")
|
||||
assert imported is not None
|
||||
assert imported.settings["portable_import"]["package_id"] == package["package_id"]
|
||||
assert route_session.query(CampaignJob).filter_by(campaign_id=imported.id).count() == 0
|
||||
@@ -0,0 +1,731 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from govoplan_access.backend.db.models import Account, Group, User
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignShare,
|
||||
CampaignVersion,
|
||||
CampaignWorkAssignment,
|
||||
CampaignWorkAssignmentEvent,
|
||||
)
|
||||
from govoplan_campaign.backend.routes.assignments import (
|
||||
create_campaign_work_assignment,
|
||||
list_campaign_work_assignment_history,
|
||||
reassign_campaign_work_assignment,
|
||||
reconcile_campaign_work_assignments,
|
||||
transition_campaign_work_assignment,
|
||||
)
|
||||
from govoplan_campaign.backend.schemas import (
|
||||
CampaignWorkAssigneeInput,
|
||||
CampaignWorkAssignmentCreateRequest,
|
||||
CampaignWorkAssignmentReassignRequest,
|
||||
CampaignWorkAssignmentTransitionRequest,
|
||||
)
|
||||
from govoplan_campaign.backend.work_orchestration import (
|
||||
SqlCampaignWorkOrchestrationProvider,
|
||||
)
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import GroupRef, PrincipalRef, UserRef
|
||||
from govoplan_core.core.campaigns import CampaignWorkHandoffRequest
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.core.events import EventBus, event_bus_context
|
||||
from govoplan_core.core.organizations import OrganizationFunctionRef
|
||||
from govoplan_core.core.tasks import WorkItem
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
TENANT_ID = "tenant-1"
|
||||
|
||||
|
||||
class _Principal:
|
||||
tenant_id = TENANT_ID
|
||||
api_key = None
|
||||
|
||||
def __init__(self, user_id: str, account_id: str, *scopes: str) -> None:
|
||||
self.user = SimpleNamespace(
|
||||
id=user_id,
|
||||
display_name=f"User {user_id}",
|
||||
email=f"{user_id}@example.test",
|
||||
)
|
||||
self.account_id = account_id
|
||||
self.scopes = frozenset(scopes)
|
||||
|
||||
def has(self, scope: str) -> bool:
|
||||
return scope in self.scopes or "tenant:*" in self.scopes
|
||||
|
||||
|
||||
class _Directory:
|
||||
def __init__(self) -> None:
|
||||
self.user_2_active = True
|
||||
|
||||
def users_for_tenant(self, tenant_id: str):
|
||||
if tenant_id != TENANT_ID:
|
||||
return ()
|
||||
return (
|
||||
UserRef(id="user-1", account_id="account-1", tenant_id=TENANT_ID, display_name="Owner"),
|
||||
UserRef(
|
||||
id="user-2",
|
||||
account_id="account-2",
|
||||
tenant_id=TENANT_ID,
|
||||
display_name="Collaborator",
|
||||
status="active" if self.user_2_active else "inactive",
|
||||
),
|
||||
UserRef(id="user-3", account_id="account-3", tenant_id=TENANT_ID, display_name="Unrelated"),
|
||||
)
|
||||
|
||||
def groups_for_tenant(self, tenant_id: str):
|
||||
return (GroupRef(id="group-1", tenant_id=TENANT_ID, name="Campaign group"),) if tenant_id == TENANT_ID else ()
|
||||
|
||||
def groups_for_user(self, user_id: str, *, tenant_id: str):
|
||||
if tenant_id == TENANT_ID and user_id == "user-2":
|
||||
return (GroupRef(id="group-1", tenant_id=TENANT_ID, name="Campaign group"),)
|
||||
return ()
|
||||
|
||||
|
||||
class _Tasks:
|
||||
def __init__(self) -> None:
|
||||
self.commands = []
|
||||
|
||||
def create_task(self, _session, _principal, *, command):
|
||||
self.commands.append(command)
|
||||
return WorkItem(
|
||||
id=f"task-{len(self.commands)}",
|
||||
provider_id="tasks",
|
||||
owner_module="tasks",
|
||||
tenant_id=command.tenant_id,
|
||||
title=command.title,
|
||||
assignments=command.assignments,
|
||||
sources=command.sources,
|
||||
)
|
||||
|
||||
|
||||
class _Notifications:
|
||||
def __init__(self) -> None:
|
||||
self.requests = []
|
||||
|
||||
def tenant_id_for_notification(self, _session, *, notification_id: str):
|
||||
del notification_id
|
||||
return TENANT_ID
|
||||
|
||||
def enqueue_notification(self, _session, request, *, enqueue_delivery: bool = True):
|
||||
self.requests.append((request, enqueue_delivery))
|
||||
return {"id": f"notification-{len(self.requests)}"}
|
||||
|
||||
def deliver_notification(self, _session, *, notification_id: str):
|
||||
return {"id": notification_id}
|
||||
|
||||
def deliver_pending(self, _session, *, tenant_id=None, limit: int = 50):
|
||||
return {"tenant_id": tenant_id, "limit": limit}
|
||||
|
||||
|
||||
class _FailingTasks(_Tasks):
|
||||
def create_task(self, _session, _principal, *, command):
|
||||
del command
|
||||
raise RuntimeError("Tasks is temporarily unavailable")
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, tasks: _Tasks | None = None, notifications: _Notifications | None = None) -> None:
|
||||
self.tasks = tasks
|
||||
self.notifications = notifications
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return (
|
||||
(name == "tasks.commands" and self.tasks is not None)
|
||||
or (name == "notifications.dispatch" and self.notifications is not None)
|
||||
)
|
||||
|
||||
def capability(self, name: str):
|
||||
if name == "tasks.commands":
|
||||
return self.tasks
|
||||
if name == "notifications.dispatch":
|
||||
return self.notifications
|
||||
return None
|
||||
|
||||
|
||||
class _Organizations:
|
||||
def get_function(self, function_id: str):
|
||||
if function_id != "function-1":
|
||||
return None
|
||||
return OrganizationFunctionRef(
|
||||
id=function_id,
|
||||
tenant_id=TENANT_ID,
|
||||
organization_unit_id="unit-1",
|
||||
slug="campaign-review",
|
||||
name="Campaign review function",
|
||||
)
|
||||
|
||||
|
||||
class _Idm:
|
||||
def organization_function_assignments_for_function(self, function_id: str, *, tenant_id=None, effective_at=None):
|
||||
del effective_at
|
||||
if function_id == "function-1" and tenant_id == TENANT_ID:
|
||||
return (SimpleNamespace(account_id="account-2", status="active", valid_from=None, valid_until=None),)
|
||||
return ()
|
||||
|
||||
def organization_function_incumbencies(self, function_ids, *, tenant_id, effective_at=None):
|
||||
del effective_at
|
||||
return {item: SimpleNamespace(assignments=self.organization_function_assignments_for_function(item, tenant_id=tenant_id)) for item in function_ids}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def session() -> Session:
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
engine,
|
||||
tables=[
|
||||
Account.__table__,
|
||||
User.__table__,
|
||||
Group.__table__,
|
||||
ChangeSequenceEntry.__table__,
|
||||
Campaign.__table__,
|
||||
CampaignVersion.__table__,
|
||||
CampaignShare.__table__,
|
||||
CampaignWorkAssignment.__table__,
|
||||
CampaignWorkAssignmentEvent.__table__,
|
||||
],
|
||||
)
|
||||
factory = sessionmaker(bind=engine, class_=Session, expire_on_commit=False)
|
||||
database = factory()
|
||||
database.add_all(
|
||||
[
|
||||
Account(id=f"account-{number}", email=f"user-{number}@example.test", normalized_email=f"user-{number}@example.test")
|
||||
for number in range(1, 4)
|
||||
]
|
||||
)
|
||||
database.flush()
|
||||
database.add_all(
|
||||
[
|
||||
User(id=f"user-{number}", tenant_id=TENANT_ID, account_id=f"account-{number}", email=f"user-{number}@example.test")
|
||||
for number in range(1, 4)
|
||||
]
|
||||
)
|
||||
database.add(Group(id="group-1", tenant_id=TENANT_ID, slug="campaign-group", name="Campaign group"))
|
||||
campaign = Campaign(
|
||||
id="campaign-1",
|
||||
tenant_id=TENANT_ID,
|
||||
owner_user_id="user-1",
|
||||
external_id="campaign-1",
|
||||
name="Campaign One",
|
||||
current_version_id="version-1",
|
||||
)
|
||||
database.add_all(
|
||||
[
|
||||
campaign,
|
||||
CampaignVersion(id="version-1", campaign_id=campaign.id, version_number=1, raw_json={"version": "1.0"}),
|
||||
CampaignShare(
|
||||
id="share-1",
|
||||
tenant_id=TENANT_ID,
|
||||
campaign_id=campaign.id,
|
||||
target_type="group",
|
||||
target_id="group-1",
|
||||
permission="read",
|
||||
),
|
||||
]
|
||||
)
|
||||
database.commit()
|
||||
try:
|
||||
yield database
|
||||
finally:
|
||||
database.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _manager() -> _Principal:
|
||||
return _Principal(
|
||||
"user-1",
|
||||
"account-1",
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:assignment:read",
|
||||
"campaigns:assignment:manage",
|
||||
"campaigns:assignment:complete",
|
||||
)
|
||||
|
||||
|
||||
def _assignee() -> _Principal:
|
||||
return _Principal(
|
||||
"user-2",
|
||||
"account-2",
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:assignment:read",
|
||||
"campaigns:assignment:complete",
|
||||
)
|
||||
|
||||
|
||||
def _api_principal(user_id: str, account_id: str) -> ApiPrincipal:
|
||||
return ApiPrincipal(
|
||||
principal=PrincipalRef(
|
||||
account_id=account_id,
|
||||
membership_id=user_id,
|
||||
tenant_id=TENANT_ID,
|
||||
scopes=frozenset(
|
||||
{
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:campaign:create",
|
||||
"campaigns:assignment:read",
|
||||
"campaigns:assignment:manage",
|
||||
"campaigns:assignment:complete",
|
||||
}
|
||||
),
|
||||
),
|
||||
account=SimpleNamespace(id=account_id),
|
||||
user=SimpleNamespace(
|
||||
id=user_id,
|
||||
display_name=f"User {user_id}",
|
||||
email=f"{user_id}@example.test",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _commit_audit(session: Session, *_args, **_kwargs) -> None:
|
||||
session.commit()
|
||||
|
||||
|
||||
def test_assignment_is_authorization_neutral_and_mirrors_through_optional_tasks(session: Session) -> None:
|
||||
directory = _Directory()
|
||||
tasks = _Tasks()
|
||||
notifications = _Notifications()
|
||||
with (
|
||||
patch("govoplan_campaign.backend.routes.assignments._access_directory", return_value=directory),
|
||||
patch("govoplan_campaign.backend.routes.assignments.get_registry", return_value=_Registry(tasks, notifications)),
|
||||
patch("govoplan_campaign.backend.routes.assignments.audit_from_principal", side_effect=_commit_audit),
|
||||
):
|
||||
response = create_campaign_work_assignment(
|
||||
"campaign-1",
|
||||
CampaignWorkAssignmentCreateRequest(
|
||||
purpose="Review the frozen recipient import",
|
||||
assignee=CampaignWorkAssigneeInput(type="account", id="account-2"),
|
||||
reference={"kind": "campaign_version", "id": "version-1"},
|
||||
),
|
||||
session,
|
||||
_manager(),
|
||||
)
|
||||
|
||||
assert response.assignee_label_snapshot == "Collaborator"
|
||||
assert response.assignee_resolution_state == "resolved"
|
||||
assert response.resolution_provenance["policy_code"] == "assignment_does_not_grant_access"
|
||||
assert response.task_mirror_id == "task-1"
|
||||
assert tasks.commands[0].assignments[0].kind == "account"
|
||||
assert tasks.commands[0].provenance["authorization_neutral"] is True
|
||||
assert notifications.requests[0][0].recipient_id == "account-2"
|
||||
assert notifications.requests[0][0].payload["purpose_disclosed"] is False
|
||||
assert notifications.requests[0][1] is False
|
||||
assert session.query(CampaignShare).count() == 1
|
||||
assert [item.event_kind for item in session.query(CampaignWorkAssignmentEvent).all()] == ["assigned"]
|
||||
|
||||
|
||||
def test_assignment_rejects_target_without_existing_campaign_access(session: Session) -> None:
|
||||
with patch("govoplan_campaign.backend.routes.assignments._access_directory", return_value=_Directory()):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
create_campaign_work_assignment(
|
||||
"campaign-1",
|
||||
CampaignWorkAssignmentCreateRequest(
|
||||
purpose="Should not grant access",
|
||||
assignee=CampaignWorkAssigneeInput(type="account", id="account-3"),
|
||||
),
|
||||
session,
|
||||
_manager(),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 422
|
||||
assert exc_info.value.detail["code"] == "campaign_assignment_assignee_inaccessible"
|
||||
assert session.query(CampaignWorkAssignment).count() == 0
|
||||
assert session.query(CampaignShare).count() == 1
|
||||
|
||||
|
||||
def test_optional_tasks_failure_is_recorded_without_blocking_campaign_work(session: Session) -> None:
|
||||
with (
|
||||
patch("govoplan_campaign.backend.routes.assignments._access_directory", return_value=_Directory()),
|
||||
patch("govoplan_campaign.backend.routes.assignments.get_registry", return_value=_Registry(_FailingTasks())),
|
||||
patch("govoplan_campaign.backend.routes.assignments.audit_from_principal", side_effect=_commit_audit),
|
||||
):
|
||||
created = create_campaign_work_assignment(
|
||||
"campaign-1",
|
||||
CampaignWorkAssignmentCreateRequest(
|
||||
purpose="Continue even without Tasks",
|
||||
assignee=CampaignWorkAssigneeInput(type="account", id="account-2"),
|
||||
),
|
||||
session,
|
||||
_manager(),
|
||||
)
|
||||
|
||||
assert created.task_mirror_status == "failed"
|
||||
assert "RuntimeError" in (created.task_mirror_error or "")
|
||||
assert session.get(CampaignWorkAssignment, created.id) is not None
|
||||
|
||||
|
||||
def test_assignee_can_start_and_complete_but_not_cancel(session: Session) -> None:
|
||||
directory = _Directory()
|
||||
with (
|
||||
patch("govoplan_campaign.backend.routes.assignments._access_directory", return_value=directory),
|
||||
patch("govoplan_campaign.backend.route_support._access_directory", return_value=directory),
|
||||
patch("govoplan_campaign.backend.routes.assignments.audit_from_principal", side_effect=_commit_audit),
|
||||
):
|
||||
created = create_campaign_work_assignment(
|
||||
"campaign-1",
|
||||
CampaignWorkAssignmentCreateRequest(
|
||||
purpose="Review campaign",
|
||||
assignee=CampaignWorkAssigneeInput(type="account", id="account-2"),
|
||||
mirror_to_tasks=False,
|
||||
),
|
||||
session,
|
||||
_manager(),
|
||||
)
|
||||
started = transition_campaign_work_assignment(
|
||||
"campaign-1",
|
||||
created.id,
|
||||
CampaignWorkAssignmentTransitionRequest(expected_revision=1, action="start"),
|
||||
session,
|
||||
_assignee(),
|
||||
)
|
||||
completed = transition_campaign_work_assignment(
|
||||
"campaign-1",
|
||||
created.id,
|
||||
CampaignWorkAssignmentTransitionRequest(expected_revision=2, action="complete"),
|
||||
session,
|
||||
_assignee(),
|
||||
)
|
||||
|
||||
assert started.status == "in_progress"
|
||||
assert completed.status == "completed"
|
||||
assert completed.resource_revision == 3
|
||||
assert [item.event_kind for item in session.query(CampaignWorkAssignmentEvent).order_by(CampaignWorkAssignmentEvent.created_at, CampaignWorkAssignmentEvent.id)] == ["assigned", "started", "completed"]
|
||||
|
||||
|
||||
def test_reassignment_and_deactivation_reconciliation_preserve_history(session: Session) -> None:
|
||||
directory = _Directory()
|
||||
with (
|
||||
patch("govoplan_campaign.backend.routes.assignments._access_directory", return_value=directory),
|
||||
patch("govoplan_campaign.backend.routes.assignments.audit_from_principal", side_effect=_commit_audit),
|
||||
):
|
||||
created = create_campaign_work_assignment(
|
||||
"campaign-1",
|
||||
CampaignWorkAssignmentCreateRequest(
|
||||
purpose="Coordinate delivery",
|
||||
assignee=CampaignWorkAssigneeInput(type="group", id="group-1"),
|
||||
mirror_to_tasks=False,
|
||||
),
|
||||
session,
|
||||
_manager(),
|
||||
)
|
||||
reassigned = reassign_campaign_work_assignment(
|
||||
"campaign-1",
|
||||
created.id,
|
||||
CampaignWorkAssignmentReassignRequest(
|
||||
expected_revision=1,
|
||||
assignee=CampaignWorkAssigneeInput(type="account", id="account-2"),
|
||||
reason="Named accountability is now required.",
|
||||
mirror_to_tasks=False,
|
||||
),
|
||||
session,
|
||||
_manager(),
|
||||
)
|
||||
directory.user_2_active = False
|
||||
result = reconcile_campaign_work_assignments("campaign-1", 100, session, _manager())
|
||||
history = list_campaign_work_assignment_history("campaign-1", created.id, 50, None, session, _manager())
|
||||
|
||||
assert reassigned.assignee_type == "account"
|
||||
assert result.changed == 1
|
||||
assert result.assignments[0].assignee_resolution_state == "unavailable"
|
||||
assert [item.event_kind for item in reversed(history.items)] == [
|
||||
"assigned",
|
||||
"reassigned",
|
||||
"assignee_unavailable",
|
||||
]
|
||||
assert history.items[1].details["assignee_id"] == "group-1"
|
||||
|
||||
|
||||
def test_workflow_provider_is_idempotent_emits_typed_events_and_rechecks_access(
|
||||
session: Session,
|
||||
) -> None:
|
||||
directory = _Directory()
|
||||
registry = _Registry(_Tasks(), _Notifications())
|
||||
provider = SqlCampaignWorkOrchestrationProvider(registry=registry)
|
||||
manager = _api_principal("user-1", "account-1")
|
||||
assignee = _api_principal("user-2", "account-2")
|
||||
request = CampaignWorkHandoffRequest(
|
||||
tenant_id=TENANT_ID,
|
||||
campaign_id="campaign-1",
|
||||
expected_campaign_revision=1,
|
||||
idempotency_key="workflow-handoff-1",
|
||||
purpose="Review the Campaign evidence",
|
||||
assignee_kind="account",
|
||||
assignee_id="account-2",
|
||||
correlation_id="workflow-correlation-1",
|
||||
workflow_instance_id="workflow-instance-1",
|
||||
workflow_step_id="workflow-step-1",
|
||||
)
|
||||
bus = EventBus()
|
||||
events = []
|
||||
bus.subscribe("campaign.work.changed", events.append)
|
||||
with (
|
||||
patch(
|
||||
"govoplan_campaign.backend.routes.assignments._access_directory",
|
||||
return_value=directory,
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.route_support._access_directory",
|
||||
return_value=directory,
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.routes.assignments.get_registry",
|
||||
return_value=registry,
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.work_orchestration.audit_from_principal",
|
||||
return_value=SimpleNamespace(id="audit-workflow-1"),
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.routes.assignments.audit_from_principal",
|
||||
side_effect=_commit_audit,
|
||||
),
|
||||
event_bus_context(bus),
|
||||
):
|
||||
created = provider.prepare_handoff(session, manager, request=request)
|
||||
session.commit()
|
||||
replayed = provider.prepare_handoff(session, manager, request=request)
|
||||
accepted = transition_campaign_work_assignment(
|
||||
"campaign-1",
|
||||
created.assignment_id,
|
||||
CampaignWorkAssignmentTransitionRequest(
|
||||
expected_revision=1,
|
||||
action="accept",
|
||||
),
|
||||
session,
|
||||
assignee,
|
||||
)
|
||||
completed = transition_campaign_work_assignment(
|
||||
"campaign-1",
|
||||
created.assignment_id,
|
||||
CampaignWorkAssignmentTransitionRequest(
|
||||
expected_revision=2,
|
||||
action="complete",
|
||||
),
|
||||
session,
|
||||
assignee,
|
||||
)
|
||||
|
||||
assert created.replayed is False
|
||||
assert replayed.replayed is True
|
||||
assert created.assignment_id == replayed.assignment_id
|
||||
assert created.campaign_ref == "campaign:campaign-1:version:version-1:r1"
|
||||
assert created.assignment_ref.endswith(":r1")
|
||||
assert created.optional_capabilities == {"tasks": True, "notifications": True}
|
||||
assert session.query(CampaignWorkAssignment).filter(
|
||||
CampaignWorkAssignment.orchestration_idempotency_key
|
||||
== "workflow-handoff-1"
|
||||
).count() == 1
|
||||
assert accepted.status == "in_progress"
|
||||
assert completed.status == "completed"
|
||||
assert [event.payload["outcome"] for event in events] == [
|
||||
"assigned",
|
||||
"accepted",
|
||||
"completed",
|
||||
]
|
||||
assert [event.payload["assignment_revision"] for event in events] == [1, 2, 3]
|
||||
assert all(event.correlation_id == "workflow-correlation-1" for event in events)
|
||||
|
||||
with patch(
|
||||
"govoplan_campaign.backend.route_support._access_directory",
|
||||
return_value=directory,
|
||||
):
|
||||
allowed = provider.inspect_handoff(
|
||||
session,
|
||||
assignee,
|
||||
tenant_id=TENANT_ID,
|
||||
assignment_id=created.assignment_id,
|
||||
expected_revision=3,
|
||||
)
|
||||
share = session.get(CampaignShare, "share-1")
|
||||
assert share is not None
|
||||
share.revoked_at = completed.updated_at
|
||||
session.flush()
|
||||
revoked = provider.inspect_handoff(
|
||||
session,
|
||||
assignee,
|
||||
tenant_id=TENANT_ID,
|
||||
assignment_id=created.assignment_id,
|
||||
expected_revision=3,
|
||||
)
|
||||
|
||||
assert allowed.allowed is True
|
||||
assert revoked.allowed is False
|
||||
assert revoked.provenance["code"] == "campaign_handoff_access_revoked"
|
||||
|
||||
|
||||
def test_workflow_provider_can_create_self_assigned_campaign_and_rejects_stale_revision(
|
||||
session: Session,
|
||||
) -> None:
|
||||
directory = _Directory()
|
||||
registry = _Registry()
|
||||
provider = SqlCampaignWorkOrchestrationProvider(registry=registry)
|
||||
manager = _api_principal("user-1", "account-1")
|
||||
with (
|
||||
patch(
|
||||
"govoplan_campaign.backend.routes.assignments._access_directory",
|
||||
return_value=directory,
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.routes.assignments.get_registry",
|
||||
return_value=registry,
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.work_orchestration.audit_from_principal",
|
||||
return_value=SimpleNamespace(id="audit-workflow-create"),
|
||||
),
|
||||
):
|
||||
created = provider.prepare_handoff(
|
||||
session,
|
||||
manager,
|
||||
request=CampaignWorkHandoffRequest(
|
||||
tenant_id=TENANT_ID,
|
||||
create_external_id="workflow-created",
|
||||
create_name="Workflow-created Campaign",
|
||||
idempotency_key="workflow-create-1",
|
||||
purpose="Prepare the Campaign",
|
||||
assignee_kind="account",
|
||||
assignee_id="account-1",
|
||||
workflow_instance_id="workflow-instance-create",
|
||||
workflow_step_id="workflow-step-create",
|
||||
),
|
||||
)
|
||||
|
||||
assert session.get(Campaign, created.campaign_id).external_id == "workflow-created"
|
||||
version = session.get(CampaignVersion, "version-1")
|
||||
assert version is not None
|
||||
version.edit_revision = 2
|
||||
with pytest.raises(ValueError, match="Campaign revision changed"):
|
||||
provider.prepare_handoff(
|
||||
session,
|
||||
manager,
|
||||
request=CampaignWorkHandoffRequest(
|
||||
tenant_id=TENANT_ID,
|
||||
campaign_id="campaign-1",
|
||||
expected_campaign_revision=1,
|
||||
idempotency_key="workflow-stale-1",
|
||||
purpose="Review stale Campaign",
|
||||
assignee_kind="account",
|
||||
assignee_id="account-1",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_organization_function_requires_authorized_current_incumbencies(session: Session) -> None:
|
||||
with (
|
||||
patch("govoplan_campaign.backend.routes.assignments._access_directory", return_value=_Directory()),
|
||||
patch("govoplan_campaign.backend.routes.assignments.organization_directory", return_value=_Organizations()),
|
||||
patch("govoplan_campaign.backend.routes.assignments._idm_function_directory", return_value=_Idm()),
|
||||
patch("govoplan_campaign.backend.routes.assignments.audit_from_principal", side_effect=_commit_audit),
|
||||
):
|
||||
created = create_campaign_work_assignment(
|
||||
"campaign-1",
|
||||
CampaignWorkAssignmentCreateRequest(
|
||||
purpose="Approve the recipient segment",
|
||||
assignee=CampaignWorkAssigneeInput(type="organization_function", id="function-1"),
|
||||
mirror_to_tasks=False,
|
||||
),
|
||||
session,
|
||||
_manager(),
|
||||
)
|
||||
|
||||
assert created.assignee_label_snapshot == "Campaign review function"
|
||||
assert created.resolution_provenance["resolved_members"] == 1
|
||||
assert created.resolution_provenance["all_current_incumbents_authorized"] is True
|
||||
|
||||
|
||||
def test_assignment_migration_is_repeatable_and_creates_history_indexes() -> None:
|
||||
migration = importlib.import_module(
|
||||
"govoplan_campaign.backend.migrations.versions."
|
||||
"d8e9f0a1b2c3_v0121_campaign_work_assignments"
|
||||
)
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text("CREATE TABLE access_users (id VARCHAR(36) PRIMARY KEY)"))
|
||||
connection.execute(text("CREATE TABLE campaigns (id VARCHAR(36) PRIMARY KEY)"))
|
||||
connection.execute(text("CREATE TABLE campaign_versions (id VARCHAR(36) PRIMARY KEY, campaign_id VARCHAR(36) NOT NULL)"))
|
||||
context = MigrationContext.configure(connection)
|
||||
with patch.object(migration, "op", Operations(context)):
|
||||
migration.upgrade()
|
||||
migration.upgrade()
|
||||
|
||||
inspector = inspect(connection)
|
||||
assignment_columns = {item["name"] for item in inspector.get_columns("campaign_work_assignments")}
|
||||
assignment_indexes = {item["name"] for item in inspector.get_indexes("campaign_work_assignments")}
|
||||
event_indexes = {item["name"] for item in inspector.get_indexes("campaign_work_assignment_events")}
|
||||
assert {
|
||||
"purpose",
|
||||
"assignee_type",
|
||||
"assignee_id",
|
||||
"assignee_label_snapshot",
|
||||
"assignee_resolution_state",
|
||||
"resolution_provenance",
|
||||
"task_mirror_status",
|
||||
"resource_revision",
|
||||
}.issubset(assignment_columns)
|
||||
assert "ix_campaign_work_assignments_campaign_status" in assignment_indexes
|
||||
assert "ix_campaign_work_assignment_events_history" in event_indexes
|
||||
|
||||
with patch.object(migration, "op", Operations(context)):
|
||||
migration.downgrade()
|
||||
assert not inspect(connection).has_table("campaign_work_assignment_events")
|
||||
assert not inspect(connection).has_table("campaign_work_assignments")
|
||||
|
||||
|
||||
def test_workflow_orchestration_migration_is_repeatable() -> None:
|
||||
assignments = importlib.import_module(
|
||||
"govoplan_campaign.backend.migrations.versions."
|
||||
"d8e9f0a1b2c3_v0121_campaign_work_assignments"
|
||||
)
|
||||
orchestration = importlib.import_module(
|
||||
"govoplan_campaign.backend.migrations.versions."
|
||||
"f3c7a9d2e6b1_v0123_campaign_work_orchestration"
|
||||
)
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text("CREATE TABLE access_users (id VARCHAR(36) PRIMARY KEY)"))
|
||||
connection.execute(text("CREATE TABLE campaigns (id VARCHAR(36) PRIMARY KEY)"))
|
||||
connection.execute(text("CREATE TABLE campaign_versions (id VARCHAR(36) PRIMARY KEY, campaign_id VARCHAR(36) NOT NULL)"))
|
||||
context = MigrationContext.configure(connection)
|
||||
with patch.object(assignments, "op", Operations(context)):
|
||||
assignments.upgrade()
|
||||
with patch.object(orchestration, "op", Operations(context)):
|
||||
orchestration.upgrade()
|
||||
orchestration.upgrade()
|
||||
|
||||
inspector = inspect(connection)
|
||||
columns = {
|
||||
item["name"]
|
||||
for item in inspector.get_columns("campaign_work_assignments")
|
||||
}
|
||||
indexes = {
|
||||
item["name"]
|
||||
for item in inspector.get_indexes("campaign_work_assignments")
|
||||
}
|
||||
assert {
|
||||
"orchestration_idempotency_key",
|
||||
"orchestration_request_sha256",
|
||||
"orchestration_correlation_id",
|
||||
"workflow_instance_id",
|
||||
"workflow_step_id",
|
||||
}.issubset(columns)
|
||||
assert "uq_campaign_work_assignment_orchestration_key" in indexes
|
||||
|
||||
with patch.object(orchestration, "op", Operations(context)):
|
||||
orchestration.downgrade()
|
||||
assert "workflow_instance_id" not in {
|
||||
item["name"]
|
||||
for item in inspect(connection).get_columns(
|
||||
"campaign_work_assignments"
|
||||
)
|
||||
}
|
||||
@@ -223,8 +223,9 @@ def test_queue_projection_fails_closed_if_no_task_was_published() -> None:
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("duplicate_mutates", [False, True])
|
||||
def test_redelivery_orchestration_requires_same_task_and_no_second_smtp_effect(
|
||||
monkeypatch,
|
||||
monkeypatch, duplicate_mutates,
|
||||
) -> None:
|
||||
first_worker = mock.Mock()
|
||||
first_worker.received_task_ids.return_value = (TASK_ID,)
|
||||
@@ -232,22 +233,29 @@ def test_redelivery_orchestration_requires_same_task_and_no_second_smtp_effect(
|
||||
replacement_worker.received_task_ids.return_value = (TASK_ID,)
|
||||
workers = iter([first_worker, replacement_worker])
|
||||
endpoint = _Endpoint()
|
||||
durable_states = iter(
|
||||
[
|
||||
{
|
||||
"job_count": 1,
|
||||
"send_status_counts": {"sending": 1},
|
||||
"attempt_status_counts": {"smtp_in_progress": 1},
|
||||
"unfinished_attempt_count": 1,
|
||||
},
|
||||
{
|
||||
"job_count": 1,
|
||||
"send_status_counts": {"outcome_unknown": 1},
|
||||
"attempt_status_counts": {"outcome_unknown": 1},
|
||||
"unfinished_attempt_count": 0,
|
||||
},
|
||||
]
|
||||
)
|
||||
durable_states = [
|
||||
{
|
||||
"job_count": 1,
|
||||
"send_status_counts": {"sending": 1},
|
||||
"attempt_status_counts": {"smtp_in_progress": 1},
|
||||
"unfinished_attempt_count": 1,
|
||||
},
|
||||
{
|
||||
"job_count": 1,
|
||||
"send_status_counts": {"sending": 1},
|
||||
"attempt_status_counts": {"smtp_in_progress": 1},
|
||||
"unfinished_attempt_count": 1,
|
||||
},
|
||||
{
|
||||
"job_count": 1,
|
||||
"send_status_counts": {"outcome_unknown": 1},
|
||||
"attempt_status_counts": {"outcome_unknown": 1},
|
||||
"unfinished_attempt_count": 0,
|
||||
},
|
||||
]
|
||||
if duplicate_mutates:
|
||||
durable_states[1] = durable_states[2]
|
||||
durable_states = iter(durable_states)
|
||||
prepared = SimpleNamespace(
|
||||
campaign_id="campaign-internal",
|
||||
version_id="version-internal",
|
||||
@@ -277,24 +285,34 @@ def test_redelivery_orchestration_requires_same_task_and_no_second_smtp_effect(
|
||||
"_wait_for_broker_drained",
|
||||
lambda *args, **kwargs: runner.RedisBrokerState(0, 0, 0),
|
||||
)
|
||||
recover_claim = mock.Mock(return_value={"explicit_fenced_recovery": True})
|
||||
|
||||
evidence = runner.execute_redelivery_scenario(
|
||||
_Client(),
|
||||
{"Authorization": "not-retained"},
|
||||
fixture_path=FIXTURE_PATH,
|
||||
settings=_settings(),
|
||||
endpoint=endpoint,
|
||||
redis_url="redis://127.0.0.1:36379/0",
|
||||
runtime_root=Path("/not-used"),
|
||||
snapshot_probe=lambda _version_id: ({}, {}),
|
||||
audit_probe=lambda _campaign_id, _version_id: {
|
||||
"campaign.created": 1,
|
||||
"campaign.validated": 1,
|
||||
"campaign.messages_built": 1,
|
||||
"campaign.queued": 1,
|
||||
},
|
||||
delivery_probe=lambda _campaign_id, _version_id: next(durable_states),
|
||||
)
|
||||
def execute():
|
||||
return runner.execute_redelivery_scenario(
|
||||
_Client(),
|
||||
{"Authorization": "not-retained"},
|
||||
fixture_path=FIXTURE_PATH,
|
||||
settings=_settings(),
|
||||
endpoint=endpoint,
|
||||
redis_url="redis://127.0.0.1:36379/0",
|
||||
runtime_root=Path("/not-used"),
|
||||
snapshot_probe=lambda _version_id: ({}, {}),
|
||||
audit_probe=lambda _campaign_id, _version_id: {
|
||||
"campaign.created": 1,
|
||||
"campaign.validated": 1,
|
||||
"campaign.messages_built": 1,
|
||||
"campaign.queued": 1,
|
||||
},
|
||||
delivery_probe=lambda _campaign_id, _version_id: next(durable_states),
|
||||
recover_claim=recover_claim,
|
||||
)
|
||||
|
||||
if duplicate_mutates:
|
||||
with pytest.raises(runner.AcceptanceError, match="without stopped-runtime proof"):
|
||||
execute()
|
||||
recover_claim.assert_not_called()
|
||||
return
|
||||
evidence = execute()
|
||||
|
||||
assert evidence["broker"] == {
|
||||
"transport": "redis",
|
||||
@@ -310,5 +328,8 @@ def test_redelivery_orchestration_requires_same_task_and_no_second_smtp_effect(
|
||||
assert evidence["recovered_durable_state"]["send_status_counts"] == {
|
||||
"outcome_unknown": 1
|
||||
}
|
||||
assert evidence["redelivered_durable_state"] == evidence["interrupted_durable_state"]
|
||||
assert evidence["supervision"]["duplicate_task_left_sending_unchanged"] is True
|
||||
recover_claim.assert_called_once_with("campaign-internal", "version-internal", first_worker.process)
|
||||
assert TASK_ID not in json.dumps(evidence, sort_keys=True)
|
||||
assert endpoint.release_count >= 1
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.admin.models import SystemSettings
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.core.configuration_safety import classify_configuration_field, plan_configuration_change
|
||||
from govoplan_core.tenancy.scope import Tenant
|
||||
from govoplan_campaign.backend.delivery_policy import SYNCHRONOUS_SEND_MAX_ENV, effective_synchronous_send_policy
|
||||
from govoplan_campaign.backend.routes import delivery_settings as routes
|
||||
from govoplan_campaign.backend.router import router as campaign_router
|
||||
|
||||
|
||||
def principal(*scopes, tenant="tenant-a"):
|
||||
actor = SimpleNamespace(id="admin-1")
|
||||
return ApiPrincipal(principal=PrincipalRef(account_id=actor.id, membership_id=actor.id, tenant_id=tenant, scopes=frozenset(scopes)), user=actor, account=actor)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def policy(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv(SYNCHRONOUS_SEND_MAX_ENV, raising=False)
|
||||
engine = create_engine(f"sqlite+pysqlite:///{tmp_path / 'policy.db'}")
|
||||
for table in (SystemSettings.__table__, Tenant.__table__, ChangeSequenceEntry.__table__):
|
||||
table.create(engine)
|
||||
with Session(engine) as session:
|
||||
session.add_all([
|
||||
SystemSettings(id="global", settings={"unrelated": {"enabled": True}}),
|
||||
Tenant(id="tenant-a", slug="a", name="A", settings={"unrelated": "preserve"}),
|
||||
Tenant(id="tenant-b", slug="b", name="B", settings={}),
|
||||
])
|
||||
session.commit()
|
||||
with patch.object(routes, "audit_from_principal", autospec=True) as audit:
|
||||
yield SimpleNamespace(session=session, engine=engine, audit=audit,
|
||||
admin=principal("system:settings:read", "system:settings:write", "admin:policies:read", "admin:policies:write"))
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def state(policy, scope="system", actor=None):
|
||||
return routes.read_delivery_policy(scope, session=policy.session, principal=actor or policy.admin)
|
||||
|
||||
|
||||
def save(policy, value, scope="system", revision=None, actor=None):
|
||||
payload = routes.DeliveryPolicyUpdate(synchronous_send_max_recipients=value, expected_revision=revision or state(policy, scope)["revision"])
|
||||
return routes.update_delivery_policy(scope, payload, session=policy.session, principal=actor or policy.admin)
|
||||
|
||||
|
||||
def test_system_admin_can_raise_implicit_default_and_save_is_durable_audited_and_scoped(policy):
|
||||
before = state(policy)
|
||||
assert before["effective_max_recipients"] == 25 and before["max_configurable_recipients"] == 500
|
||||
result = save(policy, 200)
|
||||
assert result["effective_max_recipients"] == 200 and result["revision"] != before["revision"]
|
||||
with Session(policy.engine) as fresh:
|
||||
assert effective_synchronous_send_policy(fresh, tenant_id="tenant-a", environ={}).max_recipient_jobs == 200
|
||||
system = fresh.get(SystemSettings, "global")
|
||||
assert system.settings["unrelated"] == {"enabled": True}
|
||||
history = system.settings["_configuration_control"]["history"]
|
||||
assert len(history) == 1 and history[0]["key"] == "campaign_delivery_policy.system"
|
||||
assert history[0]["before"]["synchronous_send_max_recipients"] is None
|
||||
assert history[0]["after"]["synchronous_send_max_recipients"] == 200
|
||||
assert history[0]["actor_user_id"] == "admin-1"
|
||||
assert policy.audit.call_args.kwargs["commit"] is False
|
||||
assert policy.audit.call_args.kwargs["scope"] == "system"
|
||||
|
||||
|
||||
def test_tenant_can_only_narrow_and_reset_inherits_without_affecting_other_tenant(policy):
|
||||
save(policy, 200)
|
||||
with pytest.raises(HTTPException) as failure:
|
||||
save(policy, 201, "tenant")
|
||||
assert failure.value.status_code == 422
|
||||
save(policy, 183, "tenant")
|
||||
assert effective_synchronous_send_policy(policy.session, tenant_id="tenant-a").max_recipient_jobs == 183
|
||||
assert effective_synchronous_send_policy(policy.session, tenant_id="tenant-b").max_recipient_jobs == 200
|
||||
assert policy.session.get(Tenant, "tenant-a").settings["unrelated"] == "preserve"
|
||||
assert save(policy, None, "tenant")["effective_max_recipients"] == 200
|
||||
assert save(policy, None)["effective_max_recipients"] == 25
|
||||
|
||||
|
||||
def test_explicit_deployment_ceiling_remains_authoritative_and_zero_disables(policy, monkeypatch):
|
||||
save(policy, 200)
|
||||
monkeypatch.setenv(SYNCHRONOUS_SEND_MAX_ENV, "40")
|
||||
assert state(policy)["effective_max_recipients"] == 40
|
||||
with pytest.raises(HTTPException) as failure:
|
||||
save(policy, 41)
|
||||
assert failure.value.status_code == 422
|
||||
assert save(policy, None)["effective_max_recipients"] == 40
|
||||
assert save(policy, 0)["effective_max_recipients"] == 0
|
||||
assert state(policy, "tenant")["max_configurable_recipients"] == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("blank", ["", " ", "\t\n"])
|
||||
def test_blank_deployment_value_never_turns_default_into_absolute_maximum(policy, monkeypatch, blank):
|
||||
monkeypatch.setenv(SYNCHRONOUS_SEND_MAX_ENV, blank)
|
||||
assert state(policy)["effective_max_recipients"] == 25
|
||||
assert state(policy)["deployment_ceiling_explicit"] is False
|
||||
save(policy, 200)
|
||||
assert state(policy)["effective_max_recipients"] == 200
|
||||
save(policy, 183, "tenant")
|
||||
assert state(policy, "tenant")["effective_max_recipients"] == 183
|
||||
|
||||
|
||||
def test_stale_parent_own_or_aba_revision_never_overwrites_policy(policy):
|
||||
initial = state(policy)
|
||||
tenant = state(policy, "tenant")
|
||||
save(policy, 200)
|
||||
for scope, revision in (("system", initial["revision"]), ("tenant", tenant["revision"])):
|
||||
with pytest.raises(HTTPException) as failure:
|
||||
save(policy, 10, scope, revision=revision)
|
||||
assert failure.value.status_code == 409
|
||||
save(policy, None)
|
||||
with pytest.raises(HTTPException) as failure:
|
||||
save(policy, 30, revision=initial["revision"])
|
||||
assert failure.value.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scope,scopes,operation", [
|
||||
("system", ("admin:policies:read", "admin:policies:write"), "read"),
|
||||
("system", ("admin:policies:write",), "write"),
|
||||
("tenant", ("system:settings:read", "system:settings:write"), "read"),
|
||||
("tenant", ("system:settings:write",), "write"),
|
||||
("system", ("system:settings:read",), "write"),
|
||||
("tenant", ("admin:policies:read",), "write"),
|
||||
])
|
||||
def test_permissions_cannot_cross_scope_or_use_read_permission_to_write(policy, scope, scopes, operation):
|
||||
actor = principal(*scopes)
|
||||
with pytest.raises(HTTPException) as failure:
|
||||
if operation == "read": state(policy, scope, actor=actor)
|
||||
else: save(policy, 10, scope, actor=actor)
|
||||
assert failure.value.status_code == 403
|
||||
assert "_configuration_control" not in policy.session.get(SystemSettings, "global").settings
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [True, 1.5, "20", -1, 501])
|
||||
def test_schema_rejects_coercions_and_unbounded_values(value):
|
||||
with pytest.raises(ValidationError):
|
||||
routes.DeliveryPolicyUpdate(synchronous_send_max_recipients=value, expected_revision="a" * 64)
|
||||
|
||||
|
||||
def test_route_rolls_back_configuration_and_history_if_audit_fails(policy):
|
||||
policy.audit.side_effect = RuntimeError("Audit unavailable")
|
||||
with pytest.raises(RuntimeError, match="Audit unavailable"):
|
||||
save(policy, 200)
|
||||
policy.session.expire_all()
|
||||
assert policy.session.get(SystemSettings, "global").settings == {"unrelated": {"enabled": True}}
|
||||
|
||||
|
||||
def test_configuration_catalog_supports_only_known_scope_keys_and_scopes():
|
||||
for scope, permission in (("system", "system:settings:write"), ("tenant", "admin:policies:write")):
|
||||
key = f"campaign_delivery_policy.{scope}"
|
||||
field = classify_configuration_field(key)
|
||||
assert field.owner_module == "campaigns" and field.rollback_history_required
|
||||
assert plan_configuration_change(key, actor_scopes=(permission,), value={"synchronous_send_max_recipients": 200}).allowed
|
||||
assert not plan_configuration_change(key, actor_scopes=(), value={"synchronous_send_max_recipients": 200}).allowed
|
||||
assert classify_configuration_field("campaign_delivery_policy.unknown") is None
|
||||
|
||||
|
||||
def test_registered_http_routes_validate_scope_authorization_payload_and_revision(policy):
|
||||
app = FastAPI()
|
||||
app.include_router(campaign_router, prefix="/api/v1")
|
||||
app.dependency_overrides[get_session] = lambda: policy.session
|
||||
actor = [policy.admin]
|
||||
app.dependency_overrides[get_api_principal] = lambda: actor[0]
|
||||
with TestClient(app) as client:
|
||||
path = "/api/v1/campaigns/settings/delivery-policy/system"
|
||||
response = client.get(path)
|
||||
assert response.status_code == 200
|
||||
payload = {"synchronous_send_max_recipients": 200, "expected_revision": response.json()["revision"]}
|
||||
actor[0] = principal("system:settings:read")
|
||||
assert client.put(path, json=payload).status_code == 403
|
||||
actor[0] = principal("admin:policies:read", "admin:policies:write")
|
||||
assert client.get(path).status_code == 403
|
||||
assert client.put(path, json=payload).status_code == 403
|
||||
actor[0] = policy.admin
|
||||
assert client.put(path, json={**payload, "settings": {"unrelated": "overwrite"}}).status_code == 422
|
||||
assert client.put(path, json={**payload, "synchronous_send_max_recipients": True}).status_code == 422
|
||||
saved = client.put(path, json=payload)
|
||||
assert saved.status_code == 200 and saved.json()["effective_max_recipients"] == 200
|
||||
assert client.put(path, json=payload).status_code == 409
|
||||
assert client.get("/api/v1/campaigns/settings/delivery-policy/user").status_code == 422
|
||||
actor[0] = principal()
|
||||
assert client.get(path).status_code == 403
|
||||
assert policy.audit.call_args.kwargs["object_type"] == "campaign_delivery_policy"
|
||||
@@ -78,6 +78,53 @@ def test_complete_review_workflow_documents_each_attention_class() -> None:
|
||||
assert topic.metadata["help_contexts"] == ["campaign.review-send"]
|
||||
|
||||
|
||||
def test_attachment_reuse_workflow_documents_policy_and_evidence() -> None:
|
||||
topic = next(
|
||||
item
|
||||
for item in CAMPAIGN_USER_DOCUMENTATION
|
||||
if item.id == "campaigns.workflow.control-attachment-reuse"
|
||||
)
|
||||
|
||||
rendered = "\n".join(
|
||||
(
|
||||
topic.summary,
|
||||
topic.body,
|
||||
*topic.metadata["steps"],
|
||||
topic.metadata["verification"],
|
||||
)
|
||||
)
|
||||
assert "allow" in rendered.lower()
|
||||
assert "warn" in rendered.lower()
|
||||
assert "review" in rendered.lower()
|
||||
assert "block" in rendered.lower()
|
||||
assert "fingerprint" in rendered.lower()
|
||||
assert "reason" in rendered.lower()
|
||||
assert topic.metadata["help_contexts"] == [
|
||||
"campaign.attachments",
|
||||
"campaign.attachments.reuse-policy",
|
||||
]
|
||||
|
||||
|
||||
def test_access_evidence_reference_documents_independent_owner_boundaries() -> None:
|
||||
from govoplan_campaign.backend.manifest import get_manifest
|
||||
|
||||
topic = next(
|
||||
item for item in get_manifest().documentation
|
||||
if item.id == "campaigns.access.child-evidence"
|
||||
)
|
||||
rendered = f"{topic.summary}\n{topic.body}".lower()
|
||||
for expected in (
|
||||
"governance visibility",
|
||||
"mapping profiles",
|
||||
"validation",
|
||||
"reusable templates",
|
||||
"export packages",
|
||||
"fail closed",
|
||||
):
|
||||
assert expected in rendered
|
||||
assert "campaign.import" in topic.metadata["help_contexts"]
|
||||
|
||||
|
||||
def test_runtime_documentation_is_user_only_and_requires_a_campaign_task() -> None:
|
||||
assert _topics({"docs:documentation:read"}) == ()
|
||||
assert _topics({"campaigns:campaign:read"}, documentation_type="admin") == ()
|
||||
@@ -416,7 +463,13 @@ def test_static_campaign_handbook_has_unique_ids_help_contexts_and_no_planned_re
|
||||
"campaign.settings",
|
||||
"campaign.fields",
|
||||
"campaign.template",
|
||||
"campaign.attachments",
|
||||
"campaign.template.content-library",
|
||||
"campaigns.action.schedule-drafts",
|
||||
"campaigns.action.export-package",
|
||||
"campaigns.action.import-package",
|
||||
"campaign.attachments",
|
||||
"campaign.attachments.reuse-policy",
|
||||
"campaign.attachments.residual-files",
|
||||
"campaign.recipients",
|
||||
"campaign.recipient-data",
|
||||
"campaign.server-settings",
|
||||
@@ -424,8 +477,21 @@ def test_static_campaign_handbook_has_unique_ids_help_contexts_and_no_planned_re
|
||||
"campaign.review-send",
|
||||
"campaign.report",
|
||||
"campaign.audit",
|
||||
"campaign.json",
|
||||
}
|
||||
"campaign.json",
|
||||
"campaign.activity",
|
||||
"campaign.activity.composer",
|
||||
"campaign.activity.action.post",
|
||||
"campaign.activity.action.withdraw",
|
||||
"campaign.activity.action.redact",
|
||||
"campaign.work",
|
||||
"campaign.work.create",
|
||||
"campaign.work.action.start",
|
||||
"campaign.work.action.complete",
|
||||
"campaign.work.action.reject",
|
||||
"campaign.work.action.reassign",
|
||||
"campaign.work.action.cancel",
|
||||
"campaign.work.history",
|
||||
}
|
||||
|
||||
assert len(ids) == len(set(ids))
|
||||
assert "single resend" not in rendered_static
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
from govoplan_campaign.backend.manifest import get_manifest
|
||||
|
||||
|
||||
def test_static_documentation_has_complete_german_reference_copy() -> None:
|
||||
for topic in get_manifest().documentation:
|
||||
german = topic.translations.get("de", {})
|
||||
assert all(german.get(field, "").strip() for field in ("title", "summary", "body")), topic.id
|
||||
@@ -0,0 +1,787 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_access.backend.db.models import Account, Group, User
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
AttachmentBlob,
|
||||
AttachmentInstance,
|
||||
Campaign,
|
||||
CampaignCollaborationEntry,
|
||||
CampaignIssue,
|
||||
CampaignJob,
|
||||
CampaignMessageAction,
|
||||
CampaignMessageActionAttempt,
|
||||
CampaignSchedule,
|
||||
CampaignShare,
|
||||
CampaignVersion,
|
||||
CampaignWorkAssignment,
|
||||
CampaignWorkAssignmentEvent,
|
||||
ImapAppendAttempt,
|
||||
PostboxDeliveryAttempt,
|
||||
PrintOutputAttempt,
|
||||
RecipientImportMappingProfile,
|
||||
SendAttempt,
|
||||
)
|
||||
from govoplan_campaign.backend.dsar_provider import (
|
||||
CAMPAIGN_DSAR_CAPABILITY,
|
||||
CampaignDsarProvider,
|
||||
)
|
||||
from govoplan_campaign.backend.manifest import manifest
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.core.dsar import DsarProvider, DsarSubjectRef
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.privacy.dsar_workflow import (
|
||||
DataSubjectRequest,
|
||||
create_data_subject_request,
|
||||
execute_data_subject_erasure,
|
||||
plan_data_subject_erasure,
|
||||
search_data_subject_request,
|
||||
)
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(
|
||||
self,
|
||||
provider: CampaignDsarProvider,
|
||||
*,
|
||||
campaign_active: bool = True,
|
||||
) -> None:
|
||||
self.provider = provider
|
||||
self.campaign_active = campaign_active
|
||||
|
||||
def capability_names(self):
|
||||
return (CAMPAIGN_DSAR_CAPABILITY,)
|
||||
|
||||
def capability_owner(self, name):
|
||||
self._assert_capability(name)
|
||||
return "campaigns"
|
||||
|
||||
def tenant_entitlement_resolver(self):
|
||||
campaign_active = self.campaign_active
|
||||
|
||||
class _Resolver:
|
||||
@staticmethod
|
||||
def resolve(session, tenant_id):
|
||||
del session, tenant_id
|
||||
return type(
|
||||
"State",
|
||||
(),
|
||||
{"effective_modules": ("campaigns",) if campaign_active else ()},
|
||||
)()
|
||||
|
||||
return _Resolver()
|
||||
|
||||
def require_tenant_capability(self, name, session, **kwargs):
|
||||
del session, kwargs
|
||||
self._assert_capability(name)
|
||||
return self.provider
|
||||
|
||||
def manifests(self):
|
||||
return (type("Manifest", (), {"id": "campaigns"})(),)
|
||||
|
||||
@staticmethod
|
||||
def _assert_capability(name: str) -> None:
|
||||
if name != CAMPAIGN_DSAR_CAPABILITY:
|
||||
raise KeyError(name)
|
||||
|
||||
|
||||
class CampaignDsarProviderTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:", future=True)
|
||||
Base.metadata.create_all(
|
||||
bind=self.engine,
|
||||
tables=[
|
||||
Account.__table__,
|
||||
User.__table__,
|
||||
Group.__table__,
|
||||
ChangeSequenceEntry.__table__,
|
||||
DataSubjectRequest.__table__,
|
||||
Campaign.__table__,
|
||||
CampaignShare.__table__,
|
||||
CampaignCollaborationEntry.__table__,
|
||||
CampaignWorkAssignment.__table__,
|
||||
CampaignWorkAssignmentEvent.__table__,
|
||||
CampaignVersion.__table__,
|
||||
CampaignJob.__table__,
|
||||
CampaignIssue.__table__,
|
||||
AttachmentBlob.__table__,
|
||||
AttachmentInstance.__table__,
|
||||
SendAttempt.__table__,
|
||||
CampaignMessageAction.__table__,
|
||||
CampaignMessageActionAttempt.__table__,
|
||||
ImapAppendAttempt.__table__,
|
||||
PostboxDeliveryAttempt.__table__,
|
||||
PrintOutputAttempt.__table__,
|
||||
RecipientImportMappingProfile.__table__,
|
||||
CampaignSchedule.__table__,
|
||||
],
|
||||
)
|
||||
self.session = sessionmaker(bind=self.engine, future=True)()
|
||||
now = datetime.now(timezone.utc)
|
||||
self.account = Account(
|
||||
id="account-1",
|
||||
email="subject@example.test",
|
||||
normalized_email="subject@example.test",
|
||||
display_name="Subject",
|
||||
)
|
||||
other_account = Account(
|
||||
id="account-2",
|
||||
email="other@example.test",
|
||||
normalized_email="other@example.test",
|
||||
display_name="Other",
|
||||
)
|
||||
self.user = User(
|
||||
id="membership-1",
|
||||
tenant_id="tenant-1",
|
||||
account_id=self.account.id,
|
||||
email="subject@example.test",
|
||||
display_name="Subject",
|
||||
)
|
||||
self.other_user = User(
|
||||
id="membership-2",
|
||||
tenant_id="tenant-1",
|
||||
account_id=other_account.id,
|
||||
email="other@example.test",
|
||||
display_name="Other",
|
||||
)
|
||||
self.campaign = Campaign(
|
||||
id="campaign-1",
|
||||
tenant_id="tenant-1",
|
||||
created_by_user_id=self.other_user.id,
|
||||
owner_user_id=self.other_user.id,
|
||||
external_id="privacy-notice",
|
||||
name="Privacy notice",
|
||||
status="active",
|
||||
)
|
||||
self.version = CampaignVersion(
|
||||
id="version-1",
|
||||
campaign_id=self.campaign.id,
|
||||
version_number=1,
|
||||
workflow_state="built",
|
||||
execution_snapshot_hash="a" * 64,
|
||||
raw_json={
|
||||
"entries": {
|
||||
"inline": [
|
||||
{
|
||||
"id": "entry-subject",
|
||||
"to": [
|
||||
{
|
||||
"email": "subject@example.test",
|
||||
"name": "Subject Person",
|
||||
}
|
||||
],
|
||||
"cc": [
|
||||
{
|
||||
"email": "other@example.test",
|
||||
"name": "Unrelated person",
|
||||
}
|
||||
],
|
||||
"body": "private-rendered-body-do-not-export",
|
||||
"password": "inline-secret-do-not-export",
|
||||
"case_reference": "CASE-SUBJECT-1",
|
||||
},
|
||||
{
|
||||
"id": "entry-other",
|
||||
"to": [{"email": "other@example.test"}],
|
||||
"private_value": "other-recipient-data-do-not-export",
|
||||
},
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
self.draft_version = CampaignVersion(
|
||||
id="version-draft",
|
||||
campaign_id=self.campaign.id,
|
||||
version_number=2,
|
||||
workflow_state="editing",
|
||||
raw_json={
|
||||
"entries": {
|
||||
"inline": [
|
||||
{
|
||||
"id": "entry-draft-subject",
|
||||
"to": [{"email": "subject@example.test"}],
|
||||
"case_reference": "CASE-DRAFT-1",
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
self.job = CampaignJob(
|
||||
id="job-subject",
|
||||
tenant_id="tenant-1",
|
||||
campaign_id=self.campaign.id,
|
||||
campaign_version_id=self.version.id,
|
||||
entry_index=0,
|
||||
entry_id="entry-subject",
|
||||
recipient_email="Subject@Example.Test",
|
||||
subject="Your governed notice",
|
||||
eml_storage_key="private/eml/key-do-not-export",
|
||||
eml_local_path="/private/message-do-not-export.eml",
|
||||
eml_size_bytes=512,
|
||||
eml_sha256="b" * 64,
|
||||
build_status="built",
|
||||
validation_status="ready",
|
||||
queue_status="completed",
|
||||
send_status="smtp_accepted",
|
||||
postbox_status="accepted",
|
||||
print_status="accepted",
|
||||
imap_status="appended",
|
||||
attempt_count=1,
|
||||
queued_at=now,
|
||||
sent_at=now,
|
||||
claim_token="job-claim-do-not-export",
|
||||
resolved_recipients={
|
||||
"from": {"email": "sender@example.test"},
|
||||
"to": [{"email": "subject@example.test", "name": "Subject"}],
|
||||
"cc": [{"email": "other@example.test", "name": "Other"}],
|
||||
"legacy": ["other@example.test", "subject@example.test"],
|
||||
},
|
||||
resolved_attachments=[
|
||||
{"storage_key": "resolved-attachment-key-do-not-export"}
|
||||
],
|
||||
)
|
||||
other_job = CampaignJob(
|
||||
id="job-other",
|
||||
tenant_id="tenant-1",
|
||||
campaign_id=self.campaign.id,
|
||||
campaign_version_id=self.version.id,
|
||||
entry_index=1,
|
||||
entry_id="entry-other",
|
||||
recipient_email="other@example.test",
|
||||
subject="Other person's message",
|
||||
build_status="built",
|
||||
validation_status="ready",
|
||||
)
|
||||
tenant_two_campaign = Campaign(
|
||||
id="campaign-tenant-2",
|
||||
tenant_id="tenant-2",
|
||||
external_id="other-tenant",
|
||||
name="Other tenant data do not export",
|
||||
)
|
||||
tenant_two_version = CampaignVersion(
|
||||
id="version-tenant-2",
|
||||
campaign_id=tenant_two_campaign.id,
|
||||
version_number=1,
|
||||
raw_json={
|
||||
"entries": {"inline": [{"to": [{"email": "subject@example.test"}]}]}
|
||||
},
|
||||
)
|
||||
tenant_two_job = CampaignJob(
|
||||
id="job-tenant-2",
|
||||
tenant_id="tenant-2",
|
||||
campaign_id=tenant_two_campaign.id,
|
||||
campaign_version_id=tenant_two_version.id,
|
||||
entry_index=0,
|
||||
recipient_email="subject@example.test",
|
||||
)
|
||||
self.issue = CampaignIssue(
|
||||
id="issue-1",
|
||||
tenant_id="tenant-1",
|
||||
campaign_id=self.campaign.id,
|
||||
campaign_version_id=self.version.id,
|
||||
job_id=self.job.id,
|
||||
severity="warning",
|
||||
code="delivery_warning",
|
||||
message="issue-detail-do-not-export",
|
||||
source="private-source-do-not-export",
|
||||
behavior="review",
|
||||
)
|
||||
send_attempt = SendAttempt(
|
||||
id="send-attempt-1",
|
||||
job_id=self.job.id,
|
||||
attempt_number=1,
|
||||
status="accepted",
|
||||
claim_token="attempt-claim-do-not-export",
|
||||
smtp_status_code=250,
|
||||
smtp_response="smtp-response-do-not-export",
|
||||
error_message="transport-detail-do-not-export",
|
||||
started_at=now,
|
||||
finished_at=now,
|
||||
)
|
||||
postbox_attempt = PostboxDeliveryAttempt(
|
||||
id="postbox-attempt-1",
|
||||
tenant_id="tenant-1",
|
||||
job_id=self.job.id,
|
||||
target_key="target-key-do-not-export",
|
||||
target_index=0,
|
||||
attempt_number=1,
|
||||
idempotency_key="postbox-idempotency-do-not-export",
|
||||
status="accepted",
|
||||
target_snapshot={"private": "snapshot-do-not-export"},
|
||||
provider_delivery_id="delivery-1",
|
||||
provider_message_id="message-1",
|
||||
postbox_id="postbox-1",
|
||||
address="subject@example.test",
|
||||
evidence={"private": "postbox-evidence-do-not-export"},
|
||||
started_at=now,
|
||||
finished_at=now,
|
||||
)
|
||||
print_attempt = PrintOutputAttempt(
|
||||
id="print-attempt-1",
|
||||
tenant_id="tenant-1",
|
||||
job_id=self.job.id,
|
||||
attempt_number=1,
|
||||
idempotency_key="print-idempotency-do-not-export",
|
||||
status="accepted",
|
||||
render_id="render-1",
|
||||
artifact_sha256="c" * 64,
|
||||
evidence={"private": "print-evidence-do-not-export"},
|
||||
started_at=now,
|
||||
finished_at=now,
|
||||
)
|
||||
self.share = CampaignShare(
|
||||
id="share-1",
|
||||
tenant_id="tenant-1",
|
||||
campaign_id=self.campaign.id,
|
||||
target_type="user",
|
||||
target_id=self.user.id,
|
||||
permission="read",
|
||||
created_by_user_id=self.other_user.id,
|
||||
)
|
||||
collaboration = CampaignCollaborationEntry(
|
||||
id="collaboration-1",
|
||||
tenant_id="tenant-1",
|
||||
campaign_id=self.campaign.id,
|
||||
campaign_version_id=self.version.id,
|
||||
actor_user_id=self.user.id,
|
||||
actor_label_snapshot="Subject",
|
||||
visibility="collaborators",
|
||||
content="Subject-authored discussion text",
|
||||
content_sha256="9" * 64,
|
||||
mention_user_ids=[self.other_user.id],
|
||||
reference_kind="campaign_version",
|
||||
reference_id=self.version.id,
|
||||
reference_label="Version 1",
|
||||
)
|
||||
self.profile = RecipientImportMappingProfile(
|
||||
id="mapping-1",
|
||||
tenant_id="tenant-1",
|
||||
owner_user_id=self.user.id,
|
||||
name="Subject mapping",
|
||||
column_count=2,
|
||||
headers=["email", "case_reference"],
|
||||
normalized_headers=["email", "case_reference"],
|
||||
ordered_header_fingerprint="d" * 64,
|
||||
unordered_header_fingerprint="e" * 64,
|
||||
delimiter=";",
|
||||
header_rows=1,
|
||||
quoted=True,
|
||||
value_separators=",;|",
|
||||
mappings=[
|
||||
{"header": "email", "field": "to.0.email"},
|
||||
{"secret": "profile-secret-do-not-export"},
|
||||
],
|
||||
)
|
||||
blob = AttachmentBlob(
|
||||
id="blob-1",
|
||||
tenant_id="tenant-1",
|
||||
sha256="f" * 64,
|
||||
size_bytes=42,
|
||||
mime_type="application/pdf",
|
||||
storage_bucket="private-bucket-do-not-export",
|
||||
storage_key="private-attachment-key-do-not-export",
|
||||
)
|
||||
attachment = AttachmentInstance(
|
||||
id="attachment-1",
|
||||
tenant_id="tenant-1",
|
||||
owner_user_id=self.other_user.id,
|
||||
campaign_id=self.campaign.id,
|
||||
blob_id=blob.id,
|
||||
logical_name="notice",
|
||||
filename="notice.pdf",
|
||||
tags=["notice"],
|
||||
metadata_={"secret": "attachment-secret-do-not-export"},
|
||||
)
|
||||
schedule = CampaignSchedule(
|
||||
id="schedule-1",
|
||||
tenant_id="tenant-1",
|
||||
campaign_id=self.campaign.id,
|
||||
source_version_id=self.version.id,
|
||||
created_by_user_id=self.other_user.id,
|
||||
name="Recurring privacy notice",
|
||||
delivery_mode="manual",
|
||||
recurrence_kind="monthly",
|
||||
starts_at=now,
|
||||
next_fire_at=now,
|
||||
max_occurrences=12,
|
||||
source_snapshot={"private": "schedule-snapshot-do-not-export"},
|
||||
source_snapshot_hash="1" * 64,
|
||||
)
|
||||
self.session.add_all(
|
||||
[
|
||||
self.account,
|
||||
other_account,
|
||||
self.user,
|
||||
self.other_user,
|
||||
self.campaign,
|
||||
self.version,
|
||||
self.draft_version,
|
||||
self.job,
|
||||
other_job,
|
||||
tenant_two_campaign,
|
||||
tenant_two_version,
|
||||
tenant_two_job,
|
||||
self.issue,
|
||||
send_attempt,
|
||||
postbox_attempt,
|
||||
print_attempt,
|
||||
self.share,
|
||||
collaboration,
|
||||
self.profile,
|
||||
blob,
|
||||
attachment,
|
||||
schedule,
|
||||
]
|
||||
)
|
||||
self.session.commit()
|
||||
self.provider = CampaignDsarProvider()
|
||||
self.subject = DsarSubjectRef(
|
||||
account_id=self.account.id,
|
||||
membership_id=self.user.id,
|
||||
email="subject@example.test",
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def test_manifest_publishes_protocol_conforming_provider(self) -> None:
|
||||
provided_names = {item.name for item in manifest.provides_interfaces}
|
||||
self.assertIn(CAMPAIGN_DSAR_CAPABILITY, provided_names)
|
||||
provider = manifest.capability_factories[CAMPAIGN_DSAR_CAPABILITY](None)
|
||||
self.assertIsInstance(provider, DsarProvider)
|
||||
self.assertIn(
|
||||
"campaigns.privacy.data-subject-requests",
|
||||
{topic.id for topic in manifest.documentation},
|
||||
)
|
||||
|
||||
def test_search_includes_account_assignment_and_immutable_lifecycle_evidence(self) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
assignment = CampaignWorkAssignment(
|
||||
id="work-assignment-subject",
|
||||
tenant_id="tenant-1",
|
||||
campaign_id=self.campaign.id,
|
||||
purpose="Review the individualized notice",
|
||||
status="open",
|
||||
assignee_type="account",
|
||||
assignee_id=self.account.id,
|
||||
assignee_label_snapshot="Subject",
|
||||
assignee_resolution_state="resolved",
|
||||
resolution_provenance={"policy_code": "assignment_does_not_grant_access"},
|
||||
resolution_checked_at=now,
|
||||
assigned_by_user_id=self.other_user.id,
|
||||
assigned_by_label_snapshot="Other",
|
||||
)
|
||||
event = CampaignWorkAssignmentEvent(
|
||||
id="work-assignment-event-subject",
|
||||
tenant_id="tenant-1",
|
||||
campaign_id=self.campaign.id,
|
||||
assignment_id=assignment.id,
|
||||
event_kind="assigned",
|
||||
actor_user_id=self.other_user.id,
|
||||
actor_label_snapshot="Other",
|
||||
status_snapshot="open",
|
||||
assignee_type_snapshot="account",
|
||||
assignee_id_snapshot=self.account.id,
|
||||
assignee_label_snapshot="Subject",
|
||||
resolution_state_snapshot="resolved",
|
||||
)
|
||||
self.session.add_all((assignment, event))
|
||||
self.session.commit()
|
||||
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=self.subject,
|
||||
)
|
||||
work = next(item for item in records if item.resource_type == "campaign_work_assignment")
|
||||
history = next(item for item in records if item.resource_type == "campaign_work_assignment_event")
|
||||
|
||||
self.assertIn("assignee_id", work.data["match_fields"])
|
||||
self.assertEqual("Review the individualized notice", work.data["purpose"])
|
||||
self.assertFalse(work.immutable_evidence)
|
||||
self.assertTrue(history.immutable_evidence)
|
||||
self.assertIn("accountable institutional work history", history.retention_reason)
|
||||
|
||||
def test_search_is_tenant_scoped_minimized_and_recipient_specific(self) -> None:
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=self.subject,
|
||||
)
|
||||
resource_types = {record.resource_type for record in records}
|
||||
self.assertTrue(
|
||||
{
|
||||
"campaign",
|
||||
"campaign_version",
|
||||
"campaign_recipient_job",
|
||||
"campaign_message_artifact",
|
||||
"campaign_delivery_issue",
|
||||
"campaign_send_attempt",
|
||||
"campaign_postbox_attempt",
|
||||
"campaign_print_attempt",
|
||||
"campaign_report_projection",
|
||||
"campaign_share",
|
||||
"campaign_collaboration_entry",
|
||||
"recipient_import_mapping_profile",
|
||||
"campaign_schedule",
|
||||
"campaign_attachment",
|
||||
}.issubset(resource_types)
|
||||
)
|
||||
collaboration_record = next(
|
||||
record
|
||||
for record in records
|
||||
if record.resource_type == "campaign_collaboration_entry"
|
||||
)
|
||||
self.assertEqual(
|
||||
"Subject-authored discussion text",
|
||||
collaboration_record.data["posted_text"],
|
||||
)
|
||||
self.assertTrue(collaboration_record.immutable_evidence)
|
||||
report = next(
|
||||
record
|
||||
for record in records
|
||||
if record.resource_type == "campaign_report_projection"
|
||||
)
|
||||
self.assertEqual(1, report.data["matched_job_count"])
|
||||
artifact = next(
|
||||
record
|
||||
for record in records
|
||||
if record.resource_type == "campaign_message_artifact"
|
||||
)
|
||||
self.assertEqual("b" * 64, artifact.data["sha256"])
|
||||
self.assertEqual(512, artifact.data["size_bytes"])
|
||||
|
||||
serialized = repr([record.to_dict() for record in records])
|
||||
for hidden in (
|
||||
"job-tenant-2",
|
||||
"Other tenant data do not export",
|
||||
"job-other",
|
||||
"other@example.test",
|
||||
"Unrelated person",
|
||||
"other-recipient-data-do-not-export",
|
||||
"private-rendered-body-do-not-export",
|
||||
"inline-secret-do-not-export",
|
||||
"private/eml/key-do-not-export",
|
||||
"/private/message-do-not-export.eml",
|
||||
"resolved-attachment-key-do-not-export",
|
||||
"job-claim-do-not-export",
|
||||
"issue-detail-do-not-export",
|
||||
"private-source-do-not-export",
|
||||
"attempt-claim-do-not-export",
|
||||
"smtp-response-do-not-export",
|
||||
"transport-detail-do-not-export",
|
||||
"target-key-do-not-export",
|
||||
"postbox-idempotency-do-not-export",
|
||||
"snapshot-do-not-export",
|
||||
"postbox-evidence-do-not-export",
|
||||
"print-idempotency-do-not-export",
|
||||
"print-evidence-do-not-export",
|
||||
"profile-secret-do-not-export",
|
||||
"private-bucket-do-not-export",
|
||||
"private-attachment-key-do-not-export",
|
||||
"attachment-secret-do-not-export",
|
||||
"schedule-snapshot-do-not-export",
|
||||
):
|
||||
self.assertNotIn(hidden, serialized)
|
||||
|
||||
def test_conflicting_email_references_fail_closed_for_recipient_data(self) -> None:
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
email="subject@example.test",
|
||||
external_references={"campaign.email": "other@example.test"},
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual((), records)
|
||||
|
||||
def test_plan_retains_evidence_and_limits_execution_to_reversible_data(
|
||||
self,
|
||||
) -> None:
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=self.subject,
|
||||
)
|
||||
actions = self.provider.plan_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=self.subject,
|
||||
records=records,
|
||||
)
|
||||
|
||||
kinds = {action.kind for action in actions}
|
||||
self.assertTrue({"retain", "manual_review", "revoke", "delete"}.issubset(kinds))
|
||||
self.assertTrue(
|
||||
any(
|
||||
action.action_id
|
||||
== "campaigns:retain:campaign_recipient_job:job-subject"
|
||||
for action in actions
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
any(
|
||||
action.action_id == "campaigns:review:campaign_version:version-draft"
|
||||
for action in actions
|
||||
)
|
||||
)
|
||||
executable_ids = {action.action_id for action in actions if action.executable}
|
||||
self.assertEqual(
|
||||
{
|
||||
"campaigns:revoke:campaign_share:share-1",
|
||||
"campaigns:delete:recipient_import_mapping_profile:mapping-1",
|
||||
},
|
||||
executable_ids,
|
||||
)
|
||||
|
||||
def test_execution_is_revalidated_tenant_bound_and_idempotent(self) -> None:
|
||||
actions = self._executable_actions()
|
||||
|
||||
wrong_tenant = self.provider.execute_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-2",
|
||||
subject=self.subject,
|
||||
actions=actions,
|
||||
request_id="dsar-wrong-tenant",
|
||||
)
|
||||
self.assertEqual({"blocked"}, {result.status for result in wrong_tenant})
|
||||
|
||||
first = self.provider.execute_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=self.subject,
|
||||
actions=actions,
|
||||
request_id="dsar-1",
|
||||
)
|
||||
self.assertEqual({"executed"}, {result.status for result in first})
|
||||
self.session.flush()
|
||||
self.assertIsNotNone(self.share.revoked_at)
|
||||
self.assertIsNone(
|
||||
self.session.get(RecipientImportMappingProfile, self.profile.id)
|
||||
)
|
||||
|
||||
repeated = self.provider.execute_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=self.subject,
|
||||
actions=actions,
|
||||
request_id="dsar-1",
|
||||
)
|
||||
self.assertEqual({"unchanged"}, {result.status for result in repeated})
|
||||
|
||||
def test_execution_blocks_when_mapping_owner_changed_after_planning(self) -> None:
|
||||
delete_action = next(
|
||||
action
|
||||
for action in self._executable_actions()
|
||||
if action.resource_type == "recipient_import_mapping_profile"
|
||||
)
|
||||
self.profile.owner_user_id = self.other_user.id
|
||||
self.session.flush()
|
||||
|
||||
result = self.provider.execute_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=self.subject,
|
||||
actions=(delete_action,),
|
||||
request_id="dsar-stale",
|
||||
)
|
||||
|
||||
self.assertEqual("blocked", result[0].status)
|
||||
self.assertIsNotNone(
|
||||
self.session.get(RecipientImportMappingProfile, self.profile.id)
|
||||
)
|
||||
|
||||
def test_core_workflow_discovers_active_provider_and_skips_it_when_disabled(
|
||||
self,
|
||||
) -> None:
|
||||
request = create_data_subject_request(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
reference="DSAR-CAMPAIGN-1",
|
||||
request_kind="access_and_erasure",
|
||||
subject=self.subject,
|
||||
purpose="Respond to an authorized privacy request.",
|
||||
legal_basis="Article 15 and 17 GDPR",
|
||||
due_at=None,
|
||||
requested_by_account_id="privacy-officer",
|
||||
)
|
||||
self.session.commit()
|
||||
registry = _Registry(self.provider)
|
||||
|
||||
search_data_subject_request(
|
||||
self.session,
|
||||
registry=registry,
|
||||
row=request,
|
||||
expected_revision=1,
|
||||
)
|
||||
self.assertEqual("searched", request.status)
|
||||
self.assertEqual(["campaigns"], request.coverage["covered_modules"])
|
||||
self.assertEqual([], request.coverage["modules_without_provider"])
|
||||
plan_data_subject_erasure(
|
||||
self.session,
|
||||
registry=registry,
|
||||
row=request,
|
||||
expected_revision=2,
|
||||
)
|
||||
executable_ids = [
|
||||
action["action_id"]
|
||||
for action in request.erasure_plan["actions"]
|
||||
if action["executable"]
|
||||
]
|
||||
execute_data_subject_erasure(
|
||||
self.session,
|
||||
registry=registry,
|
||||
row=request,
|
||||
expected_revision=3,
|
||||
action_ids=executable_ids,
|
||||
)
|
||||
self.assertEqual("completed", request.status)
|
||||
|
||||
disabled = create_data_subject_request(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
reference="DSAR-CAMPAIGN-DISABLED",
|
||||
request_kind="access",
|
||||
subject=self.subject,
|
||||
purpose="Verify disabled-module coverage.",
|
||||
legal_basis="Article 15 GDPR",
|
||||
due_at=None,
|
||||
requested_by_account_id="privacy-officer",
|
||||
)
|
||||
search_data_subject_request(
|
||||
self.session,
|
||||
registry=_Registry(self.provider, campaign_active=False),
|
||||
row=disabled,
|
||||
expected_revision=1,
|
||||
)
|
||||
|
||||
self.assertEqual(0, disabled.search_result["record_count"])
|
||||
self.assertEqual(
|
||||
[CAMPAIGN_DSAR_CAPABILITY],
|
||||
disabled.coverage["inactive_provider_capabilities"],
|
||||
)
|
||||
|
||||
def _executable_actions(self):
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=self.subject,
|
||||
)
|
||||
actions = self.provider.plan_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=self.subject,
|
||||
records=records,
|
||||
)
|
||||
return tuple(action for action in actions if action.executable)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -6,7 +6,9 @@ import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
||||
CampaignMailProfileBoundaryError,
|
||||
campaign_editor_state_for_edit,
|
||||
campaign_editor_state_with_client_update,
|
||||
)
|
||||
from govoplan_campaign.backend.schemas import (
|
||||
CampaignVersionResponse,
|
||||
@@ -19,6 +21,7 @@ from govoplan_campaign.backend.schemas import (
|
||||
[
|
||||
{"smtp": {"host": "smtp.example.test", "password": "secret"}},
|
||||
{"transport": {"imap_password": "secret"}},
|
||||
{"approval_gate": {"request_id": "forged"}},
|
||||
{
|
||||
"review_send": {
|
||||
"build_token": "forged",
|
||||
@@ -108,3 +111,48 @@ def test_fork_copy_keeps_only_client_owned_bounded_metadata() -> None:
|
||||
"field_overrides": {"department": False},
|
||||
}
|
||||
assert "legacy-secret" not in repr(copied)
|
||||
|
||||
|
||||
def test_client_metadata_update_preserves_only_trusted_server_evidence() -> None:
|
||||
review = {
|
||||
"build_token": "server-build-token",
|
||||
"inspection_complete": True,
|
||||
"reviewed_message_keys": ["entry-1"],
|
||||
"issue_decisions": [],
|
||||
"updated_at": "2026-07-21T00:00:00+00:00",
|
||||
"updated_by_user_id": "reviewer-1",
|
||||
}
|
||||
approval = {
|
||||
"request_id": "approval-1",
|
||||
"request_revision": 2,
|
||||
"subject_version": "version-1",
|
||||
"subject_digest": "a" * 64,
|
||||
"requested_at": "2026-07-21T00:00:00+00:00",
|
||||
"requested_by_user_id": "approver-1",
|
||||
}
|
||||
stored = {
|
||||
"created_from": "minimal_campaign",
|
||||
"opt_ins": {"inline_guidance": True},
|
||||
"review_send": review,
|
||||
"approval_gate": approval,
|
||||
"credentials": {"password": "legacy-secret"},
|
||||
}
|
||||
updated = campaign_editor_state_with_client_update(
|
||||
stored, {"opt_ins": {"inline_guidance": False}}
|
||||
)
|
||||
|
||||
assert updated == {
|
||||
"opt_ins": {"inline_guidance": False},
|
||||
"review_send": review,
|
||||
"approval_gate": approval,
|
||||
}
|
||||
assert updated["review_send"] is not review
|
||||
assert updated["approval_gate"] is not approval
|
||||
assert stored["opt_ins"] == {"inline_guidance": True}
|
||||
assert "legacy-secret" not in repr(updated)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("server_key", ["review_send", "approval_gate"])
|
||||
def test_client_metadata_update_cannot_replace_server_evidence(server_key: str) -> None:
|
||||
with pytest.raises(CampaignMailProfileBoundaryError, match="unsupported"):
|
||||
campaign_editor_state_with_client_update({}, {server_key: {}})
|
||||
|
||||
@@ -64,6 +64,9 @@ class _Session:
|
||||
def query(self, _model):
|
||||
return _Query(self.jobs)
|
||||
|
||||
def get(self, _model, _identifier):
|
||||
return SimpleNamespace(id="campaign-1")
|
||||
|
||||
|
||||
def _snapshotted_version(job: SimpleNamespace):
|
||||
version = SimpleNamespace(
|
||||
@@ -87,9 +90,15 @@ def _snapshotted_version(job: SimpleNamespace):
|
||||
|
||||
|
||||
def _ensure(session: _Session, version) -> None:
|
||||
with patch(
|
||||
"govoplan_campaign.backend.sending.execution.files_integration",
|
||||
return_value=SimpleNamespace(available=False),
|
||||
with (
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.execution.files_integration",
|
||||
return_value=SimpleNamespace(available=False),
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.execution.assert_archive_encryption_allowed",
|
||||
return_value=SimpleNamespace(policy_hash="archive-policy"),
|
||||
),
|
||||
):
|
||||
ensure_execution_snapshot(session, version) # type: ignore[arg-type]
|
||||
|
||||
@@ -125,12 +134,19 @@ def test_effect_check_verifies_only_the_claimed_job_in_constant_time() -> None:
|
||||
job = _job()
|
||||
version = _snapshotted_version(job)
|
||||
session = SimpleNamespace(
|
||||
query=lambda *_args: pytest.fail("per-effect validation must not rescan every campaign job")
|
||||
query=lambda *_args: pytest.fail("per-effect validation must not rescan every campaign job"),
|
||||
get=lambda *_args: SimpleNamespace(id="campaign-1"),
|
||||
)
|
||||
|
||||
with patch(
|
||||
"govoplan_campaign.backend.sending.execution.files_integration",
|
||||
return_value=SimpleNamespace(available=False),
|
||||
with (
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.execution.files_integration",
|
||||
return_value=SimpleNamespace(available=False),
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.execution.assert_archive_encryption_allowed",
|
||||
return_value=SimpleNamespace(policy_hash="archive-policy"),
|
||||
),
|
||||
):
|
||||
ensure_execution_snapshot(
|
||||
session, # type: ignore[arg-type]
|
||||
|
||||
Executable
+75
@@ -0,0 +1,75 @@
|
||||
from pathlib import Path
|
||||
import random
|
||||
import unittest
|
||||
|
||||
from govoplan_campaign.backend.services.filenames import FilenameAllocator
|
||||
from govoplan_campaign.backend.services.zip_service import _normalized_members
|
||||
|
||||
|
||||
def legacy_names(names):
|
||||
used = set()
|
||||
result = []
|
||||
for name in names:
|
||||
path = Path(name)
|
||||
candidate = name
|
||||
counter = 2
|
||||
while candidate.casefold() in used:
|
||||
candidate = f"{path.stem} ({counter}){path.suffix}"
|
||||
counter += 1
|
||||
used.add(candidate.casefold())
|
||||
result.append(candidate)
|
||||
return result
|
||||
|
||||
|
||||
class FilenameAllocatorTests(unittest.TestCase):
|
||||
def test_exact_equivalence_with_colliding_suffixes_case_unicode_and_multiple_dots(
|
||||
self,
|
||||
):
|
||||
choices = [
|
||||
"report.pdf",
|
||||
"REPORT.PDF",
|
||||
"report (2).pdf",
|
||||
"report (3).pdf",
|
||||
"report (2) (2).pdf",
|
||||
".hidden",
|
||||
"a.tar.gz",
|
||||
"A.TAR.GZ",
|
||||
"Straße.txt",
|
||||
"STRASSE.txt",
|
||||
"readme",
|
||||
]
|
||||
rng = random.Random(42)
|
||||
for _ in range(30):
|
||||
names = [rng.choice(choices) for _ in range(200)]
|
||||
allocator = FilenameAllocator()
|
||||
self.assertEqual(
|
||||
legacy_names(names), [allocator.allocate(name) for name in names]
|
||||
)
|
||||
|
||||
def test_zip_and_message_names_preserve_every_input_and_sequence(self):
|
||||
names = ["a.pdf", "A.pdf", "a (2).pdf", "a.pdf"]
|
||||
paths = [Path(f"/synthetic/{index}/source") for index in range(len(names))]
|
||||
members = _normalized_members(list(zip(paths, names)))
|
||||
self.assertEqual(paths, [path for path, _ in members])
|
||||
self.assertEqual(legacy_names(names), [name for _, name in members])
|
||||
self.assertEqual("a.pdf", FilenameAllocator().allocate("a.pdf"))
|
||||
|
||||
def test_many_collisions_have_linear_membership_work(self):
|
||||
class CountingSet(set):
|
||||
probes = 0
|
||||
|
||||
def __contains__(self, item):
|
||||
self.probes += 1
|
||||
return super().__contains__(item)
|
||||
|
||||
allocator = FilenameAllocator()
|
||||
allocator.used = CountingSet()
|
||||
for _ in range(10000):
|
||||
last = allocator.allocate("report.pdf")
|
||||
self.assertEqual("report (10000).pdf", last)
|
||||
self.assertEqual(10000, len(allocator.used))
|
||||
self.assertEqual(19999, allocator.used.probes)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -304,6 +304,7 @@ def test_imap_reconciliation_preserves_attempt_and_only_not_appended_is_retryabl
|
||||
session = MagicMock()
|
||||
session.get.return_value = job
|
||||
session.query.return_value.filter.return_value.order_by.return_value.first.return_value = attempt
|
||||
session.query.return_value.filter.return_value.update.return_value = 1
|
||||
|
||||
with (
|
||||
patch(
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from govoplan_campaign.backend.integrations import ImapAppendError, MailCampaignIntegration
|
||||
|
||||
|
||||
def test_older_mail_capability_keeps_single_message_compatibility():
|
||||
integration = MailCampaignIntegration(SimpleNamespace())
|
||||
with integration.campaign_imap_batch(tenant_id="tenant", campaign_id="campaign") as state:
|
||||
assert state is None
|
||||
|
||||
|
||||
def test_optional_batch_forwards_scope_and_cleans_up_when_caller_fails():
|
||||
calls = []
|
||||
state = SimpleNamespace(connection_count=0, reconnect_count=0)
|
||||
|
||||
@contextmanager
|
||||
def batch(**kwargs):
|
||||
calls.append(kwargs)
|
||||
try:
|
||||
yield state
|
||||
finally:
|
||||
calls.append("closed")
|
||||
|
||||
integration = MailCampaignIntegration(SimpleNamespace(campaign_imap_batch=batch))
|
||||
with pytest.raises(ValueError, match="caller"):
|
||||
with integration.campaign_imap_batch(tenant_id="tenant", campaign_id="campaign") as actual:
|
||||
assert actual is state
|
||||
raise ValueError("caller failure")
|
||||
assert calls == [{"tenant_id": "tenant", "campaign_id": "campaign"}, "closed"]
|
||||
|
||||
|
||||
def test_optional_batch_translates_unknown_outcome_without_losing_flags():
|
||||
class ProviderAppendError(RuntimeError):
|
||||
temporary = False
|
||||
outcome_unknown = True
|
||||
|
||||
@contextmanager
|
||||
def batch(**kwargs):
|
||||
raise ProviderAppendError("inspect mailbox")
|
||||
yield None
|
||||
|
||||
integration = MailCampaignIntegration(SimpleNamespace(
|
||||
campaign_imap_batch=batch, ImapAppendError=ProviderAppendError,
|
||||
))
|
||||
with pytest.raises(ImapAppendError) as caught:
|
||||
with integration.campaign_imap_batch(tenant_id="tenant", campaign_id="campaign"):
|
||||
pass
|
||||
assert caught.value.outcome_unknown
|
||||
assert caught.value.temporary is False
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Real database/route checks: a review acceptance is durable before completion."""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import Column, String, Table, create_engine, event
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion
|
||||
from govoplan_campaign.backend.routes import versions as routes
|
||||
from govoplan_campaign.backend.schemas import CampaignReviewStateRequest, CampaignVersionDetailResponse
|
||||
from govoplan_campaign.backend.services.job_queries import _review_metadata, _review_metadata_counts
|
||||
from govoplan_campaign.backend.services.review_decisions import review_decision_metadata
|
||||
from govoplan_campaign.backend.sending.jobs import _reviewed_needs_review_keys
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def review(tmp_path):
|
||||
engine = create_engine(f"sqlite+pysqlite:///{tmp_path / 'review.db'}")
|
||||
for name in ("access_users", "access_groups"):
|
||||
if name not in Base.metadata.tables:
|
||||
Table(name, Base.metadata, Column("id", String(36), primary_key=True))
|
||||
Base.metadata.create_all(engine, tables=[
|
||||
Base.metadata.tables["access_users"], Base.metadata.tables["access_groups"],
|
||||
ChangeSequenceEntry.__table__, Campaign.__table__, CampaignVersion.__table__, CampaignJob.__table__,
|
||||
])
|
||||
with Session(engine) as session:
|
||||
campaign = Campaign(id="campaign-1", tenant_id="tenant-1", external_id="C1", name="Campaign", current_version_id="version-1")
|
||||
version = CampaignVersion(id="version-1", campaign_id=campaign.id, version_number=1,
|
||||
raw_json={"version": "1.0", "campaign": {"id": "C1", "name": "Campaign"}},
|
||||
build_summary={"build_token": "private-build-1", "built_count": 2}, editor_state={"created_from": "minimal_campaign"})
|
||||
session.add_all([campaign, version, _job(1), _job(2)])
|
||||
session.commit()
|
||||
audits = []
|
||||
def audit(current_session, _principal, **kwargs):
|
||||
audits.append(kwargs)
|
||||
if kwargs.get("commit"):
|
||||
current_session.commit()
|
||||
with patch.object(routes, "_get_campaign_for_principal", return_value=campaign), patch.object(routes, "audit_from_principal", side_effect=audit):
|
||||
yield SimpleNamespace(engine=engine, session=session, version=version, campaign=campaign, audits=audits)
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _job(index, *, validation="needs_review", build="built", code="missing_optional_attachment", behavior="ask"):
|
||||
return CampaignJob(id=f"job-{index}", tenant_id="tenant-1", campaign_id="campaign-1", campaign_version_id="version-1",
|
||||
entry_index=index, entry_id=f"entry-{index}", validation_status=validation, build_status=build,
|
||||
eml_sha256=str(index % 10) * 64, issues_snapshot=[{"code": code, "behavior": behavior, "source": "attachments", "details": {"rule_id": f"rule-{index}"}}])
|
||||
|
||||
|
||||
def _principal(actor="reviewer-1"):
|
||||
return ApiPrincipal(principal=PrincipalRef(account_id=actor, membership_id=actor, tenant_id="tenant-1", scopes=frozenset({"campaigns:campaign:review"})), account=SimpleNamespace(id=actor), user=SimpleNamespace(id=actor))
|
||||
|
||||
|
||||
def _save(review, *, ids=(1,), complete=False, actor="reviewer-1", session=None, **overrides):
|
||||
payload = {
|
||||
"inspection_complete": complete, "merge_progress": True,
|
||||
"build_token": review.version.review_build_token, "base_revision": review.version.edit_revision,
|
||||
"reviewed_message_keys": [f"entry-{index}" for index in ids],
|
||||
"issue_decisions": [{"job_id": f"job-{index}", "decision": "accept", "reason": f"Reason {index}"} for index in ids],
|
||||
**overrides,
|
||||
}
|
||||
return routes.set_version_review_state("campaign-1", "version-1", CampaignReviewStateRequest(**payload), session=session or review.session, principal=_principal(actor))
|
||||
|
||||
|
||||
def test_partial_reason_and_reviewed_state_survive_fresh_reload_without_completion(review):
|
||||
response = _save(review)
|
||||
review.session.expire_all()
|
||||
stored = review.session.get(CampaignVersion, "version-1")
|
||||
state = stored.editor_state["review_send"]
|
||||
assert state["inspection_complete"] is False
|
||||
assert state["reviewed_message_keys"] == ["entry-1"]
|
||||
assert state["issue_decisions"][0]["reason"] == "Reason 1"
|
||||
assert state["issue_decisions"][0]["actor_user_id"] == "reviewer-1"
|
||||
assert state["issue_decisions"][0]["message_sha256"] == "1" * 64
|
||||
assert response.edit_revision == 2
|
||||
assert response.review_build_token and "private-build-1" not in repr(response)
|
||||
public_state = response.editor_state["review_send"]
|
||||
assert public_state["review_build_token"] == response.review_build_token
|
||||
assert "build_token" not in public_state and "build_token" not in (response.build_summary or {})
|
||||
metadata, reviewed_keys = _review_metadata(review.session, stored, [CampaignJob.campaign_version_id == stored.id])
|
||||
assert metadata["reviewed_required_count"] == 1 and reviewed_keys == {"entry-1"}
|
||||
assert _reviewed_needs_review_keys(stored) == set(), "partial progress never authorizes delivery"
|
||||
assert review.audits[-1]["action"] == "campaign.message_review_updated"
|
||||
|
||||
|
||||
def test_second_reviewer_and_final_completion_preserve_first_decision_evidence(review):
|
||||
_save(review)
|
||||
first = copy.deepcopy(review.version.editor_state["review_send"]["issue_decisions"][0])
|
||||
_save(review, ids=(2,), actor="reviewer-2")
|
||||
state = review.version.editor_state["review_send"]
|
||||
assert state["reviewed_message_keys"] == ["entry-1", "entry-2"]
|
||||
assert state["issue_decisions"][0] == first
|
||||
second = copy.deepcopy(state["issue_decisions"][1])
|
||||
assert review.audits[-1]["details"]["issue_decisions"]["count"] == 1
|
||||
assert review.audits[-1]["details"]["issue_decisions"] == routes._review_decision_audit_evidence(review.version, job_ids={"job-2"})
|
||||
_save(review, ids=(), complete=True, actor="reviewer-3")
|
||||
review.session.expire_all()
|
||||
state = review.version.editor_state["review_send"]
|
||||
assert state["inspection_complete"] is True
|
||||
assert state["issue_decisions"] == [first, second]
|
||||
assert review.audits[-1]["details"]["issue_decisions"]["count"] == 2
|
||||
assert _reviewed_needs_review_keys(review.version) == {"entry-1", "entry-2"}
|
||||
|
||||
|
||||
def test_incremental_save_loads_only_selected_job_and_never_materializes_files(review):
|
||||
review.session.add_all([_job(index, validation="ready", behavior="continue") for index in range(3, 503)])
|
||||
review.session.commit()
|
||||
review.session.refresh(review.version)
|
||||
review.session.refresh(review.campaign)
|
||||
review.session.expunge_all()
|
||||
loaded_jobs = []
|
||||
def loaded(_session, instance):
|
||||
if isinstance(instance, CampaignJob):
|
||||
loaded_jobs.append(instance.id)
|
||||
event.listen(review.session, "loaded_as_persistent", loaded)
|
||||
try:
|
||||
with patch("govoplan_campaign.backend.persistence.campaigns.load_version_config", side_effect=AssertionError("Review progress must not rebuild or resolve Files")):
|
||||
_save(review)
|
||||
assert loaded_jobs == ["job-1"]
|
||||
finally:
|
||||
event.remove(review.session, "loaded_as_persistent", loaded)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mutation", ["unknown_job", "unknown_key", "missing_reason", "duplicate", "blocked", "hard_issue", "excluded", "category"])
|
||||
def test_invalid_incremental_acceptance_is_atomic(review, mutation):
|
||||
options = {}
|
||||
if mutation == "unknown_job":
|
||||
options["issue_decisions"] = [{"job_id": "outside-build", "reason": "Not allowed"}]
|
||||
elif mutation == "unknown_key":
|
||||
options["reviewed_message_keys"] = ["outside-build"]
|
||||
elif mutation == "missing_reason":
|
||||
options["issue_decisions"] = [{"job_id": "job-1", "reason": " "}]
|
||||
elif mutation == "duplicate":
|
||||
options["issue_decisions"] = [{"job_id": "job-1", "reason": "A"}, {"job_id": "job-1", "reason": "B"}]
|
||||
elif mutation == "category":
|
||||
options["decision_category_key"] = "wrong-category"
|
||||
else:
|
||||
job = review.session.get(CampaignJob, "job-1")
|
||||
if mutation == "hard_issue":
|
||||
job.issues_snapshot = [{"code": "required", "source": "attachments", "behavior": "block"}]
|
||||
else:
|
||||
job.validation_status = mutation
|
||||
review.session.commit()
|
||||
original = copy.deepcopy(review.version.editor_state)
|
||||
with pytest.raises(HTTPException) as error:
|
||||
_save(review, **options)
|
||||
assert error.value.status_code == 422
|
||||
review.session.expire_all()
|
||||
assert review.version.editor_state == original and review.version.edit_revision == 1
|
||||
assert review.audits == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("precondition", ["build", "revision"])
|
||||
def test_stale_progress_is_conflict_and_preserves_acknowledged_progress(review, precondition):
|
||||
_save(review)
|
||||
options = {"build_token": "old-build"} if precondition == "build" else {"base_revision": 1}
|
||||
with pytest.raises(HTTPException) as error:
|
||||
_save(review, ids=(2,), **options)
|
||||
assert error.value.status_code == 409
|
||||
assert review.version.editor_state["review_send"]["reviewed_message_keys"] == ["entry-1"]
|
||||
|
||||
|
||||
def test_same_category_bulk_is_bound_to_exact_selected_jobs(review):
|
||||
category = review_decision_metadata(review.session.get(CampaignJob, "job-1"))["category_key"]
|
||||
result = _save(review, ids=(1, 2), decision_category_key=category)
|
||||
assert result.editor_state["review_send"]["reviewed_message_keys"] == ["entry-1", "entry-2"]
|
||||
assert len(result.editor_state["review_send"]["issue_decisions"]) == 2
|
||||
|
||||
|
||||
def test_deliberately_excluded_and_inactive_rows_do_not_block_completion_or_need_bulk_acceptance(review):
|
||||
review.session.add_all([_job(3, validation="excluded", build="skipped", behavior="drop"), _job(4, validation="inactive", build="skipped", behavior="continue")])
|
||||
review.session.commit()
|
||||
_save(review, ids=(1, 2), complete=True)
|
||||
counts = _review_metadata_counts([(None, 3, "skipped", "excluded"), (None, 4, "skipped", "inactive")], set())
|
||||
assert counts == {"blocking_count": 0, "required_count": 0, "reviewed_required_count": 0, "bulk_acceptable_count": 0}
|
||||
assert review.version.editor_state["review_send"]["reviewed_message_keys"] == ["entry-1", "entry-2"]
|
||||
assert not review_decision_metadata(review.session.get(CampaignJob, "job-3"))["eligible"]
|
||||
|
||||
|
||||
def test_final_review_cannot_override_remaining_hard_blockers(review):
|
||||
review.session.add(_job(3, validation="blocked", build="build_failed", behavior="block"))
|
||||
review.session.commit()
|
||||
_save(review, ids=(1, 2))
|
||||
with pytest.raises(HTTPException, match="Blocked or failed"):
|
||||
_save(review, ids=(), complete=True)
|
||||
assert review.version.editor_state["review_send"]["inspection_complete"] is False
|
||||
|
||||
|
||||
def test_existing_frozen_blocker_is_not_reclassified_from_current_allowed_empty_settings(review):
|
||||
job = review.session.get(CampaignJob, "job-1")
|
||||
job.validation_status = "blocked"
|
||||
job.build_status = "build_failed"
|
||||
job.issues_snapshot = [{"code": "missing_required_attachment", "source": "attachments", "behavior": "block"}]
|
||||
review.version.raw_json = {**review.version.raw_json, "attachments": {"missing_behavior": "continue", "send_without_attachments_behavior": "continue"}}
|
||||
review.session.commit()
|
||||
evidence = copy.deepcopy(job.issues_snapshot)
|
||||
with pytest.raises(HTTPException):
|
||||
_save(review, ids=(1,))
|
||||
assert job.issues_snapshot == evidence and job.validation_status == "blocked"
|
||||
|
||||
|
||||
def test_concurrent_database_write_is_409_without_losing_other_reviewer(review):
|
||||
# Keep a genuinely stale ORM identity in a second transaction so the SQL
|
||||
# version-column check, rather than just the request comparison, must fire.
|
||||
with Session(review.engine) as stale_session:
|
||||
stale = stale_session.get(CampaignVersion, "version-1")
|
||||
assert stale is not None and stale.edit_revision == 1
|
||||
_save(review, ids=(1,))
|
||||
with pytest.raises(HTTPException) as error:
|
||||
_save(review, ids=(2,), session=stale_session, base_revision=1)
|
||||
assert error.value.status_code == 409
|
||||
review.session.expire_all()
|
||||
assert review.version.editor_state["review_send"]["reviewed_message_keys"] == ["entry-1"]
|
||||
assert len(review.audits) == 1
|
||||
|
||||
|
||||
def test_delivery_final_lock_rejects_incremental_acceptance(review):
|
||||
review.version.workflow_state = "completed"
|
||||
review.session.commit()
|
||||
with pytest.raises(HTTPException) as error:
|
||||
_save(review)
|
||||
assert error.value.status_code == 409
|
||||
assert "review_send" not in review.version.editor_state
|
||||
|
||||
|
||||
def test_owner_denial_rejects_before_persisting_review(review):
|
||||
with patch.object(routes, "_get_campaign_for_principal", side_effect=HTTPException(status_code=403, detail="Owner access denied")):
|
||||
with pytest.raises(HTTPException) as error:
|
||||
_save(review)
|
||||
assert error.value.status_code == 403
|
||||
assert "review_send" not in review.version.editor_state and review.audits == []
|
||||
|
||||
|
||||
def test_progress_contract_requires_both_preconditions():
|
||||
with pytest.raises(ValidationError, match="build_token and base_revision"):
|
||||
CampaignReviewStateRequest(merge_progress=True, build_token="build-1")
|
||||
@@ -0,0 +1,317 @@
|
||||
"""Exercise real version routes/persistence without transport or filesystem effects."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import Column, String, Table, create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend import route_support
|
||||
from govoplan_campaign.backend.archive_encryption import (
|
||||
CampaignArchiveEncryptionError,
|
||||
assert_archive_encryption_allowed,
|
||||
stamp_legacy_zipcrypto_acknowledgements,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
||||
CampaignMailProfileBoundaryError,
|
||||
assert_campaign_uses_mail_profile_reference,
|
||||
)
|
||||
from govoplan_campaign.backend.db.models import Campaign, CampaignIssue, CampaignVersion
|
||||
from govoplan_campaign.backend.integrations import MailProfileError
|
||||
from govoplan_campaign.backend.persistence.campaigns import (
|
||||
CampaignPersistenceError,
|
||||
build_campaign_version,
|
||||
load_campaign_config_from_json,
|
||||
validate_campaign_version,
|
||||
)
|
||||
from govoplan_campaign.backend.schemas import CampaignVersionDetailResponse, CampaignVersionUpdateRequest
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
def _principal(*scopes: str) -> ApiPrincipal:
|
||||
return ApiPrincipal(
|
||||
principal=PrincipalRef(
|
||||
account_id="account-1", membership_id="user-1", tenant_id="tenant-1",
|
||||
scopes=frozenset(scopes),
|
||||
),
|
||||
account=SimpleNamespace(id="account-1"),
|
||||
user=SimpleNamespace(id="user-1"),
|
||||
)
|
||||
|
||||
|
||||
def _legacy() -> dict:
|
||||
return {
|
||||
"version": "1.0",
|
||||
"campaign": {"id": "campaign-1", "name": "Campaign", "mode": "send"},
|
||||
"server": {
|
||||
"mail_profile_id": "profile-1",
|
||||
"smtp": {"host": "old.example.test", "password": "stored-secret"},
|
||||
"imap": None,
|
||||
"credentials": {},
|
||||
"inherit_smtp_credentials": True,
|
||||
},
|
||||
"recipients": {"from": [{"email": "sender@example.test"}]},
|
||||
"template": {"subject": "Subject", "text": "Body", "body_mode": "text"},
|
||||
"entries": {"inline": []},
|
||||
"attachments": {"zip": {"enabled": True, "archives": [{
|
||||
"id": "archive-1", "method": "zip_standard", "password_enabled": True,
|
||||
"password_delivery_channel": "separate_mail",
|
||||
}]}},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repair():
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
for name in ("access_users", "access_groups"):
|
||||
if name not in Base.metadata.tables:
|
||||
Table(name, Base.metadata, Column("id", String(36), primary_key=True))
|
||||
Base.metadata.create_all(engine, tables=[
|
||||
Base.metadata.tables["access_users"], Base.metadata.tables["access_groups"],
|
||||
ChangeSequenceEntry.__table__, Campaign.__table__, CampaignVersion.__table__,
|
||||
CampaignIssue.__table__,
|
||||
])
|
||||
with Session(engine) as session:
|
||||
campaign = Campaign(
|
||||
id="campaign-1", tenant_id="tenant-1", external_id="campaign-1",
|
||||
name="Campaign", current_version_id="draft-1",
|
||||
)
|
||||
draft = CampaignVersion(id="draft-1", campaign_id=campaign.id, version_number=2, raw_json=_legacy())
|
||||
history = CampaignVersion(id="history-1", campaign_id=campaign.id, version_number=1, raw_json=_legacy(), workflow_state="final")
|
||||
session.add_all((campaign, draft, history))
|
||||
session.commit()
|
||||
audits: list[dict] = []
|
||||
|
||||
def audit(_session, _principal, **kwargs):
|
||||
audits.append(kwargs)
|
||||
if kwargs.get("commit"):
|
||||
_session.commit()
|
||||
|
||||
integration = SimpleNamespace(assert_campaign_mail_policy_allows_json=Mock())
|
||||
with (
|
||||
patch.object(route_support, "_get_campaign_for_principal", return_value=campaign),
|
||||
patch.object(route_support, "audit_from_principal", side_effect=audit),
|
||||
patch("govoplan_campaign.backend.persistence.versions.mail_integration", return_value=integration),
|
||||
patch("govoplan_campaign.backend.archive_encryption.get_registry", return_value=None),
|
||||
):
|
||||
yield SimpleNamespace(session=session, campaign=campaign, draft=draft, history=history, integration=integration, audits=audits)
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _public(repair) -> dict:
|
||||
return CampaignVersionDetailResponse.model_validate(repair.draft).raw_json
|
||||
|
||||
|
||||
def _save(repair, raw: dict, *, migrate: bool = False, principal=None):
|
||||
return route_support._update_campaign_version_detail_response(
|
||||
repair.session, principal or _principal("mail:profile:use"),
|
||||
repair.campaign.id, repair.draft.id,
|
||||
CampaignVersionUpdateRequest(
|
||||
campaign_json=raw, base_revision=repair.draft.edit_revision,
|
||||
migrate_legacy_mail_settings=migrate,
|
||||
),
|
||||
if_match=repair.draft.strong_etag, autosave=True,
|
||||
audit_action="campaign.version_autosaved",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mail_first", [True, False])
|
||||
def test_independent_repairs_persist_in_either_order_without_altering_history(repair, mail_first):
|
||||
original_zip = copy.deepcopy(repair.draft.raw_json["attachments"]["zip"])
|
||||
original_server = copy.deepcopy(repair.draft.raw_json["server"])
|
||||
first = _public(repair)
|
||||
if not mail_first:
|
||||
first["attachments"]["zip"]["archives"][0]["method"] = "aes"
|
||||
result = _save(repair, first, migrate=mail_first)
|
||||
repair.session.expire_all()
|
||||
assert repair.draft.edit_revision == 2
|
||||
assert result.mail_profile_migration_required is not mail_first
|
||||
assert "stored-secret" not in repr(result)
|
||||
assert repair.draft.build_summary is None
|
||||
assert repair.draft.execution_snapshot is None
|
||||
if mail_first:
|
||||
assert repair.draft.raw_json["attachments"]["zip"] == original_zip
|
||||
assert repair.draft.raw_json["server"] == {"mail_profile_id": "profile-1"}
|
||||
# Real validation/build gates still reject the unresolved ZIP policy.
|
||||
for action in (validate_campaign_version, build_campaign_version):
|
||||
with patch(
|
||||
"govoplan_campaign.backend.persistence.campaigns.load_version_config",
|
||||
return_value=(repair.draft, None, object()),
|
||||
), pytest.raises(CampaignPersistenceError, match="blocked"):
|
||||
action(repair.session, tenant_id="tenant-1", version_id=repair.draft.id)
|
||||
else:
|
||||
assert repair.draft.raw_json["server"] == original_server
|
||||
repair.integration.assert_campaign_mail_policy_allows_json.assert_not_called()
|
||||
# The authoritative config loader still blocks legacy transport.
|
||||
with pytest.raises(CampaignMailProfileBoundaryError, match="remove campaign-local"):
|
||||
load_campaign_config_from_json(repair.session, tenant_id="tenant-1", raw_json=repair.draft.raw_json)
|
||||
|
||||
second = _public(repair)
|
||||
if mail_first:
|
||||
second["attachments"]["zip"]["archives"][0]["method"] = "aes"
|
||||
result = _save(repair, second, migrate=not mail_first)
|
||||
repair.session.expire_all()
|
||||
assert repair.draft.edit_revision == 3
|
||||
assert not result.mail_profile_migration_required
|
||||
assert_campaign_uses_mail_profile_reference(repair.draft.raw_json)
|
||||
assert_archive_encryption_allowed(repair.session, repair.campaign, repair.draft.raw_json)
|
||||
assert repair.history.raw_json == _legacy()
|
||||
assert len(repair.audits) == 2
|
||||
assert repair.audits[0]["details"]["legacy_mail_settings_preserved"] is not mail_first
|
||||
assert repair.audits[0]["details"]["legacy_mail_settings_migrated"] is mail_first
|
||||
assert not any(item["details"]["legacy_zipcrypto_acknowledgements"] for item in repair.audits)
|
||||
|
||||
|
||||
def test_zip_repair_does_not_require_use_of_unchanged_revoked_mail_profile(repair):
|
||||
repair.integration.assert_campaign_mail_policy_allows_json.side_effect = MailProfileError("Profile revoked")
|
||||
raw = _public(repair)
|
||||
raw["attachments"]["zip"]["archives"][0]["method"] = "aes"
|
||||
result = _save(repair, raw, principal=_principal())
|
||||
assert result.mail_profile_migration_required
|
||||
repair.integration.assert_campaign_mail_policy_allows_json.assert_not_called()
|
||||
with pytest.raises(HTTPException) as denied:
|
||||
_save(repair, _public(repair), migrate=True, principal=_principal())
|
||||
assert denied.value.status_code == 403
|
||||
assert "mail:profile:use" in denied.value.detail
|
||||
with pytest.raises(HTTPException, match="Profile revoked"):
|
||||
_save(repair, _public(repair), migrate=True)
|
||||
assert repair.draft.mail_profile_migration_required
|
||||
|
||||
|
||||
def test_archive_repair_is_saveable_after_mail_migration_and_a_stricter_credential_policy(repair):
|
||||
_save(repair, _public(repair), migrate=True)
|
||||
repair.integration.assert_campaign_mail_policy_allows_json.reset_mock()
|
||||
repair.integration.assert_campaign_mail_policy_allows_json.side_effect = MailProfileError(
|
||||
"SMTP credential policy requires an explicit credential selection"
|
||||
)
|
||||
raw = _public(repair)
|
||||
raw["attachments"]["zip"]["archives"][0]["method"] = "aes"
|
||||
result = _save(repair, raw, principal=_principal())
|
||||
assert not result.mail_profile_migration_required
|
||||
assert result.raw_json["server"] == {"mail_profile_id": "profile-1"}
|
||||
assert result.raw_json["attachments"]["zip"]["archives"][0]["method"] == "aes"
|
||||
repair.integration.assert_campaign_mail_policy_allows_json.assert_not_called()
|
||||
changed_mail = _public(repair)
|
||||
changed_mail["server"]["smtp_server_id"] = "smtp-1"
|
||||
with pytest.raises(HTTPException) as missing_permission:
|
||||
_save(repair, changed_mail, principal=_principal())
|
||||
assert missing_permission.value.status_code == 403
|
||||
with pytest.raises(HTTPException, match="explicit credential selection"):
|
||||
_save(repair, changed_mail)
|
||||
assert repair.draft.raw_json["server"] == {"mail_profile_id": "profile-1"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mutation", ["new_profile", "remove_profile", "credential", "inline", "echo_inline"])
|
||||
def test_archive_save_cannot_modify_or_introduce_mail_transport_without_migration(repair, mutation):
|
||||
raw = _public(repair)
|
||||
raw["attachments"]["zip"]["archives"][0]["method"] = "aes"
|
||||
if mutation == "new_profile":
|
||||
raw["server"]["mail_profile_id"] = "other-profile"
|
||||
elif mutation == "remove_profile":
|
||||
raw["server"] = {}
|
||||
elif mutation == "credential":
|
||||
raw["server"].update(smtp_server_id="smtp-1", smtp_credential_id="credential-1")
|
||||
elif mutation == "inline":
|
||||
raw["server"]["smtp"] = {"password": "injected-secret"}
|
||||
else:
|
||||
raw["server"] = copy.deepcopy(repair.draft.raw_json["server"])
|
||||
with pytest.raises(HTTPException):
|
||||
_save(repair, raw)
|
||||
assert repair.draft.raw_json == _legacy()
|
||||
assert repair.draft.edit_revision == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mutation", ["acknowledgement", "reason", "actor", "method", "channel", "typed_boolean"])
|
||||
def test_mail_migration_cannot_grandfather_modified_zip_settings(repair, mutation):
|
||||
raw = _public(repair)
|
||||
archive = raw["attachments"]["zip"]["archives"][0]
|
||||
if mutation == "typed_boolean":
|
||||
archive["password_enabled"] = 1 # Python True == 1 is not exact JSON equality.
|
||||
elif mutation == "method":
|
||||
archive["method"] = "unknown"
|
||||
elif mutation == "channel":
|
||||
archive["method"] = "aes"
|
||||
archive["password_delivery_channel"] = "same_mail"
|
||||
else:
|
||||
archive[{
|
||||
"acknowledgement": "legacy_zipcrypto_acknowledged",
|
||||
"reason": "legacy_zipcrypto_reason",
|
||||
"actor": "legacy_zipcrypto_acknowledged_by",
|
||||
}[mutation]] = True if mutation == "acknowledgement" else "forged evidence"
|
||||
with pytest.raises(HTTPException):
|
||||
_save(repair, raw, migrate=True)
|
||||
assert repair.draft.raw_json == _legacy()
|
||||
assert repair.draft.edit_revision == 1
|
||||
|
||||
|
||||
def test_unchanged_zip_is_not_restamped_even_after_legacy_permission_revocation(repair):
|
||||
raw = _public(repair)
|
||||
archive = raw["attachments"]["zip"]["archives"][0]
|
||||
archive.update(
|
||||
legacy_zipcrypto_acknowledged=True,
|
||||
legacy_zipcrypto_reason="Existing recipient compatibility requirement",
|
||||
legacy_zipcrypto_acknowledged_by="original-actor",
|
||||
legacy_zipcrypto_acknowledged_at="2026-08-01T10:00:00+00:00",
|
||||
)
|
||||
repair.draft.raw_json = {**copy.deepcopy(repair.draft.raw_json), "attachments": copy.deepcopy(raw["attachments"])}
|
||||
repair.session.commit()
|
||||
result = _save(repair, raw, migrate=True, principal=_principal("mail:profile:use"))
|
||||
assert result.raw_json["attachments"]["zip"]["archives"][0] == archive
|
||||
assert repair.audits[-1]["details"]["legacy_zipcrypto_acknowledgements"] == []
|
||||
with pytest.raises(CampaignArchiveEncryptionError, match="blocked"):
|
||||
assert_archive_encryption_allowed(repair.session, repair.campaign, repair.draft.raw_json)
|
||||
with patch(
|
||||
"govoplan_campaign.backend.archive_encryption.effective_archive_encryption_policy",
|
||||
return_value=SimpleNamespace(
|
||||
available=True, allowed_password_encryption_methods={"aes", "zip_standard"},
|
||||
allowed_password_delivery_channels={"separate_mail"}, reason="Policy allows legacy",
|
||||
),
|
||||
), pytest.raises(CampaignArchiveEncryptionError, match="Missing scope"):
|
||||
assert_archive_encryption_allowed(
|
||||
repair.session, repair.campaign, repair.draft.raw_json,
|
||||
principal=_principal("mail:profile:use"),
|
||||
)
|
||||
|
||||
|
||||
def test_unchanged_missing_zip_remains_noop_without_policy_lookup(repair):
|
||||
with patch("govoplan_campaign.backend.archive_encryption.effective_archive_encryption_policy") as policy:
|
||||
candidate, evidence = stamp_legacy_zipcrypto_acknowledgements(
|
||||
repair.session, repair.campaign, {}, {"template": {"text": "New"}}, principal=_principal(),
|
||||
)
|
||||
assert candidate == {"template": {"text": "New"}}
|
||||
assert evidence == []
|
||||
policy.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target", ["individual", "global"])
|
||||
def test_recipient_address_order_round_trips_through_the_real_save_route(repair, target):
|
||||
addresses = [
|
||||
{"name": "Zulu", "email": "zulu@example.test"},
|
||||
{"name": "Alpha", "email": "alpha@example.test"},
|
||||
{"name": "Beta", "email": "beta@example.test"},
|
||||
]
|
||||
raw = _public(repair)
|
||||
if target == "individual":
|
||||
raw["entries"]["inline"] = [{"id": "entry-1", "to": addresses}]
|
||||
else:
|
||||
raw["recipients"]["to"] = addresses
|
||||
result = _save(repair, raw, principal=_principal("campaigns:recipient:write"))
|
||||
repair.session.expire_all()
|
||||
persisted = repair.session.get(CampaignVersion, "draft-1")
|
||||
assert persisted is not None
|
||||
if target == "individual":
|
||||
assert persisted.raw_json["entries"]["inline"][0]["to"] == addresses
|
||||
assert result.raw_json["entries"]["inline"][0]["to"] == addresses
|
||||
else:
|
||||
assert persisted.raw_json["recipients"]["to"] == addresses
|
||||
assert result.raw_json["recipients"]["to"] == addresses
|
||||
assert persisted.mail_profile_migration_required
|
||||
assert persisted.edit_revision == 2
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine
|
||||
@@ -12,6 +14,8 @@ from govoplan_campaign.backend.services.job_queries import (
|
||||
_campaign_jobs_grid_filter_expressions,
|
||||
_campaign_jobs_ordering,
|
||||
_campaign_jobs_page_response,
|
||||
_campaign_jobs_query_context,
|
||||
_public_recipient_groups,
|
||||
)
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.db.base import Base
|
||||
@@ -119,6 +123,56 @@ class CampaignJobListQueryTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(raised.exception.status_code, 422)
|
||||
|
||||
def test_recipient_projection_preserves_each_group_order_without_extra_fields(self) -> None:
|
||||
recipients = {"to": [{"email": "second@example.test", "name": "Second", "secret": "never-project"},
|
||||
{"email": "first@example.test"}],
|
||||
"cc": [{"email": "copy@example.test"}], "bcc": [{"email": "blind@example.test"}],
|
||||
"from": {"email": "sender@example.test"}, "private": "never-project"}
|
||||
result = _public_recipient_groups(recipients)
|
||||
self.assertEqual(list(result), ["to", "cc", "bcc"])
|
||||
self.assertEqual([value["email"] for value in result["to"]], ["second@example.test", "first@example.test"])
|
||||
self.assertNotIn("never-project", str(result))
|
||||
|
||||
def test_each_frozen_recipient_group_is_searchable_before_pagination(self) -> None:
|
||||
row = self.session.get(CampaignJob, "job-0")
|
||||
row.resolved_recipients = {"to": [{"email": "second-to@example.test"}],
|
||||
"cc": [{"email": "copy@example.test"}], "bcc": [{"email": "blind@example.test"}]}
|
||||
self.session.commit()
|
||||
for recipient in ("second-to", "copy@", "blind@"):
|
||||
with self.subTest(recipient=recipient):
|
||||
filters = _campaign_jobs_grid_filter_expressions({"recipient": recipient})
|
||||
page = _campaign_jobs_page_response(self.session, campaign_id="campaign-1", version_id="version-1",
|
||||
base_filters=[CampaignJob.tenant_id == "tenant-1"], filtered=filters,
|
||||
reviewed_keys=set(), review_metadata={}, page=1, page_size=1, grid_filters={"recipient": recipient})
|
||||
self.assertEqual(page.total, 1)
|
||||
self.assertEqual(page.jobs[0]["id"], "job-0")
|
||||
self.assertEqual(page.jobs[0]["resolved_recipients"]["bcc"], [{"email": "blind@example.test"}])
|
||||
|
||||
def test_free_search_also_matches_additional_recipients_and_keeps_tenant_scope(self) -> None:
|
||||
row = self.session.get(CampaignJob, "job-0")
|
||||
row.resolved_recipients = {"bcc": [{"email": "additional@example.test"}]}
|
||||
other = self.session.get(CampaignJob, "job-1")
|
||||
other.tenant_id = "other-tenant"
|
||||
other.resolved_recipients = row.resolved_recipients
|
||||
self.session.commit()
|
||||
principal = SimpleNamespace(tenant_id="tenant-1", has=lambda scope: scope == "campaigns:recipient:read")
|
||||
with patch("govoplan_campaign.backend.services.job_queries._get_campaign_for_principal"), \
|
||||
patch("govoplan_campaign.backend.services.job_queries._get_campaign_for_tenant", return_value=SimpleNamespace(id="campaign-1")), \
|
||||
patch("govoplan_campaign.backend.services.job_queries._review_metadata", return_value=({}, set())):
|
||||
_, _, filters, _, _ = _campaign_jobs_query_context(self.session, principal, campaign_id="campaign-1", version_id=None,
|
||||
send_status=None, validation_status=None, imap_status=None, query_text="additional@example.test")
|
||||
self.assertEqual([job.id for job in self.session.query(CampaignJob).filter(*filters)], ["job-0"])
|
||||
|
||||
def test_recipient_read_is_required_before_additional_addresses_are_queried(self) -> None:
|
||||
principal = SimpleNamespace(tenant_id="tenant-1", has=lambda _scope: False)
|
||||
with patch("govoplan_campaign.backend.services.job_queries._get_campaign_for_principal"), \
|
||||
patch("govoplan_campaign.backend.services.job_queries._get_campaign_for_tenant") as lookup:
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
_campaign_jobs_query_context(self.session, principal, campaign_id="campaign-1", version_id=None,
|
||||
send_status=None, validation_status=None, imap_status=None, query_text="blind@example.test")
|
||||
self.assertEqual(raised.exception.status_code, 403)
|
||||
lookup.assert_not_called()
|
||||
|
||||
def test_skipped_transport_filters_and_counts_remain_separate(self) -> None:
|
||||
base_filters = [CampaignJob.tenant_id == "tenant-1", CampaignJob.campaign_id == "campaign-1"]
|
||||
grid_filters = {"send": 'list:["skipped"]', "imap": 'list:["skipped"]'}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import call, patch
|
||||
from unittest.mock import Mock, call, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import Column, String, Table, create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend import route_support
|
||||
from govoplan_campaign.backend.routes import attachments as attachment_routes
|
||||
@@ -17,11 +19,14 @@ from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
||||
campaign_mail_profile_id,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.models import DeliveryConfig
|
||||
from govoplan_campaign.backend.db.models import Campaign, CampaignIssue, CampaignVersion
|
||||
from govoplan_campaign.backend.persistence.campaigns import CampaignPersistenceError, load_campaign_config_from_json
|
||||
from govoplan_campaign.backend.persistence.versions import update_campaign_version
|
||||
from govoplan_campaign.backend.integrations import MailCampaignIntegration
|
||||
from govoplan_campaign.backend.persistence.versions import _updated_runtime_json, update_campaign_version
|
||||
from govoplan_campaign.backend.integrations import MailCampaignIntegration, MailProfileError
|
||||
from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError, create_execution_snapshot, ensure_execution_snapshot
|
||||
from govoplan_campaign.backend.schemas import CampaignVersionUpdateRequest
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
def _campaign_json(server: dict[str, object] | None = None) -> dict[str, object]:
|
||||
@@ -176,7 +181,7 @@ def test_new_execution_snapshot_stores_reference_and_evidence_not_transport_mate
|
||||
delivery=DeliveryConfig(),
|
||||
)
|
||||
|
||||
assert payload["snapshot_version"] == "8"
|
||||
assert payload["snapshot_version"] == "9"
|
||||
assert payload["mail_profile_id"] == "profile-1"
|
||||
assert "smtp" not in payload
|
||||
assert "imap" not in payload
|
||||
@@ -237,6 +242,133 @@ def test_editing_a_legacy_record_requires_an_explicit_profile_migration() -> Non
|
||||
assert legacy_raw["server"]["smtp"]["password"] == "secret" # type: ignore[index]
|
||||
|
||||
|
||||
def test_explicit_legacy_migration_accepts_an_unchanged_authorized_profile_reference() -> None:
|
||||
legacy = _campaign_json({
|
||||
"mail_profile_id": "profile-1",
|
||||
"smtp": {"host": "smtp.example.test", "password": "legacy-secret"},
|
||||
"imap": None,
|
||||
"credentials": {},
|
||||
"inherit_smtp_credentials": True,
|
||||
"inherit_imap_credentials": True,
|
||||
})
|
||||
submitted = _campaign_json({"mail_profile_id": "profile-1"})
|
||||
integration = SimpleNamespace(assert_campaign_mail_policy_allows_json=Mock())
|
||||
session = object()
|
||||
with patch("govoplan_campaign.backend.persistence.versions.mail_integration", return_value=integration):
|
||||
result = _updated_runtime_json(
|
||||
session, # type: ignore[arg-type]
|
||||
tenant_id="tenant-1",
|
||||
campaign=SimpleNamespace(id="campaign-1"),
|
||||
version=SimpleNamespace(raw_json=legacy),
|
||||
raw_json=submitted,
|
||||
source_base_path=None,
|
||||
migrate_legacy_mail_settings=True,
|
||||
)
|
||||
|
||||
assert result == submitted
|
||||
assert result is not submitted
|
||||
assert campaign_mail_profile_boundary_violations(result) == ()
|
||||
assert "legacy-secret" not in repr(result)
|
||||
assert legacy["server"]["smtp"]["password"] == "legacy-secret" # type: ignore[index]
|
||||
integration.assert_campaign_mail_policy_allows_json.assert_called_once_with(
|
||||
session, tenant_id="tenant-1", raw_json=result, campaign_id="campaign-1"
|
||||
)
|
||||
|
||||
|
||||
def test_explicit_legacy_migration_requires_a_profile_and_preserves_mail_authorization() -> None:
|
||||
legacy = _campaign_json({"smtp": {"password": "legacy-secret"}})
|
||||
integration = SimpleNamespace(assert_campaign_mail_policy_allows_json=Mock(
|
||||
side_effect=MailProfileError("Mail profile is not authorized for this campaign")
|
||||
))
|
||||
with patch("govoplan_campaign.backend.persistence.versions.mail_integration", return_value=integration):
|
||||
for submitted, expected_error in (
|
||||
(_campaign_json(), CampaignPersistenceError),
|
||||
(_campaign_json({"mail_profile_id": "unauthorized-profile"}), MailProfileError),
|
||||
(_campaign_json({"mail_profile_id": "profile-1", "smtp": {}}), CampaignMailProfileBoundaryError),
|
||||
):
|
||||
with pytest.raises(expected_error):
|
||||
_updated_runtime_json(
|
||||
object(), # type: ignore[arg-type]
|
||||
tenant_id="tenant-1",
|
||||
campaign=SimpleNamespace(id="campaign-1"),
|
||||
version=SimpleNamespace(raw_json=legacy),
|
||||
raw_json=submitted,
|
||||
source_base_path=None,
|
||||
migrate_legacy_mail_settings=True,
|
||||
)
|
||||
assert integration.assert_campaign_mail_policy_allows_json.call_count == 1
|
||||
assert legacy["server"]["smtp"]["password"] == "legacy-secret" # type: ignore[index]
|
||||
|
||||
|
||||
def test_persisted_legacy_draft_can_migrate_then_save_other_content_without_changing_history() -> None:
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
for table_name in ("access_users", "access_groups"):
|
||||
if table_name not in Base.metadata.tables:
|
||||
Table(table_name, Base.metadata, Column("id", String(36), primary_key=True))
|
||||
Base.metadata.create_all(engine, tables=[
|
||||
Base.metadata.tables["access_users"],
|
||||
Base.metadata.tables["access_groups"],
|
||||
ChangeSequenceEntry.__table__,
|
||||
Campaign.__table__,
|
||||
CampaignVersion.__table__,
|
||||
CampaignIssue.__table__,
|
||||
])
|
||||
legacy = _campaign_json({
|
||||
"mail_profile_id": "profile-1",
|
||||
"smtp": {"host": "old.example.test", "password": "historical-secret"},
|
||||
"imap": None,
|
||||
"credentials": {},
|
||||
"inherit_smtp_credentials": True,
|
||||
"inherit_imap_credentials": True,
|
||||
})
|
||||
integration = SimpleNamespace(assert_campaign_mail_policy_allows_json=Mock())
|
||||
try:
|
||||
with Session(engine) as session, patch(
|
||||
"govoplan_campaign.backend.persistence.versions.mail_integration", return_value=integration
|
||||
):
|
||||
campaign = Campaign(id="campaign-1", tenant_id="tenant-1", external_id="campaign-1", name="Campaign", current_version_id="draft-1")
|
||||
historical = CampaignVersion(id="history-1", campaign_id=campaign.id, version_number=1, raw_json=legacy, workflow_state="final")
|
||||
draft = CampaignVersion(id="draft-1", campaign_id=campaign.id, version_number=2, raw_json=legacy, editor_state={"created_from": "minimal_campaign"})
|
||||
session.add_all((campaign, historical, draft))
|
||||
session.commit()
|
||||
original_revision = draft.edit_revision
|
||||
assert draft.mail_profile_migration_required
|
||||
|
||||
migrated = update_campaign_version(
|
||||
session,
|
||||
tenant_id="tenant-1", campaign_id=campaign.id, version_id=draft.id,
|
||||
raw_json=_campaign_json({"mail_profile_id": "profile-1"}),
|
||||
editor_state={"created_from": "minimal_campaign"},
|
||||
expected_revision=original_revision,
|
||||
migrate_legacy_mail_settings=True,
|
||||
autosave=True,
|
||||
)
|
||||
session.expire_all()
|
||||
persisted = session.get(CampaignVersion, migrated.id)
|
||||
assert persisted is not None
|
||||
assert not persisted.mail_profile_migration_required
|
||||
assert persisted.raw_json["server"] == {"mail_profile_id": "profile-1"}
|
||||
assert persisted.edit_revision > original_revision
|
||||
assert persisted.autosaved_at is not None
|
||||
assert "historical-secret" not in repr(persisted.raw_json)
|
||||
assert session.get(CampaignVersion, historical.id).raw_json == legacy
|
||||
|
||||
edited = _campaign_json({"mail_profile_id": "profile-1"})
|
||||
edited["template"]["subject"] = "Edited after migration" # type: ignore[index]
|
||||
updated = update_campaign_version(
|
||||
session,
|
||||
tenant_id="tenant-1", campaign_id=campaign.id, version_id=persisted.id,
|
||||
raw_json=edited,
|
||||
expected_revision=persisted.edit_revision,
|
||||
)
|
||||
session.expire_all()
|
||||
assert session.get(CampaignVersion, updated.id).raw_json["template"]["subject"] == "Edited after migration"
|
||||
assert session.get(CampaignVersion, historical.id).raw_json == legacy
|
||||
assert integration.assert_campaign_mail_policy_allows_json.call_count == 1
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_fork_inherited_profile_requires_mail_profile_use_scope() -> None:
|
||||
principal = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
|
||||
@@ -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",)
|
||||
@@ -71,3 +73,21 @@ def test_reusable_template_library_is_not_owned_by_campaign() -> None:
|
||||
assert "/templates" not in {item.path for item in manifest.nav_items}
|
||||
assert "/templates" not in {item.path for item in manifest.frontend.nav_items}
|
||||
assert "/templates" not in {route.path for route in manifest.frontend.routes}
|
||||
|
||||
|
||||
def test_quick_access_selects_exact_campaigns_only_for_active_cases() -> None:
|
||||
manifest = get_manifest()
|
||||
assert manifest.frontend is not None
|
||||
|
||||
tool = next(
|
||||
item
|
||||
for item in manifest.frontend.quick_access_tools
|
||||
if item.id == "campaigns.select"
|
||||
)
|
||||
assert tool.availability == "active_object"
|
||||
assert tool.accepted_reference_kinds == ("cases.case",)
|
||||
assert tool.returned_reference_kinds == ("campaigns.campaign",)
|
||||
assert tool.modes == ("select",)
|
||||
assert "campaigns.workflow.link-exact-campaign-to-case" in {
|
||||
topic.id for topic in manifest.documentation
|
||||
}
|
||||
|
||||
@@ -138,3 +138,39 @@ def test_eml_retention_removes_only_terminal_artifact(tmp_path) -> None:
|
||||
assert job.eml_local_path is None
|
||||
assert job.eml_storage_key is None
|
||||
session.add.assert_called_once_with(job)
|
||||
|
||||
|
||||
def test_eml_retention_preserves_unfinished_autonomous_schedule_source(tmp_path) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
eml_path = tmp_path / "approved-source.eml"
|
||||
eml_path.write_bytes(b"approved message")
|
||||
job = SimpleNamespace(
|
||||
campaign_id="campaign-1",
|
||||
campaign_version_id="version-1",
|
||||
updated_at=now - timedelta(days=10),
|
||||
queue_status="draft",
|
||||
send_status="smtp_accepted",
|
||||
imap_status="appended",
|
||||
eml_local_path=str(eml_path),
|
||||
eml_storage_key=None,
|
||||
)
|
||||
schedule_query = MagicMock()
|
||||
schedule_query.filter.return_value.all.return_value = [("version-1",)]
|
||||
job_query = MagicMock()
|
||||
job_query.filter.return_value.order_by.return_value.all.return_value = [job]
|
||||
session = MagicMock()
|
||||
session.query.side_effect = [schedule_query, job_query]
|
||||
policy = SimpleNamespace(generated_eml_retention_days=1)
|
||||
|
||||
result = _apply_eml_retention(
|
||||
session,
|
||||
dry_run=False,
|
||||
now=now,
|
||||
policy_for_campaign_id=lambda _campaign_id: policy,
|
||||
)
|
||||
|
||||
assert result["skipped_schedule_source"] == 1
|
||||
assert result["metadata_cleared"] == 0
|
||||
assert eml_path.exists()
|
||||
assert job.eml_local_path == str(eml_path)
|
||||
session.add.assert_not_called()
|
||||
|
||||
@@ -92,6 +92,43 @@ def test_attachment_block_cannot_be_overridden_by_review_decision() -> None:
|
||||
assert decisions == []
|
||||
|
||||
|
||||
def test_attachment_reuse_review_requires_reason_and_captures_policy() -> None:
|
||||
job = _job(
|
||||
issues=[{
|
||||
"code": "duplicate_attachment_reuse",
|
||||
"behavior": "ask",
|
||||
"source": "attachments:reuse_policy",
|
||||
"details": {
|
||||
"file_fingerprint": "f" * 64,
|
||||
"policy": {"action": "review", "allow_within": "none"},
|
||||
},
|
||||
}]
|
||||
)
|
||||
|
||||
with pytest.raises(CampaignPersistenceError, match="require an explicit reason"):
|
||||
_normalize_review_issue_decisions(
|
||||
[job],
|
||||
[],
|
||||
user_id="reviewer-1",
|
||||
build_token="build-1",
|
||||
)
|
||||
|
||||
decisions = _normalize_review_issue_decisions(
|
||||
[job],
|
||||
[{
|
||||
"job_id": job.id,
|
||||
"decision": "accept",
|
||||
"reason": "The shared statutory notice is intentionally identical.",
|
||||
}],
|
||||
user_id="reviewer-1",
|
||||
build_token="build-1",
|
||||
)
|
||||
|
||||
assert decisions[0]["issue_codes"] == ["duplicate_attachment_reuse"]
|
||||
assert decisions[0]["reason"] == "The shared statutory notice is intentionally identical."
|
||||
assert len(decisions[0]["issue_fingerprint"]) == 64
|
||||
|
||||
|
||||
def _job(*, issues: list[dict[str, object]]) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
id="job-1",
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Mock accepted exceptions using real frozen jobs/EML, never new live effects."""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
from email import policy
|
||||
from email.message import EmailMessage
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from test_incremental_review_persistence import review, _save
|
||||
from govoplan_campaign.backend.campaign.models import DeliveryConfig
|
||||
from govoplan_campaign.backend.db.models import CampaignJob
|
||||
from govoplan_campaign.backend.dev import mock_campaign
|
||||
from govoplan_campaign.backend.persistence.campaigns import CampaignPersistenceError
|
||||
from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError, create_execution_snapshot
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def frozen(review, tmp_path):
|
||||
review.version.raw_json = {**review.version.raw_json, "server": {"mail_profile_id": "profile-1"}}
|
||||
jobs = review.session.query(CampaignJob).order_by(CampaignJob.entry_index).all()
|
||||
for job in jobs:
|
||||
job.subject = f"Frozen subject {job.entry_index}"
|
||||
job.resolved_recipients = {"from": {"email": "sender@example.test"}, "to": [{"email": f"recipient-{job.entry_index}@example.test"}]}
|
||||
job.issues_snapshot = [{**item, "severity": "warning", "message": "Explicit attachment exception"} for item in job.issues_snapshot]
|
||||
message = EmailMessage()
|
||||
message["From"] = "sender@example.test"
|
||||
message["To"] = f"recipient-{job.entry_index}@example.test"
|
||||
message["Subject"] = job.subject
|
||||
message["Date"] = "Mon, 07 Sep 2026 09:00:00 +0200"
|
||||
message["Message-ID"] = f"<frozen-{job.entry_index}@example.test>"
|
||||
message.set_content(f"Frozen reviewed body {job.entry_index}")
|
||||
payload = message.as_bytes(policy=policy.SMTP)
|
||||
path = tmp_path / f"message-{job.entry_index}.eml"
|
||||
path.write_bytes(payload)
|
||||
job.eml_local_path = str(path)
|
||||
job.eml_size_bytes = len(payload)
|
||||
job.eml_sha256 = hashlib.sha256(payload).hexdigest()
|
||||
job.message_id_header = str(message["Message-ID"])
|
||||
review.session.flush()
|
||||
payload, digest = create_execution_snapshot(review.version, mail_profile_id="profile-1",
|
||||
smtp_transport_revision="smtp-1", imap_transport_revision="imap-1", delivery=DeliveryConfig(),
|
||||
jobs=jobs, build_summary=review.version.build_summary)
|
||||
review.version.execution_snapshot = payload
|
||||
review.version.execution_snapshot_hash = digest
|
||||
review.session.commit()
|
||||
_save(review, ids=(1, 2), complete=True)
|
||||
mailbox = Mock()
|
||||
mailbox.consume_fail_next_smtp.return_value = False
|
||||
mailbox.consume_fail_next_imap.return_value = False
|
||||
mailbox.get_failures.return_value = {}
|
||||
mailbox.record_smtp_delivery.return_value = SimpleNamespace(id="mock-1")
|
||||
mailbox.record_imap_append.return_value = SimpleNamespace(id="mock-imap-1")
|
||||
mailbox.list_records.return_value = []
|
||||
with (
|
||||
patch.object(mock_campaign, "_mock_mailbox", return_value=mailbox),
|
||||
patch("govoplan_campaign.backend.sending.execution.files_integration", return_value=SimpleNamespace(available=False)),
|
||||
patch("govoplan_campaign.backend.sending.execution.assert_archive_encryption_allowed", return_value=SimpleNamespace(policy_hash="archive-policy")),
|
||||
patch("govoplan_campaign.backend.sending.execution.profile_delivery_summary", return_value={"smtp_transport_revision": "smtp-1", "imap_transport_revision": "imap-1"}) as transport,
|
||||
):
|
||||
yield SimpleNamespace(**vars(review), jobs=jobs, mailbox=mailbox, transport=transport)
|
||||
|
||||
|
||||
def _run(frozen, **options):
|
||||
return mock_campaign.run_mock_campaign_send(frozen.session, tenant_id="tenant-1", campaign_id="campaign-1", version_id="version-1",
|
||||
use_reviewed_build=True, include_needs_review=False, send=True, **options)
|
||||
|
||||
|
||||
def test_mock_reuses_reviewed_frozen_bytes_and_decisions_without_reasking_or_mutating_campaign(frozen):
|
||||
original = copy.deepcopy(frozen.version.editor_state)
|
||||
revision = frozen.version.edit_revision
|
||||
with patch.object(mock_campaign, "_build_mock_campaign_run", side_effect=AssertionError("Do not rebuild reviewed messages")):
|
||||
result = _run(frozen)
|
||||
assert result["send"]["sent_count"] == 2 and result["send"]["skipped_count"] == 0
|
||||
assert result["use_reviewed_build"] is True and result["build"]["review_satisfied"] is True
|
||||
assert [step["status"] for step in result["steps"]] == ["ok", "ok", "ok", "ok"]
|
||||
assert frozen.mailbox.record_smtp_delivery.call_count == 2
|
||||
captured = frozen.mailbox.record_smtp_delivery.call_args_list[0].args[0]
|
||||
assert str(captured["Message-ID"]) == "<frozen-1@example.test>"
|
||||
assert "Frozen reviewed body 1" in captured.get_content()
|
||||
frozen.session.expire_all()
|
||||
assert frozen.version.editor_state == original and frozen.version.edit_revision == revision
|
||||
assert all(job.send_status == "not_queued" for job in frozen.jobs)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mutation", ["partial", "build", "issue", "reason_evidence", "configuration", "snapshot", "bytes", "transport", "imap"])
|
||||
def test_invalid_frozen_review_stops_before_mock_capture_or_mailbox_clear(frozen, mutation):
|
||||
if mutation == "partial":
|
||||
state = copy.deepcopy(frozen.version.editor_state)
|
||||
state["review_send"]["inspection_complete"] = False
|
||||
frozen.version.editor_state = state
|
||||
elif mutation == "build":
|
||||
frozen.version.build_summary = {"build_token": "different-build", "built_count": 2}
|
||||
elif mutation == "issue":
|
||||
frozen.jobs[1].issues_snapshot = [{"code": "new-hard-block", "source": "attachments", "behavior": "block", "severity": "error", "message": "Missing required attachment"}]
|
||||
elif mutation == "reason_evidence":
|
||||
state = copy.deepcopy(frozen.version.editor_state)
|
||||
state["review_send"]["issue_decisions"][0]["issue_fingerprint"] = "f" * 64
|
||||
frozen.version.editor_state = state
|
||||
elif mutation == "configuration":
|
||||
frozen.version.raw_json = {**frozen.version.raw_json, "template": {"subject": "Unreviewed changed subject"}}
|
||||
elif mutation == "snapshot":
|
||||
frozen.version.execution_snapshot = None
|
||||
elif mutation == "bytes":
|
||||
from pathlib import Path
|
||||
Path(frozen.jobs[1].eml_local_path).write_bytes(b"changed bytes")
|
||||
else:
|
||||
frozen.transport.return_value = {"smtp_transport_revision": "changed" if mutation == "transport" else "smtp-1", "imap_transport_revision": "changed" if mutation == "imap" else "imap-1"}
|
||||
frozen.session.flush()
|
||||
with pytest.raises((mock_campaign.MockCampaignSendError, ExecutionSnapshotError, CampaignPersistenceError)):
|
||||
_run(frozen, clear_mailbox=True)
|
||||
frozen.mailbox.record_smtp_delivery.assert_not_called()
|
||||
frozen.mailbox.record_imap_append.assert_not_called()
|
||||
frozen.mailbox.clear_records.assert_not_called()
|
||||
|
||||
|
||||
def test_policy_dropped_frozen_message_stays_skipped_even_with_ask_evidence(frozen):
|
||||
excluded = frozen.jobs[1]
|
||||
excluded.validation_status = "excluded"
|
||||
excluded.send_status = "skipped"
|
||||
excluded.imap_status = "skipped"
|
||||
payload, digest = create_execution_snapshot(frozen.version, mail_profile_id="profile-1", smtp_transport_revision="smtp-1",
|
||||
imap_transport_revision="imap-1", delivery=DeliveryConfig(), jobs=frozen.jobs, build_summary=frozen.version.build_summary)
|
||||
frozen.version.execution_snapshot = payload
|
||||
frozen.version.execution_snapshot_hash = digest
|
||||
state = copy.deepcopy(frozen.version.editor_state)
|
||||
state["review_send"]["reviewed_message_keys"] = ["entry-1"]
|
||||
state["review_send"]["issue_decisions"] = state["review_send"]["issue_decisions"][:1]
|
||||
frozen.version.editor_state = state
|
||||
frozen.session.commit()
|
||||
result = _run(frozen)
|
||||
assert result["send"]["sent_count"] == 1 and result["send"]["skipped_count"] == 1
|
||||
assert frozen.mailbox.record_smtp_delivery.call_count == 1
|
||||
|
||||
|
||||
def test_default_authoring_mock_still_uses_transient_preview(frozen):
|
||||
with patch.object(mock_campaign, "_build_mock_campaign_run", side_effect=RuntimeError("authoring preview path")):
|
||||
with pytest.raises(RuntimeError, match="authoring preview path"):
|
||||
mock_campaign.run_mock_campaign_send(frozen.session, tenant_id="tenant-1", campaign_id="campaign-1", send=False)
|
||||
@@ -4,12 +4,17 @@ from collections import Counter
|
||||
|
||||
from govoplan_campaign.backend.router import router
|
||||
from govoplan_campaign.backend.routes.attachments import router as attachments_router
|
||||
from govoplan_campaign.backend.routes.assignments import router as assignments_router
|
||||
from govoplan_campaign.backend.routes.campaigns import router as campaigns_router
|
||||
from govoplan_campaign.backend.routes.collaboration import router as collaboration_router
|
||||
from govoplan_campaign.backend.routes.delivery import router as delivery_router
|
||||
from govoplan_campaign.backend.routes.delivery_settings import router as delivery_settings_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.transfers import router as transfers_router
|
||||
from govoplan_campaign.backend.routes.versions import router as versions_router
|
||||
|
||||
|
||||
@@ -23,11 +28,16 @@ def _operation_keys(candidate_router) -> list[tuple[str, str]]:
|
||||
|
||||
def test_campaign_router_composes_every_workflow_operation_once() -> None:
|
||||
workflow_routers = (
|
||||
delivery_settings_router,
|
||||
operations_router,
|
||||
transfers_router,
|
||||
campaigns_router,
|
||||
assignments_router,
|
||||
collaboration_router,
|
||||
versions_router,
|
||||
jobs_router,
|
||||
reports_router,
|
||||
schedules_router,
|
||||
sharing_router,
|
||||
delivery_router,
|
||||
attachments_router,
|
||||
@@ -40,20 +50,31 @@ def test_campaign_router_composes_every_workflow_operation_once() -> None:
|
||||
actual = _operation_keys(router)
|
||||
|
||||
assert actual == expected
|
||||
assert len(actual) == 72
|
||||
assert len(actual) == 100
|
||||
assert not [operation for operation, count in Counter(actual).items() if count > 1]
|
||||
|
||||
|
||||
def test_key_routes_are_owned_by_their_focused_router() -> None:
|
||||
expectations = (
|
||||
(delivery_settings_router, ("GET", "/campaigns/settings/delivery-policy/{scope}")),
|
||||
(delivery_settings_router, ("PUT", "/campaigns/settings/delivery-policy/{scope}")),
|
||||
(delivery_router, ("GET", "/campaigns/{campaign_id}/delivery-progress")),
|
||||
(delivery_router, ("POST", "/campaigns/{campaign_id}/jobs/{job_id}/recover-claim")),
|
||||
(
|
||||
operations_router,
|
||||
("POST", "/campaigns/operations/artifacts/reconcile"),
|
||||
),
|
||||
(campaigns_router, ("GET", "/campaigns/{campaign_id}/workspace")),
|
||||
(
|
||||
transfers_router,
|
||||
("POST", "/campaign-transfers/imports/preview"),
|
||||
),
|
||||
(collaboration_router, ("POST", "/campaigns/{campaign_id}/collaboration")),
|
||||
(assignments_router, ("POST", "/campaigns/{campaign_id}/assignments")),
|
||||
(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")),
|
||||
(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user