from __future__ import annotations from pathlib import Path from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER from govoplan_core.core.campaigns import ( CAPABILITY_CAMPAIGNS_ACCESS, CAPABILITY_CAMPAIGNS_DELIVERY_TASKS, CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT, CAPABILITY_CAMPAIGNS_POLICY_CONTEXT, CAPABILITY_CAMPAIGNS_RETENTION, ) from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard from govoplan_core.core.modules import ( DocumentationCondition, DocumentationLink, DocumentationTopic, FrontendModule, MigrationSpec, ModuleContext, ModuleInterfaceProvider, ModuleInterfaceRequirement, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate, ) from govoplan_core.db.base import Base 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 register_campaign_change_tracking() def _permission(scope: str, label: str, description: str, category: str) -> PermissionDefinition: module_id, resource, action = scope.split(":", 2) return PermissionDefinition( scope=scope, label=label, description=description, category=category, level="tenant", module_id=module_id, resource=resource, action=action, ) PERMISSIONS = ( _permission("campaigns:campaign:read", "View campaigns", "Open campaign metadata, versions and permitted message summaries.", "Campaigns"), _permission("campaigns:campaign:create", "Create campaigns", "Create new campaigns in an accessible tenant scope.", "Campaigns"), _permission("campaigns:campaign:update", "Edit campaigns", "Edit current working campaign versions.", "Campaigns"), _permission("campaigns:campaign:copy", "Copy campaigns", "Create campaigns or working versions from existing campaigns.", "Campaigns"), _permission("campaigns:campaign:archive", "Archive campaigns", "Archive campaigns without destroying audit evidence.", "Campaigns"), _permission("campaigns:campaign:delete", "Delete campaigns", "Delete draft-only campaigns where retention policy allows it.", "Campaigns"), _permission("campaigns:campaign:share", "Share campaigns", "Grant or revoke explicit campaign access.", "Campaigns"), _permission("campaigns:campaign:validate", "Validate campaigns", "Run technical validation and manage validation locks.", "Campaigns"), _permission("campaigns:campaign:build", "Build campaigns", "Build exact messages and attachment evidence.", "Campaigns"), _permission("campaigns:campaign:review", "Approve campaign review", "Approve or reject built messages and review conditions.", "Campaigns"), _permission("campaigns:campaign:send_test", "Mock-send campaigns", "Use mock delivery and verification tools.", "Campaigns"), _permission("campaigns:campaign:queue", "Queue campaigns", "Place approved executions into the delivery queue.", "Campaigns"), _permission("campaigns:campaign:control", "Control delivery", "Pause, resume or cancel queued and sending jobs.", "Campaigns"), _permission("campaigns:campaign:send", "Send campaigns", "Start real SMTP delivery.", "Campaigns"), _permission("campaigns:campaign:retry", "Retry delivery", "Retry failed or unattempted delivery jobs.", "Campaigns"), _permission("campaigns:campaign:reconcile", "Reconcile delivery", "Resolve outcome-unknown SMTP or IMAP attempts after inspection.", "Campaigns"), _permission("campaigns:diagnostic:read", "View campaign diagnostics", "Inspect worker claims and internal storage locators for campaign delivery troubleshooting.", "Campaign operations"), _permission("campaigns:recipient:read", "View recipients", "Read recipient lists and recipient-specific campaign data.", "Recipients"), _permission("campaigns:recipient:write", "Edit recipients", "Create and edit recipient rows and field values.", "Recipients"), _permission("campaigns:recipient:import", "Import recipients", "Bulk-import recipient lists.", "Recipients"), _permission("campaigns:recipient:export", "Export recipients", "Export recipient data and reports.", "Recipients"), _permission("campaigns:report:read", "View reports", "View campaign delivery reports and aggregate outcomes.", "Reports"), _permission("campaigns:report:export", "Export reports", "Download detailed campaign reports.", "Reports"), _permission("campaigns:report:send", "Send reports", "Email campaign reports to configured recipients.", "Reports"), ) ROLE_TEMPLATES = ( RoleTemplate( slug="campaign_manager", name="Campaign manager", description="Prepare, validate and build campaigns without approving real delivery.", permissions=( "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", ), ), RoleTemplate( slug="campaign_reviewer", name="Campaign reviewer", description="Inspect and approve prepared campaign messages.", permissions=( "campaigns:campaign:read", "campaigns:campaign:validate", "campaigns:campaign:review", "campaigns:recipient:read", "campaigns:report:read", ), ), RoleTemplate( slug="campaign_sender", name="Campaign sender", description="Queue, test, control, send and reconcile prepared campaigns.", permissions=( "campaigns:campaign:read", "campaigns:campaign:send_test", "campaigns:campaign:queue", "campaigns:campaign:control", "campaigns:campaign:send", "campaigns:campaign:retry", "campaigns:campaign:reconcile", "campaigns:diagnostic:read", "campaigns:recipient:read", "campaigns:report:read", "campaigns:report:send", ), ), ) def _tenant_summary(session, tenant_id: str) -> dict[str, int]: from govoplan_campaign.backend.db.models import Campaign return {"campaigns": session.query(Campaign).filter(Campaign.tenant_id == tenant_id).count()} def _campaigns_router(context: ModuleContext): from govoplan_campaign.backend.runtime import configure_runtime configure_runtime(registry=context.registry, settings=context.settings) from fastapi import APIRouter from govoplan_campaign.backend.router import router from govoplan_campaign.backend.schema_router import router as schema_router aggregate = APIRouter() aggregate.include_router(router) aggregate.include_router(schema_router) return aggregate manifest = ModuleManifest( id="campaigns", name="Campaigns", version="0.1.9", required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), optional_dependencies=("files", "mail", "notifications", "addresses"), provides_interfaces=( ModuleInterfaceProvider(name="campaigns.access", version="0.1.6"), ModuleInterfaceProvider(name="campaigns.delivery_tasks", version="0.1.6"), ModuleInterfaceProvider(name="campaigns.mail_policy_context", version="0.1.6"), ModuleInterfaceProvider(name="campaigns.policy_context", version="0.1.6"), ModuleInterfaceProvider(name="campaigns.retention", version="0.1.6"), ), requires_interfaces=( ModuleInterfaceRequirement( name="files.campaign_attachments", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True, ), ModuleInterfaceRequirement( name="mail.campaign_delivery", version_min="0.2.0", version_max_exclusive="0.3.0", optional=True, ), ModuleInterfaceRequirement( name="addresses.lookup", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True, ), ModuleInterfaceRequirement( name="addresses.recipient_source", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True, ), ), permissions=PERMISSIONS, route_factory=_campaigns_router, role_templates=ROLE_TEMPLATES, tenant_summary_providers=(_tenant_summary,), nav_items=( NavItem(path="/campaigns", label="Campaigns", icon="campaign", required_any=("campaigns:campaign:read",), order=20), NavItem( path="/operator", label="Operator Queue", icon="radio-tower", required_any=( "campaigns:campaign:queue", "campaigns:campaign:retry", "campaigns:campaign:reconcile", "campaigns:campaign:control", "campaigns:campaign:send", ), order=30, ), NavItem(path="/reports", label="Reports", icon="clipboard-pen-line", required_any=("campaigns:report:read",), order=70), ), frontend=FrontendModule( module_id="campaigns", package_name="@govoplan/campaign-webui", nav_items=( NavItem(path="/campaigns", label="Campaigns", icon="campaign", required_any=("campaigns:campaign:read",), order=20), NavItem( path="/operator", label="Operator Queue", icon="radio-tower", required_any=( "campaigns:campaign:queue", "campaigns:campaign:retry", "campaigns:campaign:reconcile", "campaigns:campaign:control", "campaigns:campaign:send", ), order=30, ), NavItem(path="/reports", label="Reports", icon="clipboard-pen-line", required_any=("campaigns:report:read",), order=70), NavItem(path="/templates", label="Templates", icon="layout-template", order=90), ), ), migration_spec=MigrationSpec( module_id="campaigns", metadata=Base.metadata, script_location=str(Path(__file__).with_name("migrations") / "versions"), retirement_supported=True, retirement_provider=drop_table_retirement_provider( campaign_models.Campaign, campaign_models.CampaignShare, campaign_models.RecipientImportMappingProfile, campaign_models.CampaignVersion, campaign_models.CampaignJob, campaign_models.CampaignIssue, campaign_models.AttachmentBlob, campaign_models.AttachmentInstance, campaign_models.SendAttempt, campaign_models.ImapAppendAttempt, label="Campaigns", ), retirement_notes="Destructive retirement drops campaign-owned database tables after the installer captures a database snapshot.", ), uninstall_guard_providers=( persistent_table_uninstall_guard( campaign_models.Campaign, campaign_models.CampaignShare, campaign_models.RecipientImportMappingProfile, campaign_models.CampaignVersion, campaign_models.CampaignJob, campaign_models.CampaignIssue, campaign_models.AttachmentBlob, campaign_models.AttachmentInstance, campaign_models.SendAttempt, campaign_models.ImapAppendAttempt, label="Campaigns", ), ), documentation=( DocumentationTopic( 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.", layer="available", documentation_types=("user",), audience=("campaign_manager", "campaign_reviewer", "campaign_sender"), order=46, conditions=( DocumentationCondition( required_modules=("campaigns", "mail"), any_scopes=("campaigns:campaign:read", "mail:profile:use"), ), ), links=( DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"), DocumentationLink(label="Mail profiles", href="/mail", kind="runtime"), ), related_modules=("mail",), unlocks=("Profile-backed SMTP delivery and optional IMAP append-to-Sent.",), metadata={ "kind": "workflow", "route": "/campaigns/{campaign_id}/mail", "screen": "Campaign Mail settings", "prerequisites": [ "Campaign and Mail are installed.", "You may edit the current campaign version and use at least one authorized Mail profile.", ], "steps": [ "Open the campaign and go to Mail settings.", "Select an available Mail profile; Campaign stores only its stable identifier.", "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.", "related_topic_ids": [ "campaigns.mail-profile-governance", "campaigns.mail-profile-operations", "mail.profile-ownership-and-consumers", ], }, ), DocumentationTopic( 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.", layer="configured", documentation_types=("admin",), audience=("tenant_admin", "mail_admin", "campaign_admin"), order=47, conditions=( DocumentationCondition( required_modules=("campaigns", "mail"), any_scopes=("mail:profile:write", "admin:policies:read", "system:settings:read"), ), ), links=( DocumentationLink(label="Mail profiles", href="/mail", kind="runtime"), DocumentationLink(label="Campaign schema", href="/api/v1/campaigns/schema", kind="api"), ), related_modules=("mail", "access"), unlocks=("Auditable, reusable transport configuration across campaigns.",), metadata={ "kind": "reference", "route": "/campaigns/{campaign_id}/mail-policy", "screen": "Campaign Mail policy", "section": "Profile authorization and credential inheritance", "related_topic_ids": [ "campaigns.mail-profile-user-journey", "campaigns.mail-profile-operations", "mail.profile-ownership-and-consumers", ], }, ), DocumentationTopic( 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.", layer="configured", documentation_types=("admin",), audience=("campaign_sender", "campaign_operator", "mail_admin"), order=48, conditions=( DocumentationCondition( required_modules=("campaigns", "mail"), any_scopes=("campaigns:diagnostic:read", "campaigns:campaign:reconcile", "mail:profile:test"), ), ), links=( DocumentationLink(label="Campaign operator queue", href="/operator", kind="runtime"), DocumentationLink(label="Campaign reports", href="/reports", kind="runtime"), ), related_modules=("mail", "audit"), unlocks=("Fail-closed recovery without exposing Mail credentials.",), metadata={ "kind": "reference", "route": "/operator", "screen": "Campaign operator queue", "section": "Profile-backed delivery recovery", "related_topic_ids": [ "campaigns.mail-profile-user-journey", "campaigns.mail-profile-governance", "mail.profile-ownership-and-consumers", ], }, ), ), capability_factories={ CAPABILITY_CAMPAIGNS_ACCESS: lambda context: __import__( "govoplan_campaign.backend.capabilities", fromlist=["access_capability"], ).access_capability(context), CAPABILITY_CAMPAIGNS_DELIVERY_TASKS: lambda context: __import__( "govoplan_campaign.backend.capabilities", fromlist=["delivery_tasks_capability"], ).delivery_tasks_capability(context), CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT: lambda context: __import__( "govoplan_campaign.backend.capabilities", fromlist=["mail_policy_context_capability"], ).mail_policy_context_capability(context), CAPABILITY_CAMPAIGNS_POLICY_CONTEXT: lambda context: __import__( "govoplan_campaign.backend.capabilities", fromlist=["policy_context_capability"], ).policy_context_capability(context), CAPABILITY_CAMPAIGNS_RETENTION: lambda context: __import__( "govoplan_campaign.backend.capabilities", fromlist=["retention_capability"], ).retention_capability(context), }, ) def get_manifest() -> ModuleManifest: return manifest