Compare commits
3 Commits
03100b77db
...
bfbb86564c
| Author | SHA1 | Date | |
|---|---|---|---|
| bfbb86564c | |||
| c05bb8e474 | |||
| 99ef25b08f |
@@ -144,6 +144,11 @@ paths, storage keys, worker claim tokens, and raw provider diagnostics require
|
|||||||
the dedicated diagnostic permission and must not leak through ordinary campaign,
|
the dedicated diagnostic permission and must not leak through ordinary campaign,
|
||||||
version, job, or report responses.
|
version, job, or report responses.
|
||||||
|
|
||||||
|
The current Campaign Report Web UI additionally requires recipient-read access
|
||||||
|
and does not yet hide every action control that the actor lacks. The server
|
||||||
|
still authorizes each action, but an aggregate-only reader UI remains open
|
||||||
|
work; do not promise that experience from `campaigns:report:read` alone.
|
||||||
|
|
||||||
### Deliver and resolve outcomes
|
### Deliver and resolve outcomes
|
||||||
|
|
||||||
Use the [delivery runbook](CAMPAIGN_DELIVERY_RUNBOOK.md) for the detailed
|
Use the [delivery runbook](CAMPAIGN_DELIVERY_RUNBOOK.md) for the detailed
|
||||||
@@ -151,9 +156,11 @@ operator sequence.
|
|||||||
|
|
||||||
At a minimum:
|
At a minimum:
|
||||||
|
|
||||||
1. Queue only a validated, locked, built version.
|
1. Queue only a validated, locked, built version. The current Web UI does not
|
||||||
2. Use worker delivery for ordinary batches; synchronous send is intentionally
|
expose Queue; use an authorized supporting client or API.
|
||||||
limited to small controlled runs.
|
2. Use worker delivery for ordinary batches. Synchronous Send now has no
|
||||||
|
server-enforced job-count bound and must be used only after an operator has
|
||||||
|
deliberately confirmed a small controlled run.
|
||||||
3. Treat `smtp_accepted` as protected from ordinary retry.
|
3. Treat `smtp_accepted` as protected from ordinary retry.
|
||||||
4. Retry `failed_temporary` explicitly after inspecting the cause.
|
4. Retry `failed_temporary` explicitly after inspecting the cause.
|
||||||
5. Include `failed_permanent` only after correcting the cause and making a
|
5. Include `failed_permanent` only after correcting the cause and making a
|
||||||
@@ -174,7 +181,7 @@ it cannot recall accepted mail.
|
|||||||
|
|
||||||
### Test and one-message actions
|
### Test and one-message actions
|
||||||
|
|
||||||
The current baseline includes mock send, queue dry-run, synchronous small-run
|
The current baseline includes mock send, queue dry-run, synchronous immediate
|
||||||
delivery, sending one selected job, retry selection, and unattempted-job
|
delivery, sending one selected job, retry selection, and unattempted-job
|
||||||
selection. Their audit and state behavior is not yet the final vocabulary
|
selection. Their audit and state behavior is not yet the final vocabulary
|
||||||
accepted for issue `govoplan-campaign#69`.
|
accepted for issue `govoplan-campaign#69`.
|
||||||
|
|||||||
705
src/govoplan_campaign/backend/documentation.py
Normal file
705
src/govoplan_campaign/backend/documentation.py
Normal file
@@ -0,0 +1,705 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import DocumentationCondition, DocumentationContext, DocumentationLink, DocumentationTopic
|
||||||
|
|
||||||
|
|
||||||
|
_CAMPAIGN_USER_SCOPES = (
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:campaign:create",
|
||||||
|
"campaigns:campaign:update",
|
||||||
|
"campaigns:campaign:copy",
|
||||||
|
"campaigns:campaign:archive",
|
||||||
|
"campaigns:campaign:delete",
|
||||||
|
"campaigns:campaign:share",
|
||||||
|
"campaigns:campaign:validate",
|
||||||
|
"campaigns:campaign:build",
|
||||||
|
"campaigns:campaign:review",
|
||||||
|
"campaigns:campaign:send_test",
|
||||||
|
"campaigns:campaign:queue",
|
||||||
|
"campaigns:campaign:control",
|
||||||
|
"campaigns:campaign:send",
|
||||||
|
"campaigns:campaign:retry",
|
||||||
|
"campaigns:campaign:reconcile",
|
||||||
|
"campaigns:recipient:read",
|
||||||
|
"campaigns:recipient:write",
|
||||||
|
"campaigns:recipient:import",
|
||||||
|
"campaigns:recipient:export",
|
||||||
|
"campaigns:report:read",
|
||||||
|
"campaigns:report:export",
|
||||||
|
"campaigns:report:send",
|
||||||
|
)
|
||||||
|
|
||||||
|
_CAMPAIGN_HELP_CONTEXTS = (
|
||||||
|
"campaigns.list",
|
||||||
|
"campaign.overview",
|
||||||
|
)
|
||||||
|
|
||||||
|
_FILES_INTEGRATION = "files.campaign_attachments"
|
||||||
|
_MAIL_INTEGRATION = "mail.campaign_delivery"
|
||||||
|
_ADDRESSES_LOOKUP_INTEGRATION = "addresses.lookup"
|
||||||
|
_ADDRESSES_SOURCE_INTEGRATION = "addresses.recipient_source"
|
||||||
|
_NOTIFICATIONS_INTEGRATION = "notifications.dispatch"
|
||||||
|
|
||||||
|
|
||||||
|
def _workflow_topic(
|
||||||
|
*,
|
||||||
|
topic_id: str,
|
||||||
|
title: str,
|
||||||
|
summary: str,
|
||||||
|
body: str,
|
||||||
|
order: int,
|
||||||
|
audience: tuple[str, ...],
|
||||||
|
required_scopes: tuple[str, ...],
|
||||||
|
route: str,
|
||||||
|
screen: str,
|
||||||
|
help_contexts: tuple[str, ...],
|
||||||
|
prerequisites: tuple[str, ...],
|
||||||
|
steps: tuple[str, ...],
|
||||||
|
outcome: str,
|
||||||
|
verification: str,
|
||||||
|
related_topic_ids: tuple[str, ...] = (),
|
||||||
|
required_modules: tuple[str, ...] = ("campaigns",),
|
||||||
|
required_capabilities: tuple[str, ...] = (),
|
||||||
|
links: tuple[DocumentationLink, ...] = (DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),),
|
||||||
|
related_modules: tuple[str, ...] = (),
|
||||||
|
limitations: tuple[str, ...] = (),
|
||||||
|
) -> DocumentationTopic:
|
||||||
|
metadata: dict[str, object] = {
|
||||||
|
"kind": "workflow",
|
||||||
|
"route": route,
|
||||||
|
"screen": screen,
|
||||||
|
"help_contexts": list(help_contexts),
|
||||||
|
"prerequisites": list(prerequisites),
|
||||||
|
"steps": list(steps),
|
||||||
|
"outcome": outcome,
|
||||||
|
"verification": verification,
|
||||||
|
"related_topic_ids": list(related_topic_ids),
|
||||||
|
}
|
||||||
|
if limitations:
|
||||||
|
metadata["limitations"] = list(limitations)
|
||||||
|
return DocumentationTopic(
|
||||||
|
id=topic_id,
|
||||||
|
title=title,
|
||||||
|
summary=summary,
|
||||||
|
body=body,
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("user",),
|
||||||
|
audience=audience,
|
||||||
|
order=order,
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=required_modules,
|
||||||
|
required_capabilities=required_capabilities,
|
||||||
|
required_scopes=required_scopes,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
links=links,
|
||||||
|
related_modules=related_modules,
|
||||||
|
unlocks=(outcome,),
|
||||||
|
source_module_id="campaigns",
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
CAMPAIGN_USER_DOCUMENTATION = (
|
||||||
|
_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.",
|
||||||
|
order=30,
|
||||||
|
audience=("campaign_manager", "campaign_author"),
|
||||||
|
required_scopes=("campaigns:campaign:read", "campaigns:campaign:create"),
|
||||||
|
route="/campaigns",
|
||||||
|
screen="Campaigns",
|
||||||
|
help_contexts=("campaigns.list",),
|
||||||
|
prerequisites=("You may create campaigns in the active tenant.",),
|
||||||
|
steps=(
|
||||||
|
"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.",
|
||||||
|
),
|
||||||
|
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.",
|
||||||
|
related_topic_ids=("campaigns.workflow.prepare-validate-and-build", "campaigns.workflow.import-recipients"),
|
||||||
|
),
|
||||||
|
_workflow_topic(
|
||||||
|
topic_id="campaigns.workflow.create-editable-successor",
|
||||||
|
title="Create an editable successor",
|
||||||
|
summary="Continue work after a permanent or delivery-final lock without rewriting the preserved version.",
|
||||||
|
body="Create editable copy means creating the campaign's next working version. Validation locks and temporary user locks are removed in place instead; they must not create parallel drafts.",
|
||||||
|
order=31,
|
||||||
|
audience=("campaign_manager", "campaign_author"),
|
||||||
|
required_scopes=("campaigns:campaign:read", "campaigns:campaign:copy", "campaigns:recipient:read"),
|
||||||
|
route="/campaigns/{campaign_id}",
|
||||||
|
screen="Campaign workspace",
|
||||||
|
help_contexts=("campaign.overview", "campaign.audit"),
|
||||||
|
prerequisites=(
|
||||||
|
"You have write access to the selected campaign.",
|
||||||
|
"Its current version is permanently user-locked or delivery-final.",
|
||||||
|
),
|
||||||
|
steps=(
|
||||||
|
"Open the locked current version and review the reason it is read-only.",
|
||||||
|
"Select Create editable copy in the locked-version notice.",
|
||||||
|
"If the notice instead offers an unlock action, unlock that validation or temporary user lock rather than creating a successor.",
|
||||||
|
"Review the new working version, reselect any Mail profile when requested, and validate and build again before delivery.",
|
||||||
|
),
|
||||||
|
outcome="A new editable working version while the source version and its evidence remain unchanged.",
|
||||||
|
verification="The Campaign overview identifies a new current version number and the earlier version remains in history.",
|
||||||
|
related_topic_ids=("campaigns.workflow.prepare-validate-and-build", "campaigns.mail-profile-user-journey"),
|
||||||
|
),
|
||||||
|
_workflow_topic(
|
||||||
|
topic_id="campaigns.workflow.import-recipients",
|
||||||
|
title="Import recipients into a campaign",
|
||||||
|
summary="Turn a text, CSV, or spreadsheet table into reviewed campaign-local recipient rows with source provenance.",
|
||||||
|
body="Import copies valid rows into the editable campaign version. Invalid rows stay visible during preview instead of disappearing, and later changes to the source file do not silently change the saved campaign.",
|
||||||
|
order=33,
|
||||||
|
audience=("campaign_manager", "campaign_author"),
|
||||||
|
required_scopes=(
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:campaign:update",
|
||||||
|
"campaigns:recipient:read",
|
||||||
|
"campaigns:recipient:write",
|
||||||
|
"campaigns:recipient:import",
|
||||||
|
),
|
||||||
|
route="/campaigns/{campaign_id}/recipients",
|
||||||
|
screen="Recipient data",
|
||||||
|
help_contexts=("campaign.recipients", "campaign.recipient-data"),
|
||||||
|
prerequisites=(
|
||||||
|
"The current campaign version is editable and you have write access to it.",
|
||||||
|
"The source is tabular and contains only data needed for this campaign.",
|
||||||
|
),
|
||||||
|
steps=(
|
||||||
|
"Open Recipient data and select Import.",
|
||||||
|
"Upload or paste the table, then confirm its encoding, sheet, header rows, and separator where applicable.",
|
||||||
|
"Map address and campaign fields, choose append or replace, and review every invalid or excluded row.",
|
||||||
|
"Select Import valid rows, inspect the resulting recipient table, and save the campaign page.",
|
||||||
|
),
|
||||||
|
outcome="A campaign-local recipient snapshot with mapping and source evidence.",
|
||||||
|
verification="Reopen Recipient data, confirm the expected row count and addressing, then validate before building messages.",
|
||||||
|
related_topic_ids=("campaigns.workflow.import-address-source", "campaigns.workflow.prepare-validate-and-build"),
|
||||||
|
),
|
||||||
|
_workflow_topic(
|
||||||
|
topic_id="campaigns.workflow.import-address-source",
|
||||||
|
title="Import a recipient source from Addresses",
|
||||||
|
summary="Copy a permitted reusable address book or list into the campaign as a traceable versioned snapshot.",
|
||||||
|
body="Campaign does not follow the address source live. It records the selected source revision and warns when a newer source revision is available, so re-import is always an explicit author action.",
|
||||||
|
order=32,
|
||||||
|
audience=("campaign_manager", "campaign_author"),
|
||||||
|
required_modules=("campaigns", "addresses"),
|
||||||
|
required_capabilities=(_ADDRESSES_SOURCE_INTEGRATION,),
|
||||||
|
required_scopes=(
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:campaign:update",
|
||||||
|
"campaigns:recipient:read",
|
||||||
|
"campaigns:recipient:write",
|
||||||
|
"campaigns:recipient:import",
|
||||||
|
),
|
||||||
|
route="/campaigns/{campaign_id}/recipients",
|
||||||
|
screen="Recipient data",
|
||||||
|
help_contexts=("campaign.recipients", "campaign.recipient-data"),
|
||||||
|
prerequisites=(
|
||||||
|
"Addresses offers at least one source visible to you.",
|
||||||
|
"The current campaign version is editable and you have write access to it.",
|
||||||
|
),
|
||||||
|
steps=(
|
||||||
|
"Open Recipient data and select Import address book/list.",
|
||||||
|
"Choose the permitted source and review the returned snapshot and its revision.",
|
||||||
|
"Choose append or replace, import the snapshot, inspect the recipient rows, and save.",
|
||||||
|
"When a stale-source warning appears later, compare the source and re-import deliberately if the campaign should use the new revision.",
|
||||||
|
),
|
||||||
|
outcome="A campaign-local recipient snapshot whose Addresses source and revision can be traced.",
|
||||||
|
verification="Recipient data records the source provenance, and a later source revision does not alter the saved rows until you re-import it.",
|
||||||
|
related_topic_ids=("campaigns.workflow.import-recipients", "campaigns.workflow.prepare-validate-and-build"),
|
||||||
|
related_modules=("addresses",),
|
||||||
|
),
|
||||||
|
_workflow_topic(
|
||||||
|
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.",
|
||||||
|
order=34,
|
||||||
|
audience=("campaign_manager", "campaign_author"),
|
||||||
|
required_modules=("campaigns", "files"),
|
||||||
|
required_capabilities=(_FILES_INTEGRATION,),
|
||||||
|
required_scopes=(
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:campaign:update",
|
||||||
|
"campaigns:campaign:validate",
|
||||||
|
"campaigns:recipient:read",
|
||||||
|
"files:file:read",
|
||||||
|
"files:file:share",
|
||||||
|
),
|
||||||
|
route="/campaigns/{campaign_id}/files",
|
||||||
|
screen="Attachments",
|
||||||
|
help_contexts=("campaign.attachments",),
|
||||||
|
prerequisites=(
|
||||||
|
"The current campaign version is editable and you have write access to it.",
|
||||||
|
"The required file versions exist in Files and may be shared with this campaign.",
|
||||||
|
),
|
||||||
|
steps=(
|
||||||
|
"Open Attachments and choose managed files or patterns from Files.",
|
||||||
|
"Define campaign-wide and recipient-specific rules, including unmatched-file and archive behavior.",
|
||||||
|
"Preview the matches and link the exact managed versions to the campaign.",
|
||||||
|
"Save, validate, and build, then inspect the resolved attachment evidence in Review and send.",
|
||||||
|
),
|
||||||
|
outcome="Governed file versions linked to the campaign and frozen into the exact build evidence.",
|
||||||
|
verification="Confirm the expected filenames, paths, matches, and link state in the Campaign UI. Exact managed versions and checksums remain retained in build evidence for authorized supporting tools.",
|
||||||
|
related_topic_ids=("campaigns.workflow.prepare-validate-and-build",),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),
|
||||||
|
DocumentationLink(label="Files", href="/files", kind="runtime"),
|
||||||
|
),
|
||||||
|
related_modules=("files",),
|
||||||
|
),
|
||||||
|
_workflow_topic(
|
||||||
|
topic_id="campaigns.workflow.queue-delivery",
|
||||||
|
title="Queue a campaign for controlled delivery",
|
||||||
|
summary="Place an exact reviewed build into the worker queue while preserving recipient-level state and retry protection.",
|
||||||
|
body="Queueing is a controlled state change, not proof of delivery. Ordinary batches should use background workers, and accepted or outcome-unknown effects remain protected from blind retry.",
|
||||||
|
order=35,
|
||||||
|
audience=("campaign_sender", "campaign_operator"),
|
||||||
|
required_modules=("campaigns", "mail"),
|
||||||
|
required_capabilities=(_MAIL_INTEGRATION,),
|
||||||
|
required_scopes=(
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:campaign:queue",
|
||||||
|
"campaigns:recipient:read",
|
||||||
|
"mail:profile:use",
|
||||||
|
),
|
||||||
|
route="/campaigns/{campaign_id}/review",
|
||||||
|
screen="Review and send",
|
||||||
|
help_contexts=(),
|
||||||
|
prerequisites=(
|
||||||
|
"The selected version is validated, locked, built, and reviewed.",
|
||||||
|
"Its Mail profile remains available and authorized for the current campaign context.",
|
||||||
|
"You have write access to the selected campaign.",
|
||||||
|
),
|
||||||
|
steps=(
|
||||||
|
"Open Review and send and confirm the selected version, build, recipients, warnings, and review completion.",
|
||||||
|
"Use an authorized supporting client to run the queue dry run and resolve every blocker.",
|
||||||
|
"Invoke the Queue action through that client so background workers can process the build.",
|
||||||
|
"Open the campaign Report and monitor queue, SMTP, IMAP, failure, and uncertain-outcome counts.",
|
||||||
|
),
|
||||||
|
outcome="Eligible jobs queued with an auditable execution snapshot and protected delivery state.",
|
||||||
|
verification="The Report shows the selected version's jobs as queued or progressing, without changing any accepted job back to retryable.",
|
||||||
|
related_topic_ids=("campaigns.workflow.complete-review", "campaigns.workflow.retry-and-reconcile"),
|
||||||
|
related_modules=("mail", "notifications"),
|
||||||
|
limitations=("The current Campaign Web UI does not expose the Queue action; use an authorized supporting client or API.",),
|
||||||
|
),
|
||||||
|
_workflow_topic(
|
||||||
|
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="The current synchronous action does not enforce a server-side job-count limit. It must not replace the worker queue for ordinary batches, but it does preserve the same protected SMTP, IMAP, retry, and uncertain-outcome rules.",
|
||||||
|
order=36,
|
||||||
|
audience=("campaign_sender", "campaign_operator"),
|
||||||
|
required_modules=("campaigns", "mail"),
|
||||||
|
required_capabilities=(_MAIL_INTEGRATION,),
|
||||||
|
required_scopes=(
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:campaign:send",
|
||||||
|
"campaigns:recipient:read",
|
||||||
|
"mail:profile:use",
|
||||||
|
),
|
||||||
|
route="/campaigns/{campaign_id}/review",
|
||||||
|
screen="Review and send",
|
||||||
|
help_contexts=("campaign.review-send",),
|
||||||
|
prerequisites=(
|
||||||
|
"The selected version is validated, locked, built, and reviewed.",
|
||||||
|
"An operator has deliberately confirmed that this is a small controlled run; the server does not currently enforce that bound.",
|
||||||
|
"Its Mail profile remains available and authorized for the current campaign context.",
|
||||||
|
),
|
||||||
|
steps=(
|
||||||
|
"Open Review and send and inspect the exact build and delivery readiness checks.",
|
||||||
|
"Confirm that this is an intentionally small controlled run; use Queue for an ordinary batch.",
|
||||||
|
"Select Send now and confirm the protected delivery action.",
|
||||||
|
"Open Report and inspect each resulting delivery state before attempting any recovery action.",
|
||||||
|
),
|
||||||
|
outcome="A synchronous real delivery run with recipient-level attempt and outcome evidence.",
|
||||||
|
verification="The Report distinguishes accepted, failed, unattempted, cancelled, and outcome-unknown jobs and retains their attempt history.",
|
||||||
|
related_topic_ids=("campaigns.workflow.queue-delivery", "campaigns.workflow.retry-and-reconcile"),
|
||||||
|
related_modules=("mail",),
|
||||||
|
limitations=("No server-side synchronous job-count limit is currently enforced; use Queue and workers for ordinary batches.",),
|
||||||
|
),
|
||||||
|
_workflow_topic(
|
||||||
|
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 current Report UI requires recipient-read authority in addition to report access. Infrastructure diagnostics remain separately authorized, and every report action is still checked by the server even where the current UI has not yet hidden an unavailable control.",
|
||||||
|
order=37,
|
||||||
|
audience=("campaign_reader", "campaign_manager", "campaign_reviewer", "campaign_sender"),
|
||||||
|
required_scopes=("campaigns:campaign:read", "campaigns:report:read", "campaigns:recipient:read"),
|
||||||
|
route="/campaigns/{campaign_id}/report",
|
||||||
|
screen="Campaign Report",
|
||||||
|
help_contexts=("campaign.report",),
|
||||||
|
prerequisites=("You may read the selected campaign and its report.",),
|
||||||
|
steps=(
|
||||||
|
"Open the campaign and select Report.",
|
||||||
|
"Review totals for queued, accepted, failed, cancelled, unattempted, and outcome-unknown jobs.",
|
||||||
|
"Inspect the authorized recipient-level rows and attempt history.",
|
||||||
|
"Treat outcome-unknown jobs as unresolved and hand them to an authorized operator for evidence-backed reconciliation.",
|
||||||
|
),
|
||||||
|
outcome="A recipient-aware view of delivery progress and outcomes with diagnostics still separately protected.",
|
||||||
|
verification="The report totals and job rows match the selected campaign version, and infrastructure diagnostics are absent unless separately authorized.",
|
||||||
|
related_topic_ids=("campaigns.workflow.retry-and-reconcile", "campaigns.workflow.export-delivery-report"),
|
||||||
|
limitations=("An aggregate-only Report Web UI for readers without recipient-read authority is not implemented yet.",),
|
||||||
|
),
|
||||||
|
_workflow_topic(
|
||||||
|
topic_id="campaigns.workflow.export-delivery-report",
|
||||||
|
title="Export recipient delivery results",
|
||||||
|
summary="Download an authorized CSV snapshot of recipient-level campaign delivery results for controlled downstream use.",
|
||||||
|
body="A report export contains personal and delivery evidence. Store, transmit, retain, and delete it according to the campaign's purpose and the applicable export and retention policy.",
|
||||||
|
order=38,
|
||||||
|
audience=("campaign_report_exporter", "campaign_auditor"),
|
||||||
|
required_scopes=(
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:report:read",
|
||||||
|
"campaigns:report:export",
|
||||||
|
"campaigns:recipient:read",
|
||||||
|
"campaigns:recipient:export",
|
||||||
|
),
|
||||||
|
route="/campaigns/{campaign_id}/report",
|
||||||
|
screen="Campaign Report",
|
||||||
|
help_contexts=("campaign.report", "campaign.audit"),
|
||||||
|
prerequisites=(
|
||||||
|
"You may export both campaign reports and recipient data.",
|
||||||
|
"The export has an approved purpose and destination.",
|
||||||
|
),
|
||||||
|
steps=(
|
||||||
|
"Open the campaign Report and select the intended version.",
|
||||||
|
"Confirm that recipient-level export is necessary for the task.",
|
||||||
|
"Select Download CSV and store the result only in the approved location.",
|
||||||
|
"Verify the selected version and row counts, then apply the required retention or deletion rule to the downloaded copy.",
|
||||||
|
),
|
||||||
|
outcome="A point-in-time CSV export of authorized campaign job results.",
|
||||||
|
verification="The downloaded file identifies the intended campaign/version and contains business evidence without credentials or ordinary-reader infrastructure details.",
|
||||||
|
related_topic_ids=("campaigns.workflow.view-delivery-report",),
|
||||||
|
),
|
||||||
|
_workflow_topic(
|
||||||
|
topic_id="campaigns.workflow.share-campaign",
|
||||||
|
title="Share a campaign",
|
||||||
|
summary="Grant a user or group explicit view or write access to one campaign without widening their platform permissions.",
|
||||||
|
body="A share can only narrow access to the selected campaign within the recipient's existing role. It never grants Mail profile use, Files authority, tenant-wide recipient access, or a missing Campaign action.",
|
||||||
|
order=39,
|
||||||
|
audience=("campaign_owner", "campaign_access_manager"),
|
||||||
|
required_scopes=("campaigns:campaign:read", "campaigns:campaign:share"),
|
||||||
|
route="/campaigns/{campaign_id}/global-settings",
|
||||||
|
screen="Ownership and sharing",
|
||||||
|
help_contexts=("campaign.overview", "campaign.global-settings"),
|
||||||
|
prerequisites=(
|
||||||
|
"You have write access to the selected campaign.",
|
||||||
|
"The target user or group already has an appropriate Campaign role for the intended actions.",
|
||||||
|
),
|
||||||
|
steps=(
|
||||||
|
"Open Global settings and find Ownership and sharing.",
|
||||||
|
"Select Share, choose a user or group, and choose Can view or Can edit and operate.",
|
||||||
|
"Save the share and verify the target appears in the sharing table with the intended level.",
|
||||||
|
"Use the row action to revoke the explicit share when it is no longer needed.",
|
||||||
|
),
|
||||||
|
outcome="Explicit campaign access for the selected subject, bounded by that subject's existing permissions.",
|
||||||
|
verification="The sharing table shows the active target and level; revocation removes its explicit access while preserving the audit record.",
|
||||||
|
related_topic_ids=("campaigns.reference.composition-assurance",),
|
||||||
|
),
|
||||||
|
_workflow_topic(
|
||||||
|
topic_id="campaigns.workflow.archive-campaign",
|
||||||
|
title="Archive a campaign",
|
||||||
|
summary="Remove a completed campaign from active work while preserving its versions, outcomes, and audit evidence.",
|
||||||
|
body="Archive only after queued, sending, and outcome-unknown work has been resolved. Archiving preserves evidence and is the correct lifecycle action for any campaign with build, lock, or delivery history.",
|
||||||
|
order=40,
|
||||||
|
audience=("campaign_owner", "campaign_records_manager"),
|
||||||
|
required_scopes=("campaigns:campaign:read", "campaigns:campaign:archive"),
|
||||||
|
route="/campaigns/{campaign_id}",
|
||||||
|
screen="Campaign lifecycle",
|
||||||
|
help_contexts=("campaign.overview", "campaign.report", "campaign.audit"),
|
||||||
|
prerequisites=(
|
||||||
|
"You have write access to the selected campaign.",
|
||||||
|
"No delivery job is queued, sending, or awaiting uncertain-outcome reconciliation.",
|
||||||
|
),
|
||||||
|
steps=(
|
||||||
|
"Open Report and resolve every active or outcome-unknown delivery state.",
|
||||||
|
"Confirm that the campaign should leave active work while its evidence remains retained.",
|
||||||
|
"Invoke the authorized Archive action from a supporting client.",
|
||||||
|
"Reopen or query the campaign and confirm its state is Archived.",
|
||||||
|
),
|
||||||
|
outcome="An archived campaign whose versions, reports, and audit evidence remain preserved.",
|
||||||
|
verification="The campaign state is Archived and its report remains available. Ask an authorized audit reader to verify the platform audit event.",
|
||||||
|
related_topic_ids=("campaigns.workflow.view-delivery-report", "campaigns.workflow.delete-untouched-draft"),
|
||||||
|
limitations=(
|
||||||
|
"The current Campaign Web UI does not yet expose the archive action; use an authorized supporting client or API.",
|
||||||
|
"The Campaign-local Audit page is not integrated yet; audit verification uses the platform audit surface or API.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_workflow_topic(
|
||||||
|
topic_id="campaigns.workflow.delete-untouched-draft",
|
||||||
|
title="Delete an untouched campaign draft",
|
||||||
|
summary="Immediately remove a draft that has no protected build, lock, publication, snapshot, or delivery evidence.",
|
||||||
|
body="Deletion is deliberately narrower than archive. It marks an eligible draft deleted and emits an audit record; it cannot erase a campaign that already carries evidence that must be retained.",
|
||||||
|
order=41,
|
||||||
|
audience=("campaign_owner", "campaign_records_manager"),
|
||||||
|
required_scopes=("campaigns:campaign:read", "campaigns:campaign:delete"),
|
||||||
|
route="/campaigns/{campaign_id}",
|
||||||
|
screen="Campaign lifecycle",
|
||||||
|
help_contexts=("campaign.overview", "campaign.audit"),
|
||||||
|
prerequisites=(
|
||||||
|
"You have write access to the selected campaign.",
|
||||||
|
"The campaign is still a draft and has no jobs, locks, published version, or execution snapshot.",
|
||||||
|
),
|
||||||
|
steps=(
|
||||||
|
"Confirm that the draft is not needed and contains no evidence that should be retained.",
|
||||||
|
"Invoke the authorized Delete action from a supporting client and confirm the destructive action.",
|
||||||
|
"If deletion is refused because protected evidence exists, archive the campaign after resolving any active delivery state.",
|
||||||
|
"Verify that the deleted draft no longer appears in active Campaigns and that the audit event exists.",
|
||||||
|
),
|
||||||
|
outcome="An eligible untouched draft removed from active Campaigns with attributable deletion evidence.",
|
||||||
|
verification="The draft is no longer returned as an active campaign. Ask an authorized audit reader to verify who deleted it and when through the platform audit surface.",
|
||||||
|
related_topic_ids=("campaigns.workflow.archive-campaign",),
|
||||||
|
limitations=(
|
||||||
|
"The current Campaign Web UI does not yet expose the delete action; use an authorized supporting client or API.",
|
||||||
|
"The Campaign-local Audit page is not integrated yet; audit verification uses the platform audit surface or API.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def documentation_topics(context: DocumentationContext) -> tuple[DocumentationTopic, ...]:
|
||||||
|
"""Describe the current Campaign composition without resolving module data.
|
||||||
|
|
||||||
|
The provider deliberately uses only the actor's permission evaluator and the
|
||||||
|
registry's declared contracts. Resource access, policy decisions, and
|
||||||
|
campaign state remain authoritative at the point where an action is used.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if context.documentation_type != "user":
|
||||||
|
return ()
|
||||||
|
principal = context.principal
|
||||||
|
if principal is None or not _has_any_scope(principal, _CAMPAIGN_USER_SCOPES):
|
||||||
|
return ()
|
||||||
|
|
||||||
|
mail_available = _integration_available(context.registry, _MAIL_INTEGRATION)
|
||||||
|
current_configuration = list(_actor_capabilities(principal, mail_available=mail_available))
|
||||||
|
integration_configuration, integration_limitations = _integration_summary(context.registry, principal)
|
||||||
|
current_configuration.extend(integration_configuration)
|
||||||
|
limitations = [
|
||||||
|
"Access to a particular campaign still depends on its owner or sharing rules and on the campaign's current state.",
|
||||||
|
"Profile, file, and address pickers re-evaluate the actual resources visible in the selected campaign; this overview does not claim that an eligible item currently exists.",
|
||||||
|
*integration_limitations,
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
DocumentationTopic(
|
||||||
|
id="campaigns.current-composition",
|
||||||
|
title="Your Campaign role and installed composition",
|
||||||
|
summary="This guide separates actions authorized by your role from optional services connected to Campaign in this installation.",
|
||||||
|
body=_composition_body(current_configuration, limitations),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("user",),
|
||||||
|
audience=("campaign_user",),
|
||||||
|
order=29,
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=("campaigns",),
|
||||||
|
any_scopes=_CAMPAIGN_USER_SCOPES,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
links=(DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),),
|
||||||
|
related_modules=("mail", "files", "addresses", "notifications"),
|
||||||
|
source_module_id="campaigns",
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"route": "/campaigns",
|
||||||
|
"screen": "Campaigns",
|
||||||
|
"help_contexts": list(_CAMPAIGN_HELP_CONTEXTS),
|
||||||
|
"current_configuration": current_configuration,
|
||||||
|
"limitations": limitations,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _actor_capabilities(principal: object, *, mail_available: bool) -> tuple[str, ...]:
|
||||||
|
capabilities: list[str] = []
|
||||||
|
_append_if(capabilities, principal, ("campaigns:campaign:read",), "Open campaigns shared with you and inspect their business state.")
|
||||||
|
_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: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.")
|
||||||
|
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.")
|
||||||
|
_append_if(capabilities, principal, ("campaigns:campaign:control",), "Pause, resume, or cancel eligible delivery jobs.")
|
||||||
|
if mail_available:
|
||||||
|
_append_if(capabilities, principal, ("campaigns:campaign:send",), "Start an eligible real delivery run.")
|
||||||
|
_append_if(capabilities, principal, ("campaigns:campaign:retry",), "Retry delivery jobs whose recorded state permits a retry.")
|
||||||
|
_append_if(capabilities, principal, ("campaigns:campaign:reconcile",), "Reconcile delivery effects whose outcome is uncertain.")
|
||||||
|
_append_if(capabilities, principal, ("campaigns:report:read",), "View authorized campaign delivery reports.")
|
||||||
|
_append_if(
|
||||||
|
capabilities,
|
||||||
|
principal,
|
||||||
|
("campaigns:report:export", "campaigns:recipient:export"),
|
||||||
|
"Export authorized delivery and recipient report data.",
|
||||||
|
require_all=True,
|
||||||
|
)
|
||||||
|
_append_if(capabilities, principal, ("campaigns:campaign:share",), "Manage explicit access to eligible campaigns.")
|
||||||
|
_append_if(capabilities, principal, ("campaigns:campaign:archive",), "Archive eligible campaigns while retaining their evidence.")
|
||||||
|
_append_if(capabilities, principal, ("campaigns:campaign:delete",), "Delete eligible untouched drafts.")
|
||||||
|
if mail_available:
|
||||||
|
_append_if(
|
||||||
|
capabilities,
|
||||||
|
principal,
|
||||||
|
("campaigns:report:send", "campaigns:report:read", "mail:profile:use"),
|
||||||
|
"Send an authorized campaign report through a Mail profile.",
|
||||||
|
require_all=True,
|
||||||
|
)
|
||||||
|
return tuple(capabilities)
|
||||||
|
|
||||||
|
|
||||||
|
def _integration_summary(registry: object, principal: object) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
||||||
|
configured: list[str] = []
|
||||||
|
limitations: list[str] = []
|
||||||
|
|
||||||
|
mail_available = _integration_available(registry, _MAIL_INTEGRATION)
|
||||||
|
can_select_mail_profile = _has_all_scopes(
|
||||||
|
principal,
|
||||||
|
("campaigns:campaign:read", "campaigns:campaign:update", "mail:profile:use"),
|
||||||
|
)
|
||||||
|
can_deliver_with_mail = _has_scope(principal, "mail:profile:use") and _has_any_scope(
|
||||||
|
principal,
|
||||||
|
("campaigns:campaign:queue", "campaigns:campaign:send", "campaigns:campaign:retry"),
|
||||||
|
)
|
||||||
|
if mail_available and can_select_mail_profile:
|
||||||
|
configured.append("Installed composition: Campaign can open Mail's actor-filtered profile picker while you edit; eligible profiles are resolved in the selected campaign context.")
|
||||||
|
elif mail_available and can_deliver_with_mail:
|
||||||
|
configured.append("Installed composition: Mail-backed Campaign delivery is connected, and the selected profile is re-authorized for each delivery action.")
|
||||||
|
elif mail_available:
|
||||||
|
limitations.append("Mail delivery is configured, but selecting a Mail profile is not included in your current tasks.")
|
||||||
|
else:
|
||||||
|
limitations.append("Mail-backed Campaign delivery is not available in the current composition.")
|
||||||
|
|
||||||
|
files_available = _integration_available(registry, _FILES_INTEGRATION)
|
||||||
|
can_preview_files = _has_all_scopes(
|
||||||
|
principal,
|
||||||
|
("campaigns:campaign:read", "campaigns:recipient:read", "files:file:read"),
|
||||||
|
)
|
||||||
|
can_link_files = _has_all_scopes(
|
||||||
|
principal,
|
||||||
|
(
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:campaign:update",
|
||||||
|
"campaigns:campaign:validate",
|
||||||
|
"campaigns:recipient:read",
|
||||||
|
"files:file:read",
|
||||||
|
"files:file:share",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if files_available and can_link_files:
|
||||||
|
configured.append("Installed composition: Managed file versions can be selected, previewed, and linked as governed campaign attachments when the selected campaign and files pass their resource checks.")
|
||||||
|
elif files_available and can_preview_files:
|
||||||
|
configured.append("Installed composition: Managed campaign attachments can be previewed; linking a version requires campaign-update, validation, and attachment-sharing authority.")
|
||||||
|
elif files_available:
|
||||||
|
limitations.append("Managed attachments are configured, but they are not included in your current Campaign tasks.")
|
||||||
|
else:
|
||||||
|
limitations.append("Managed file attachments are not available in the current composition; campaign-owned uploads remain separate.")
|
||||||
|
|
||||||
|
lookup_available = _integration_available(registry, _ADDRESSES_LOOKUP_INTEGRATION)
|
||||||
|
source_available = _integration_available(registry, _ADDRESSES_SOURCE_INTEGRATION)
|
||||||
|
if lookup_available and _has_scope(principal, "campaigns:recipient:read"):
|
||||||
|
configured.append("Installed composition: Address records can be looked up while preparing recipients.")
|
||||||
|
elif lookup_available:
|
||||||
|
limitations.append("Address lookup is configured, but recipient inspection is not included in your current Campaign tasks.")
|
||||||
|
else:
|
||||||
|
limitations.append("Connected address lookup is not available in the current composition.")
|
||||||
|
can_import_source = _has_all_scopes(
|
||||||
|
principal,
|
||||||
|
(
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:campaign:update",
|
||||||
|
"campaigns:recipient:read",
|
||||||
|
"campaigns:recipient:write",
|
||||||
|
"campaigns:recipient:import",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if source_available and can_import_source:
|
||||||
|
configured.append("Installed composition: The actor-filtered Addresses source picker can copy a selected source into a campaign as a traceable recipient snapshot.")
|
||||||
|
elif source_available:
|
||||||
|
limitations.append("Connected recipient sources are configured, but source import is not included in your current Campaign tasks.")
|
||||||
|
else:
|
||||||
|
limitations.append("Connected recipient-source snapshots are not available; direct campaign imports remain available when authorized.")
|
||||||
|
|
||||||
|
if _integration_available(registry, _NOTIFICATIONS_INTEGRATION):
|
||||||
|
configured.append("Installed composition: In-app notifications can report important Campaign delivery status changes.")
|
||||||
|
else:
|
||||||
|
limitations.append("Automatic in-app Campaign status notifications are not configured.")
|
||||||
|
|
||||||
|
return tuple(configured), tuple(limitations)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_if(
|
||||||
|
target: list[str],
|
||||||
|
principal: object,
|
||||||
|
scopes: tuple[str, ...],
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
require_all: bool = False,
|
||||||
|
) -> None:
|
||||||
|
allowed = _has_all_scopes(principal, scopes) if require_all else _has_any_scope(principal, scopes)
|
||||||
|
if allowed:
|
||||||
|
target.append(f"Role authorization: {text}")
|
||||||
|
|
||||||
|
|
||||||
|
def _has_scope(principal: object, scope: str) -> bool:
|
||||||
|
checker = getattr(principal, "has", None)
|
||||||
|
if not callable(checker):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return bool(checker(scope))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _has_any_scope(principal: object, scopes: tuple[str, ...]) -> bool:
|
||||||
|
return any(_has_scope(principal, scope) for scope in scopes)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_all_scopes(principal: object, scopes: tuple[str, ...]) -> bool:
|
||||||
|
return all(_has_scope(principal, scope) for scope in scopes)
|
||||||
|
|
||||||
|
|
||||||
|
def _integration_available(registry: object, interface_name: str) -> bool:
|
||||||
|
return _registry_has_interface(registry, interface_name) and _registry_has_capability(registry, interface_name)
|
||||||
|
|
||||||
|
|
||||||
|
def _registry_has_interface(registry: object, interface_name: str) -> bool:
|
||||||
|
manifests_provider = getattr(registry, "manifests", None)
|
||||||
|
if not callable(manifests_provider):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
manifests = tuple(manifests_provider())
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return any(
|
||||||
|
getattr(provider, "name", None) == interface_name
|
||||||
|
for manifest in manifests
|
||||||
|
for provider in (getattr(manifest, "provides_interfaces", ()) or ())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _registry_has_capability(registry: object, capability_name: str) -> bool:
|
||||||
|
capability_checker = getattr(registry, "has_capability", None)
|
||||||
|
if not callable(capability_checker):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return bool(capability_checker(capability_name))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _composition_body(current_configuration: list[str], limitations: list[str]) -> str:
|
||||||
|
del current_configuration, limitations
|
||||||
|
return "The facts below are calculated for the current actor and installed module contracts. They do not bypass campaign ownership, sharing, state, or operation-time resource and policy checks."
|
||||||
@@ -28,6 +28,7 @@ from govoplan_core.core.modules import (
|
|||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_campaign.backend.change_tracking import register_campaign_change_tracking
|
from govoplan_campaign.backend.change_tracking import register_campaign_change_tracking
|
||||||
from govoplan_campaign.backend.db import models as campaign_models # noqa: F401 - populate Campaign ORM metadata
|
from govoplan_campaign.backend.db import models as campaign_models # noqa: F401 - populate Campaign ORM metadata
|
||||||
|
from govoplan_campaign.backend.documentation import CAMPAIGN_USER_DOCUMENTATION, documentation_topics
|
||||||
|
|
||||||
register_campaign_change_tracking()
|
register_campaign_change_tracking()
|
||||||
|
|
||||||
@@ -262,6 +263,7 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
documentation=(
|
documentation=(
|
||||||
|
*CAMPAIGN_USER_DOCUMENTATION,
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="campaigns.mail-profile-user-journey",
|
id="campaigns.mail-profile-user-journey",
|
||||||
title="Choose a Mail profile for campaign delivery",
|
title="Choose a Mail profile for campaign delivery",
|
||||||
@@ -288,6 +290,7 @@ manifest = ModuleManifest(
|
|||||||
"kind": "workflow",
|
"kind": "workflow",
|
||||||
"route": "/campaigns/{campaign_id}/mail-settings",
|
"route": "/campaigns/{campaign_id}/mail-settings",
|
||||||
"screen": "Campaign Mail settings",
|
"screen": "Campaign Mail settings",
|
||||||
|
"help_contexts": ["campaign.server-settings"],
|
||||||
"prerequisites": [
|
"prerequisites": [
|
||||||
"Campaign and Mail are installed.",
|
"Campaign and Mail are installed.",
|
||||||
"You may edit the current campaign version and use at least one authorized Mail profile.",
|
"You may edit the current campaign version and use at least one authorized Mail profile.",
|
||||||
@@ -401,6 +404,19 @@ manifest = ModuleManifest(
|
|||||||
"kind": "workflow",
|
"kind": "workflow",
|
||||||
"route": "/campaigns/{campaign_id}/global-settings",
|
"route": "/campaigns/{campaign_id}/global-settings",
|
||||||
"screen": "Campaign workspace",
|
"screen": "Campaign workspace",
|
||||||
|
"help_contexts": [
|
||||||
|
"campaign.overview",
|
||||||
|
"campaign.settings",
|
||||||
|
"campaign.fields",
|
||||||
|
"campaign.template",
|
||||||
|
"campaign.attachments",
|
||||||
|
"campaign.recipients",
|
||||||
|
"campaign.recipient-data",
|
||||||
|
"campaign.server-settings",
|
||||||
|
"campaign.global-settings",
|
||||||
|
"campaign.review-send",
|
||||||
|
"campaign.json",
|
||||||
|
],
|
||||||
"prerequisites": [
|
"prerequisites": [
|
||||||
"The campaign has an owner and a clear communication purpose.",
|
"The campaign has an owner and a clear communication purpose.",
|
||||||
"You may edit, validate, and build the selected campaign version.",
|
"You may edit, validate, and build the selected campaign version.",
|
||||||
@@ -447,6 +463,7 @@ manifest = ModuleManifest(
|
|||||||
"kind": "workflow",
|
"kind": "workflow",
|
||||||
"route": "/campaigns/{campaign_id}/review",
|
"route": "/campaigns/{campaign_id}/review",
|
||||||
"screen": "Review and send",
|
"screen": "Review and send",
|
||||||
|
"help_contexts": ["campaign.review-send"],
|
||||||
"prerequisites": [
|
"prerequisites": [
|
||||||
"The selected campaign version is validated and built.",
|
"The selected campaign version is validated and built.",
|
||||||
"You may read the campaign and complete its review.",
|
"You may read the campaign and complete its review.",
|
||||||
@@ -490,6 +507,7 @@ manifest = ModuleManifest(
|
|||||||
"kind": "workflow",
|
"kind": "workflow",
|
||||||
"route": "/operator",
|
"route": "/operator",
|
||||||
"screen": "Campaign operator queue",
|
"screen": "Campaign operator queue",
|
||||||
|
"help_contexts": ["campaign.review-send", "campaign.report", "campaign.audit"],
|
||||||
"prerequisites": [
|
"prerequisites": [
|
||||||
"You may perform the selected retry or reconciliation action.",
|
"You may perform the selected retry or reconciliation action.",
|
||||||
"Provider, mailbox, worker, and campaign evidence has been preserved.",
|
"Provider, mailbox, worker, and campaign evidence has been preserved.",
|
||||||
@@ -544,6 +562,7 @@ manifest = ModuleManifest(
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
documentation_providers=(documentation_topics,),
|
||||||
capability_factories={
|
capability_factories={
|
||||||
CAPABILITY_CAMPAIGNS_ACCESS: lambda context: __import__(
|
CAPABILITY_CAMPAIGNS_ACCESS: lambda context: __import__(
|
||||||
"govoplan_campaign.backend.capabilities",
|
"govoplan_campaign.backend.capabilities",
|
||||||
|
|||||||
@@ -3264,10 +3264,11 @@ def send_campaign_now_endpoint(
|
|||||||
):
|
):
|
||||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||||
_require_permission(principal, "campaigns:recipient:read")
|
_require_permission(principal, "campaigns:recipient:read")
|
||||||
"""Validate/build/queue and synchronously send a small campaign version.
|
"""Validate/build/queue and synchronously send every eligible job.
|
||||||
|
|
||||||
This endpoint is intentionally conservative and suitable for a first small
|
No server-side job-count bound is currently enforced. Ordinary batches
|
||||||
test campaign. Larger campaigns should use the queue/Celery flow.
|
must use the queue/Celery flow; this endpoint is for deliberately small
|
||||||
|
interactive runs.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
payload = payload or SendCampaignNowRequest()
|
payload = payload or SendCampaignNowRequest()
|
||||||
|
|||||||
@@ -544,10 +544,11 @@ def send_campaign_now(
|
|||||||
use_rate_limit: bool = True,
|
use_rate_limit: bool = True,
|
||||||
enqueue_imap_task: bool = False,
|
enqueue_imap_task: bool = False,
|
||||||
) -> SendCampaignNowResult:
|
) -> SendCampaignNowResult:
|
||||||
"""Queue and send a small campaign synchronously.
|
"""Queue and send all eligible jobs for one campaign synchronously.
|
||||||
|
|
||||||
This is intended for WebUI test/small-campaign flows. Large campaigns can
|
The service currently has no server-side job-count bound. Callers must use
|
||||||
still use queue_campaign_jobs with Celery workers.
|
the worker queue for ordinary batches and reserve this path for deliberately
|
||||||
|
small interactive runs.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
|
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
|
||||||
|
|||||||
375
tests/test_documentation.py
Normal file
375
tests/test_documentation.py
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.documentation import CAMPAIGN_USER_DOCUMENTATION, documentation_topics
|
||||||
|
from govoplan_core.core.modules import DocumentationContext
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _Principal:
|
||||||
|
scopes: frozenset[str]
|
||||||
|
|
||||||
|
def has(self, scope: str) -> bool:
|
||||||
|
namespace, resource, _action = scope.split(":", 2)
|
||||||
|
return scope in self.scopes or f"{namespace}:{resource}:*" in self.scopes or f"{namespace}:*" in self.scopes or "*:*" in self.scopes
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def __init__(self, integrations: set[str], *, interface_only: set[str] | None = None, capability_only: set[str] | None = None) -> None:
|
||||||
|
interfaces = integrations | (interface_only or set())
|
||||||
|
self._capabilities = integrations | (capability_only or set())
|
||||||
|
self._manifests = (
|
||||||
|
SimpleNamespace(
|
||||||
|
provides_interfaces=tuple(SimpleNamespace(name=name) for name in sorted(interfaces)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def manifests(self):
|
||||||
|
return self._manifests
|
||||||
|
|
||||||
|
def has_capability(self, name: str) -> bool:
|
||||||
|
return name in self._capabilities
|
||||||
|
|
||||||
|
|
||||||
|
def _topics(scopes: set[str], integrations: set[str] | None = None, *, documentation_type: str = "user"):
|
||||||
|
return documentation_topics(
|
||||||
|
DocumentationContext(
|
||||||
|
registry=_Registry(integrations or set()),
|
||||||
|
principal=_Principal(frozenset(scopes)),
|
||||||
|
documentation_type=documentation_type, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_campaign_runtime_documentation_provider_is_registered() -> None:
|
||||||
|
from govoplan_campaign.backend.manifest import get_manifest
|
||||||
|
|
||||||
|
assert documentation_topics in get_manifest().documentation_providers
|
||||||
|
|
||||||
|
|
||||||
|
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") == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_documentation_reflects_actor_authority_without_exposing_scopes() -> None:
|
||||||
|
topic = _topics(
|
||||||
|
{
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:campaign:create",
|
||||||
|
"campaigns:campaign:update",
|
||||||
|
"campaigns:campaign:validate",
|
||||||
|
"campaigns:campaign:build",
|
||||||
|
"campaigns:recipient:read",
|
||||||
|
"campaigns:recipient:write",
|
||||||
|
"campaigns:recipient:import",
|
||||||
|
}
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
configuration = topic.metadata["current_configuration"]
|
||||||
|
assert "Role authorization: Create new campaigns." in configuration
|
||||||
|
assert "Role authorization: Build exact recipient messages for review." in configuration
|
||||||
|
assert not any("queue" in item.lower() for item in configuration)
|
||||||
|
assert not any("campaigns:" in item or "files:" in item or "mail:" in item for item in configuration)
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_documentation_requires_both_interface_and_capability() -> None:
|
||||||
|
integration = "mail.campaign_delivery"
|
||||||
|
scopes = {"campaigns:campaign:read", "campaigns:campaign:update", "mail:profile:use"}
|
||||||
|
|
||||||
|
for registry in (
|
||||||
|
_Registry(set(), interface_only={integration}),
|
||||||
|
_Registry(set(), capability_only={integration}),
|
||||||
|
):
|
||||||
|
topic = documentation_topics(
|
||||||
|
DocumentationContext(registry=registry, principal=_Principal(frozenset(scopes)), documentation_type="user")
|
||||||
|
)[0]
|
||||||
|
assert not any("profile picker" in item for item in topic.metadata["current_configuration"])
|
||||||
|
|
||||||
|
topic = _topics(scopes, {integration})[0]
|
||||||
|
assert any("Mail's actor-filtered profile picker" in item for item in topic.metadata["current_configuration"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_mail_delivery_actions_are_not_presented_without_the_mail_contract() -> None:
|
||||||
|
scopes = {
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:campaign:queue",
|
||||||
|
"campaigns:campaign:send",
|
||||||
|
"campaigns:campaign:retry",
|
||||||
|
}
|
||||||
|
|
||||||
|
without_mail = _topics(scopes)[0]
|
||||||
|
with_mail = _topics(scopes, {"mail.campaign_delivery"})[0]
|
||||||
|
|
||||||
|
assert not any("Queue an eligible" in item for item in without_mail.metadata["current_configuration"])
|
||||||
|
assert any("Queue an eligible" in item for item in with_mail.metadata["current_configuration"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("integrations", "scopes", "expected"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
{"files.campaign_attachments"},
|
||||||
|
{"campaigns:campaign:read", "campaigns:recipient:read", "files:file:read"},
|
||||||
|
"Managed campaign attachments can be previewed",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{"addresses.lookup"},
|
||||||
|
{"campaigns:campaign:read", "campaigns:recipient:read"},
|
||||||
|
"Address records can be looked up",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{"addresses.recipient_source"},
|
||||||
|
{
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:campaign:update",
|
||||||
|
"campaigns:recipient:read",
|
||||||
|
"campaigns:recipient:write",
|
||||||
|
"campaigns:recipient:import",
|
||||||
|
},
|
||||||
|
"traceable recipient snapshot",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{"notifications.dispatch"},
|
||||||
|
{"campaigns:campaign:read"},
|
||||||
|
"status changes",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_runtime_documentation_reflects_optional_composition_permutations(
|
||||||
|
integrations: set[str],
|
||||||
|
scopes: set[str],
|
||||||
|
expected: str,
|
||||||
|
) -> None:
|
||||||
|
topic = _topics(scopes, integrations)[0]
|
||||||
|
assert any(expected in item for item in topic.metadata["current_configuration"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_documentation_full_composition_uses_only_user_facing_names() -> None:
|
||||||
|
integrations = {
|
||||||
|
"mail.campaign_delivery",
|
||||||
|
"files.campaign_attachments",
|
||||||
|
"addresses.lookup",
|
||||||
|
"addresses.recipient_source",
|
||||||
|
"notifications.dispatch",
|
||||||
|
}
|
||||||
|
topic = _topics({"*:*"}, integrations)[0]
|
||||||
|
rendered = "\n".join(
|
||||||
|
(
|
||||||
|
topic.title,
|
||||||
|
topic.summary,
|
||||||
|
topic.body,
|
||||||
|
*topic.metadata["current_configuration"],
|
||||||
|
*topic.metadata["limitations"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "Mail's actor-filtered profile picker" in rendered
|
||||||
|
assert "Managed file versions" in rendered
|
||||||
|
assert "Address records" in rendered
|
||||||
|
assert "In-app notifications" in rendered
|
||||||
|
assert topic.metadata["help_contexts"] == ["campaigns.list", "campaign.overview"]
|
||||||
|
for technical_name in integrations:
|
||||||
|
assert technical_name not in rendered
|
||||||
|
assert "0.1." not in rendered
|
||||||
|
assert "0.2." not in rendered
|
||||||
|
assert "hostname" not in rendered.lower()
|
||||||
|
assert "secret" not in rendered.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _visible_static_topics(
|
||||||
|
scopes: set[str],
|
||||||
|
*,
|
||||||
|
modules: set[str] | None = None,
|
||||||
|
capabilities: set[str] | None = None,
|
||||||
|
) -> set[str]:
|
||||||
|
installed = modules or {"campaigns"}
|
||||||
|
available_capabilities = capabilities or set()
|
||||||
|
principal = _Principal(frozenset(scopes))
|
||||||
|
visible: set[str] = set()
|
||||||
|
for topic in CAMPAIGN_USER_DOCUMENTATION:
|
||||||
|
condition = topic.conditions[0]
|
||||||
|
if not set(condition.required_modules).issubset(installed):
|
||||||
|
continue
|
||||||
|
if not set(condition.required_capabilities).issubset(available_capabilities):
|
||||||
|
continue
|
||||||
|
if not all(principal.has(scope) for scope in condition.required_scopes):
|
||||||
|
continue
|
||||||
|
visible.add(topic.id)
|
||||||
|
return visible
|
||||||
|
|
||||||
|
|
||||||
|
def test_campaign_manager_sees_only_authoring_tasks_from_the_static_handbook() -> None:
|
||||||
|
visible = _visible_static_topics(
|
||||||
|
{
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:campaign:create",
|
||||||
|
"campaigns:campaign:update",
|
||||||
|
"campaigns:campaign:copy",
|
||||||
|
"campaigns:campaign:validate",
|
||||||
|
"campaigns:campaign:build",
|
||||||
|
"campaigns:recipient:read",
|
||||||
|
"campaigns:recipient:write",
|
||||||
|
"campaigns:recipient:import",
|
||||||
|
"campaigns:report:read",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert {
|
||||||
|
"campaigns.workflow.create-campaign",
|
||||||
|
"campaigns.workflow.create-editable-successor",
|
||||||
|
"campaigns.workflow.import-recipients",
|
||||||
|
"campaigns.workflow.view-delivery-report",
|
||||||
|
}.issubset(visible)
|
||||||
|
assert "campaigns.workflow.queue-delivery" not in visible
|
||||||
|
assert "campaigns.workflow.send-small-controlled-run" not in visible
|
||||||
|
assert "campaigns.workflow.export-delivery-report" not in visible
|
||||||
|
assert "campaigns.workflow.share-campaign" not in visible
|
||||||
|
assert "campaigns.workflow.archive-campaign" not in visible
|
||||||
|
assert "campaigns.workflow.delete-untouched-draft" not in visible
|
||||||
|
assert "campaigns.workflow.create-campaign" not in _visible_static_topics({"campaigns:campaign:create"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_sender_sees_queue_and_send_only_with_the_mail_contract_and_profile_authority() -> None:
|
||||||
|
scopes = {
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:campaign:queue",
|
||||||
|
"campaigns:campaign:send",
|
||||||
|
"campaigns:recipient:read",
|
||||||
|
"campaigns:report:read",
|
||||||
|
"mail:profile:use",
|
||||||
|
}
|
||||||
|
|
||||||
|
without_mail = _visible_static_topics(scopes)
|
||||||
|
with_mail = _visible_static_topics(
|
||||||
|
scopes,
|
||||||
|
modules={"campaigns", "mail"},
|
||||||
|
capabilities={"mail.campaign_delivery"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "campaigns.workflow.queue-delivery" not in without_mail
|
||||||
|
assert "campaigns.workflow.send-small-controlled-run" not in without_mail
|
||||||
|
assert "campaigns.workflow.queue-delivery" in with_mail
|
||||||
|
assert "campaigns.workflow.send-small-controlled-run" in with_mail
|
||||||
|
|
||||||
|
|
||||||
|
def test_connected_authoring_tasks_require_their_declared_contracts_and_permissions() -> None:
|
||||||
|
attachment_scopes = {
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:campaign:update",
|
||||||
|
"campaigns:campaign:validate",
|
||||||
|
"campaigns:recipient:read",
|
||||||
|
"files:file:read",
|
||||||
|
"files:file:share",
|
||||||
|
}
|
||||||
|
source_scopes = {
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:campaign:update",
|
||||||
|
"campaigns:recipient:read",
|
||||||
|
"campaigns:recipient:write",
|
||||||
|
"campaigns:recipient:import",
|
||||||
|
}
|
||||||
|
|
||||||
|
files_visible = _visible_static_topics(
|
||||||
|
attachment_scopes,
|
||||||
|
modules={"campaigns", "files"},
|
||||||
|
capabilities={"files.campaign_attachments"},
|
||||||
|
)
|
||||||
|
addresses_visible = _visible_static_topics(
|
||||||
|
source_scopes,
|
||||||
|
modules={"campaigns", "addresses"},
|
||||||
|
capabilities={"addresses.recipient_source"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "campaigns.workflow.use-managed-attachments" in files_visible
|
||||||
|
assert "campaigns.workflow.import-address-source" in addresses_visible
|
||||||
|
assert "campaigns.workflow.use-managed-attachments" not in _visible_static_topics(attachment_scopes)
|
||||||
|
assert "campaigns.workflow.import-address-source" not in _visible_static_topics(source_scopes)
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_export_and_lifecycle_tasks_are_independently_permission_gated() -> None:
|
||||||
|
exporter = _visible_static_topics(
|
||||||
|
{
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:report:read",
|
||||||
|
"campaigns:report:export",
|
||||||
|
"campaigns:recipient:read",
|
||||||
|
"campaigns:recipient:export",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
custodian = _visible_static_topics(
|
||||||
|
{
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:campaign:share",
|
||||||
|
"campaigns:campaign:archive",
|
||||||
|
"campaigns:campaign:delete",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "campaigns.workflow.export-delivery-report" in exporter
|
||||||
|
assert "campaigns.workflow.view-delivery-report" in exporter
|
||||||
|
assert "campaigns.workflow.share-campaign" in custodian
|
||||||
|
assert "campaigns.workflow.archive-campaign" in custodian
|
||||||
|
assert "campaigns.workflow.delete-untouched-draft" in custodian
|
||||||
|
assert "campaigns.workflow.export-delivery-report" not in custodian
|
||||||
|
|
||||||
|
|
||||||
|
def test_static_campaign_handbook_has_unique_ids_help_contexts_and_no_planned_resend_claim() -> None:
|
||||||
|
from govoplan_campaign.backend.manifest import get_manifest
|
||||||
|
|
||||||
|
topics = get_manifest().documentation
|
||||||
|
ids = [topic.id for topic in topics]
|
||||||
|
rendered_static = "\n".join(
|
||||||
|
(topic.title + "\n" + topic.summary + "\n" + topic.body).lower()
|
||||||
|
for topic in CAMPAIGN_USER_DOCUMENTATION
|
||||||
|
)
|
||||||
|
known_help_contexts = {
|
||||||
|
"campaigns.list",
|
||||||
|
"campaign.overview",
|
||||||
|
"campaign.settings",
|
||||||
|
"campaign.fields",
|
||||||
|
"campaign.template",
|
||||||
|
"campaign.attachments",
|
||||||
|
"campaign.recipients",
|
||||||
|
"campaign.recipient-data",
|
||||||
|
"campaign.server-settings",
|
||||||
|
"campaign.global-settings",
|
||||||
|
"campaign.review-send",
|
||||||
|
"campaign.report",
|
||||||
|
"campaign.audit",
|
||||||
|
"campaign.json",
|
||||||
|
}
|
||||||
|
|
||||||
|
assert len(ids) == len(set(ids))
|
||||||
|
assert "single resend" not in rendered_static
|
||||||
|
for topic in CAMPAIGN_USER_DOCUMENTATION:
|
||||||
|
assert topic.metadata["kind"] == "workflow"
|
||||||
|
assert topic.metadata["prerequisites"]
|
||||||
|
assert topic.metadata["steps"]
|
||||||
|
assert topic.metadata["outcome"]
|
||||||
|
assert topic.metadata["verification"]
|
||||||
|
assert set(topic.metadata["help_contexts"]).issubset(known_help_contexts)
|
||||||
|
|
||||||
|
existing_user_workflows = {
|
||||||
|
topic.id: topic
|
||||||
|
for topic in topics
|
||||||
|
if topic.id
|
||||||
|
in {
|
||||||
|
"campaigns.mail-profile-user-journey",
|
||||||
|
"campaigns.workflow.prepare-validate-and-build",
|
||||||
|
"campaigns.workflow.complete-review",
|
||||||
|
"campaigns.workflow.retry-and-reconcile",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert set(existing_user_workflows) == {
|
||||||
|
"campaigns.mail-profile-user-journey",
|
||||||
|
"campaigns.workflow.prepare-validate-and-build",
|
||||||
|
"campaigns.workflow.complete-review",
|
||||||
|
"campaigns.workflow.retry-and-reconcile",
|
||||||
|
}
|
||||||
|
for topic in existing_user_workflows.values():
|
||||||
|
assert topic.metadata["help_contexts"]
|
||||||
Reference in New Issue
Block a user