Release v0.1.5
This commit is contained in:
35
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
35
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: "Bug"
|
||||
about: "Report a reproducible defect, regression, or incorrect behavior"
|
||||
title: "[Bug] "
|
||||
labels:
|
||||
- type/bug
|
||||
- status/triage
|
||||
- module/campaign
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
- Repository:
|
||||
- Area/module:
|
||||
- Affected version or commit:
|
||||
|
||||
## Behavior
|
||||
|
||||
Expected:
|
||||
|
||||
Actual:
|
||||
|
||||
## Reproduction
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Evidence
|
||||
|
||||
Logs, screenshots, traces, or failing test output:
|
||||
|
||||
## Verification Target
|
||||
|
||||
Command or workflow that should pass when fixed:
|
||||
1
.gitea/ISSUE_TEMPLATE/config.yaml
Normal file
1
.gitea/ISSUE_TEMPLATE/config.yaml
Normal file
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
27
.gitea/ISSUE_TEMPLATE/docs_workflow.md
Normal file
27
.gitea/ISSUE_TEMPLATE/docs_workflow.md
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: "Docs / workflow"
|
||||
about: "Request documentation, process, or developer workflow changes"
|
||||
title: "[Docs] "
|
||||
labels:
|
||||
- type/docs
|
||||
- status/triage
|
||||
- module/campaign
|
||||
- area/docs
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
- Repository:
|
||||
- Document or workflow:
|
||||
|
||||
## Current State
|
||||
|
||||
What is missing, unclear, duplicated, or stale?
|
||||
|
||||
## Desired State
|
||||
|
||||
What should the docs or workflow make clear?
|
||||
|
||||
## Verification Target
|
||||
|
||||
How should this be checked?
|
||||
32
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
32
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: "Feature"
|
||||
about: "Propose new user-visible behavior or platform capability"
|
||||
title: "[Feature] "
|
||||
labels:
|
||||
- type/feature
|
||||
- status/triage
|
||||
- module/campaign
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
What user, operator, or developer problem should this solve?
|
||||
|
||||
## Proposed Capability
|
||||
|
||||
What should exist when this is done?
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owning repository:
|
||||
- Related module repositories:
|
||||
- Extension point or integration boundary:
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ]
|
||||
- [ ]
|
||||
|
||||
## Verification Target
|
||||
|
||||
Command, scenario, or UI flow that should prove completion:
|
||||
28
.gitea/ISSUE_TEMPLATE/task.md
Normal file
28
.gitea/ISSUE_TEMPLATE/task.md
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: "Task"
|
||||
about: "Track implementation, maintenance, or migration work"
|
||||
title: "[Task] "
|
||||
labels:
|
||||
- type/task
|
||||
- status/triage
|
||||
- module/campaign
|
||||
---
|
||||
|
||||
## Objective
|
||||
|
||||
What needs to be completed?
|
||||
|
||||
## Scope
|
||||
|
||||
- Owning repository:
|
||||
- In-scope:
|
||||
- Out-of-scope:
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ]
|
||||
- [ ]
|
||||
|
||||
## Verification Target
|
||||
|
||||
Command or manual check:
|
||||
25
.gitea/ISSUE_TEMPLATE/tech_debt.md
Normal file
25
.gitea/ISSUE_TEMPLATE/tech_debt.md
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: "Tech debt"
|
||||
about: "Track cleanup, refactoring, risk reduction, or deferred engineering work"
|
||||
title: "[Debt] "
|
||||
labels:
|
||||
- type/debt
|
||||
- status/triage
|
||||
- module/campaign
|
||||
---
|
||||
|
||||
## Current Cost
|
||||
|
||||
What does this make harder, riskier, slower, or more fragile?
|
||||
|
||||
## Desired Shape
|
||||
|
||||
What should the code, tests, or architecture look like afterwards?
|
||||
|
||||
## Constraints
|
||||
|
||||
What behavior, compatibility, or module boundary must be preserved?
|
||||
|
||||
## Verification Target
|
||||
|
||||
Focused checks that should pass:
|
||||
15
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
15
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
@@ -0,0 +1,15 @@
|
||||
## Issue
|
||||
|
||||
Closes #
|
||||
|
||||
## Summary
|
||||
|
||||
-
|
||||
|
||||
## Verification
|
||||
|
||||
-
|
||||
|
||||
## Notes
|
||||
|
||||
Follow-up issues:
|
||||
13
README.md
13
README.md
@@ -28,6 +28,19 @@ Files and mail are optional module integrations declared in the campaign manifes
|
||||
|
||||
Backend optional behavior is accessed through core-provided capabilities, not direct required imports. WebUI optional behavior uses core module metadata/capabilities so campaign pages can build and run without files or mail WebUI packages installed.
|
||||
|
||||
Campaign also provides narrow kernel capabilities so other modules and core
|
||||
services can cooperate without importing campaign internals:
|
||||
|
||||
- `campaigns.access` for campaign share/existence checks
|
||||
- `campaigns.mailPolicyContext` for campaign-scoped mail policy and owner
|
||||
context
|
||||
- `campaigns.policyContext` for retention/policy provenance
|
||||
- `campaigns.deliveryTasks` for queued send and append-to-Sent workers
|
||||
- `campaigns.retention` for campaign-owned retention cleanup
|
||||
|
||||
Keep these capability payloads narrow: stable ids, policy payloads, and task
|
||||
results only.
|
||||
|
||||
## Development
|
||||
|
||||
Install through the core environment:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/campaign-webui",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.5",
|
||||
"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.4",
|
||||
"@govoplan/core-webui": "^0.1.5",
|
||||
"lucide-react": "^0.555.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-campaign"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
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.4",
|
||||
"govoplan-core>=0.1.5",
|
||||
"jsonschema>=4,<5",
|
||||
"pydantic>=2,<3",
|
||||
"SQLAlchemy>=2,<3",
|
||||
|
||||
177
src/govoplan_campaign/backend/capabilities.py
Normal file
177
src/govoplan_campaign/backend/capabilities.py
Normal file
@@ -0,0 +1,177 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping
|
||||
|
||||
from sqlalchemy import and_, or_
|
||||
|
||||
from govoplan_core.core.campaigns import (
|
||||
CampaignAccessProvider,
|
||||
CampaignDeliveryTaskProvider,
|
||||
CampaignMailPolicyContext,
|
||||
CampaignMailPolicyContextProvider,
|
||||
CampaignPolicyContext,
|
||||
CampaignPolicyContextProvider,
|
||||
CampaignRetentionProvider,
|
||||
)
|
||||
|
||||
from govoplan_campaign.backend.db.models import Campaign, CampaignShare
|
||||
|
||||
|
||||
class CampaignMailPolicyContextService(CampaignMailPolicyContextProvider):
|
||||
def get_campaign_mail_policy_context(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
) -> CampaignMailPolicyContext | None:
|
||||
campaign = session.get(Campaign, campaign_id) # type: ignore[attr-defined]
|
||||
if campaign is None or campaign.tenant_id != tenant_id:
|
||||
return None
|
||||
return CampaignMailPolicyContext(
|
||||
id=campaign.id,
|
||||
tenant_id=campaign.tenant_id,
|
||||
owner_user_id=campaign.owner_user_id,
|
||||
owner_group_id=campaign.owner_group_id,
|
||||
mail_profile_policy=dict(campaign.mail_profile_policy or {}),
|
||||
)
|
||||
|
||||
def set_campaign_mail_profile_policy(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
policy: Mapping[str, object],
|
||||
) -> Mapping[str, object]:
|
||||
campaign = session.get(Campaign, campaign_id) # type: ignore[attr-defined]
|
||||
if campaign is None or campaign.tenant_id != tenant_id:
|
||||
raise ValueError("Campaign policy not found")
|
||||
payload = dict(policy)
|
||||
campaign.mail_profile_policy = payload
|
||||
session.add(campaign) # type: ignore[attr-defined]
|
||||
return payload
|
||||
|
||||
|
||||
def mail_policy_context_capability(context: object) -> CampaignMailPolicyContextService:
|
||||
return CampaignMailPolicyContextService()
|
||||
|
||||
|
||||
class CampaignAccessService(CampaignAccessProvider):
|
||||
def campaign_exists(self, session: object, *, tenant_id: str, campaign_id: str) -> bool:
|
||||
campaign = session.get(Campaign, campaign_id) # type: ignore[attr-defined]
|
||||
return bool(campaign and campaign.tenant_id == tenant_id)
|
||||
|
||||
def can_read_campaign(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
user_id: str,
|
||||
group_ids: Iterable[str] = (),
|
||||
tenant_admin: bool = False,
|
||||
) -> bool:
|
||||
campaign = session.get(Campaign, campaign_id) # type: ignore[attr-defined]
|
||||
if campaign is None or campaign.tenant_id != tenant_id:
|
||||
return False
|
||||
if tenant_admin or campaign.owner_user_id == user_id:
|
||||
return True
|
||||
group_id_set = {str(group_id) for group_id in group_ids}
|
||||
if campaign.owner_group_id and campaign.owner_group_id in group_id_set:
|
||||
return True
|
||||
share_clauses = [
|
||||
and_(
|
||||
CampaignShare.target_type == "user",
|
||||
CampaignShare.target_id == user_id,
|
||||
)
|
||||
]
|
||||
if group_id_set:
|
||||
share_clauses.append(
|
||||
and_(
|
||||
CampaignShare.target_type == "group",
|
||||
CampaignShare.target_id.in_(group_id_set),
|
||||
)
|
||||
)
|
||||
share = (
|
||||
session.query(CampaignShare) # type: ignore[attr-defined]
|
||||
.filter(
|
||||
CampaignShare.tenant_id == tenant_id,
|
||||
CampaignShare.campaign_id == campaign.id,
|
||||
CampaignShare.revoked_at.is_(None),
|
||||
or_(*share_clauses),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
return share is not None
|
||||
|
||||
|
||||
def access_capability(context: object) -> CampaignAccessService:
|
||||
return CampaignAccessService()
|
||||
|
||||
|
||||
class CampaignPolicyContextService(CampaignPolicyContextProvider):
|
||||
def get_campaign_policy_context(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
campaign_id: str,
|
||||
) -> CampaignPolicyContext | None:
|
||||
campaign = session.get(Campaign, campaign_id) # type: ignore[attr-defined]
|
||||
if campaign is None or (tenant_id is not None and campaign.tenant_id != tenant_id):
|
||||
return None
|
||||
return CampaignPolicyContext(
|
||||
id=campaign.id,
|
||||
tenant_id=campaign.tenant_id,
|
||||
owner_user_id=campaign.owner_user_id,
|
||||
owner_group_id=campaign.owner_group_id,
|
||||
settings=dict(campaign.settings or {}),
|
||||
)
|
||||
|
||||
def set_campaign_settings(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
settings: Mapping[str, object],
|
||||
) -> Mapping[str, object]:
|
||||
campaign = session.get(Campaign, campaign_id) # type: ignore[attr-defined]
|
||||
if campaign is None or campaign.tenant_id != tenant_id:
|
||||
raise ValueError("Campaign privacy policy not found")
|
||||
payload = dict(settings)
|
||||
campaign.settings = payload
|
||||
session.add(campaign) # type: ignore[attr-defined]
|
||||
return payload
|
||||
|
||||
|
||||
def policy_context_capability(context: object) -> CampaignPolicyContextService:
|
||||
return CampaignPolicyContextService()
|
||||
|
||||
|
||||
class CampaignDeliveryTaskService(CampaignDeliveryTaskProvider):
|
||||
def send_campaign_job(self, session: object, *, job_id: str, enqueue_imap_task: bool = True) -> Mapping[str, object]:
|
||||
from govoplan_campaign.backend.sending.jobs import send_campaign_job
|
||||
|
||||
return send_campaign_job(session, job_id=job_id, enqueue_imap_task=enqueue_imap_task).as_dict() # type: ignore[arg-type]
|
||||
|
||||
def append_sent_for_job(self, session: object, *, job_id: str) -> Mapping[str, object]:
|
||||
from govoplan_campaign.backend.sending.jobs import append_sent_for_job
|
||||
|
||||
return append_sent_for_job(session, job_id=job_id).as_dict() # type: ignore[arg-type]
|
||||
|
||||
|
||||
def delivery_tasks_capability(context: object) -> CampaignDeliveryTaskService:
|
||||
return CampaignDeliveryTaskService()
|
||||
|
||||
|
||||
class CampaignRetentionService(CampaignRetentionProvider):
|
||||
def apply_retention(self, session: object, *, dry_run, now, policy_for_campaign_id):
|
||||
from govoplan_campaign.backend.retention import apply_campaign_retention
|
||||
|
||||
return apply_campaign_retention(session, dry_run=dry_run, now=now, policy_for_campaign_id=policy_for_campaign_id) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def retention_capability(context: object) -> CampaignRetentionService:
|
||||
return CampaignRetentionService()
|
||||
@@ -10,11 +10,6 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from govoplan_core.db.base import Base, TimestampMixin
|
||||
|
||||
try:
|
||||
from govoplan_core.db.models import Group, Tenant, User, UserGroupMembership
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
pass
|
||||
|
||||
|
||||
def new_uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
@@ -125,7 +120,7 @@ class Campaign(Base, TimestampMixin):
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
mail_profile_policy: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
tenant: Mapped[Tenant] = relationship()
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant")
|
||||
versions: Mapped[list[CampaignVersion]] = relationship(back_populates="campaign", cascade="all, delete-orphan")
|
||||
jobs: Mapped[list[CampaignJob]] = relationship(back_populates="campaign", cascade="all, delete-orphan")
|
||||
|
||||
@@ -167,8 +162,8 @@ class RecipientImportMappingProfile(Base, TimestampMixin):
|
||||
value_separators: Mapped[str] = mapped_column(String(50), default=",;|", nullable=False)
|
||||
mappings: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
|
||||
|
||||
tenant: Mapped[Tenant] = relationship()
|
||||
owner: Mapped[User] = relationship()
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant")
|
||||
owner: Mapped["User"] = relationship("User")
|
||||
|
||||
|
||||
class CampaignVersion(Base, TimestampMixin):
|
||||
@@ -346,7 +341,6 @@ __all__ = [
|
||||
"CampaignVersion",
|
||||
"CampaignVersionFlow",
|
||||
"CampaignVersionWorkflowState",
|
||||
"Group",
|
||||
"ImapAppendAttempt",
|
||||
"IssueSeverity",
|
||||
"JobBuildStatus",
|
||||
@@ -355,7 +349,4 @@ __all__ = [
|
||||
"JobSendStatus",
|
||||
"JobValidationStatus",
|
||||
"SendAttempt",
|
||||
"Tenant",
|
||||
"User",
|
||||
"UserGroupMembership",
|
||||
]
|
||||
|
||||
@@ -2,35 +2,28 @@ from __future__ import annotations
|
||||
|
||||
from email import policy
|
||||
from email.message import EmailMessage
|
||||
from importlib import import_module
|
||||
from types import ModuleType
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.db.models import Campaign, CampaignVersion
|
||||
from govoplan_core.core.optional import reraise_unless_missing_package
|
||||
from govoplan_campaign.backend.campaign.loader import load_campaign_json
|
||||
from govoplan_campaign.backend.campaign.validation import 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.integrations import files_integration
|
||||
from govoplan_campaign.backend.integrations import files_integration, mail_integration
|
||||
|
||||
|
||||
class MockCampaignSendError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _mock_mailbox() -> ModuleType | None:
|
||||
try:
|
||||
return import_module("govoplan_mail.backend.dev.mock_mailbox")
|
||||
except ModuleNotFoundError as exc:
|
||||
reraise_unless_missing_package(exc, "govoplan_mail")
|
||||
return None
|
||||
def _mock_mailbox() -> Any | None:
|
||||
return mail_integration().mock_mailbox()
|
||||
|
||||
|
||||
def _require_mock_mailbox() -> ModuleType:
|
||||
def _require_mock_mailbox() -> Any:
|
||||
mailbox = _mock_mailbox()
|
||||
if mailbox is None:
|
||||
raise MockCampaignSendError("Mail module is not available")
|
||||
|
||||
@@ -34,7 +34,9 @@ class ImapConfigurationError(RuntimeError):
|
||||
|
||||
|
||||
class ImapAppendError(RuntimeError):
|
||||
pass
|
||||
def __init__(self, message: str, *, temporary: bool | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.temporary = temporary
|
||||
|
||||
|
||||
class MailProfileError(OptionalModuleUnavailable):
|
||||
@@ -208,7 +210,7 @@ class MailCampaignIntegration:
|
||||
try:
|
||||
return delegate.append_message_to_sent(*args, **kwargs)
|
||||
except getattr(delegate, "ImapAppendError", ImapAppendError) as exc:
|
||||
raise ImapAppendError(str(exc)) from exc
|
||||
raise ImapAppendError(str(exc), temporary=getattr(exc, "temporary", None)) from exc
|
||||
except getattr(delegate, "ImapConfigurationError", ImapConfigurationError) as exc:
|
||||
raise ImapConfigurationError(str(exc)) from exc
|
||||
|
||||
@@ -221,6 +223,11 @@ class MailCampaignIntegration:
|
||||
except getattr(delegate, "SmtpConfigurationError", SmtpConfigurationError) as exc:
|
||||
raise SmtpConfigurationError(str(exc)) from exc
|
||||
|
||||
def mock_mailbox(self) -> Any | None:
|
||||
if self._delegate is None or not hasattr(self._delegate, "mock_mailbox"):
|
||||
return None
|
||||
return self._delegate.mock_mailbox()
|
||||
|
||||
|
||||
def files_integration() -> FilesCampaignIntegration:
|
||||
return FilesCampaignIntegration(capability(FILES_CAPABILITY))
|
||||
|
||||
@@ -2,6 +2,14 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
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 FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_campaign.backend.db import models as campaign_models # noqa: F401 - populate Campaign ORM metadata
|
||||
@@ -97,24 +105,36 @@ ROLE_TEMPLATES = (
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
||||
return router
|
||||
aggregate = APIRouter()
|
||||
aggregate.include_router(router)
|
||||
aggregate.include_router(schema_router)
|
||||
return aggregate
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id="campaigns",
|
||||
name="Campaigns",
|
||||
version="0.1.4",
|
||||
version="0.1.5",
|
||||
dependencies=("access",),
|
||||
optional_dependencies=("files", "mail"),
|
||||
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(
|
||||
@@ -159,7 +179,59 @@ manifest = ModuleManifest(
|
||||
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",
|
||||
),
|
||||
),
|
||||
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),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
191
src/govoplan_campaign/backend/retention.py
Normal file
191
src/govoplan_campaign/backend/retention.py
Normal file
@@ -0,0 +1,191 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.db.models import CampaignJob, CampaignVersion, JobImapStatus, JobQueueStatus
|
||||
|
||||
FINAL_VERSION_STATES = {
|
||||
"completed",
|
||||
"partially_completed",
|
||||
"outcome_unknown",
|
||||
"failed",
|
||||
"cancelled",
|
||||
"archived",
|
||||
}
|
||||
FINAL_EML_SEND_STATUSES = {
|
||||
"smtp_accepted",
|
||||
"sent",
|
||||
"failed_permanent",
|
||||
"cancelled",
|
||||
"outcome_unknown",
|
||||
}
|
||||
|
||||
|
||||
def _cutoff(days: int | None, *, now: datetime) -> datetime | None:
|
||||
if days is None:
|
||||
return None
|
||||
return now - timedelta(days=days)
|
||||
|
||||
|
||||
def _is_before_cutoff(value: datetime | None, cutoff: datetime | None) -> bool:
|
||||
if value is None or cutoff is None:
|
||||
return False
|
||||
candidate = value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value
|
||||
return candidate < cutoff
|
||||
|
||||
|
||||
def _json_sha256(value: Any) -> str:
|
||||
payload = json.dumps(value, sort_keys=True, ensure_ascii=False, default=str).encode("utf-8")
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
|
||||
def _summary_was_redacted(summary: Any) -> bool:
|
||||
return isinstance(summary, dict) and bool(summary.get("_retention", {}).get("report_detail_redacted"))
|
||||
|
||||
|
||||
def _redacted_report_summary(summary: Any, *, now: datetime) -> tuple[dict[str, Any] | None, bool]:
|
||||
if not isinstance(summary, dict):
|
||||
return summary, False
|
||||
if _summary_was_redacted(summary):
|
||||
return summary, False
|
||||
detail_keys = {"messages", "issues", "recent_failures", "jobs"}
|
||||
if not any(key in summary for key in detail_keys):
|
||||
return summary, False
|
||||
next_summary = copy.deepcopy(summary)
|
||||
for key in detail_keys:
|
||||
next_summary.pop(key, None)
|
||||
retention = dict(next_summary.get("_retention") or {})
|
||||
retention.update({"report_detail_redacted": True, "redacted_at": now.isoformat()})
|
||||
next_summary["_retention"] = retention
|
||||
return next_summary, True
|
||||
|
||||
|
||||
def _apply_raw_json_retention(
|
||||
session: Session,
|
||||
*,
|
||||
dry_run: bool,
|
||||
now: datetime,
|
||||
policy_for_campaign_id: Callable[[str | None], object],
|
||||
) -> dict[str, int]:
|
||||
result = {"eligible": 0, "redacted": 0, "skipped_not_final": 0, "already_redacted": 0}
|
||||
versions = session.query(CampaignVersion).order_by(CampaignVersion.updated_at.asc()).all()
|
||||
for version in versions:
|
||||
policy = policy_for_campaign_id(version.campaign_id)
|
||||
cutoff = _cutoff(0 if not policy.store_raw_campaign_json else policy.raw_campaign_json_retention_days, now=now)
|
||||
if not _is_before_cutoff(version.updated_at, cutoff):
|
||||
continue
|
||||
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||
if raw_json.get("_retention", {}).get("raw_json_redacted"):
|
||||
result["already_redacted"] += 1
|
||||
continue
|
||||
if version.workflow_state not in FINAL_VERSION_STATES:
|
||||
result["skipped_not_final"] += 1
|
||||
continue
|
||||
result["eligible"] += 1
|
||||
if dry_run:
|
||||
continue
|
||||
snapshot = version.execution_snapshot if isinstance(version.execution_snapshot, dict) else {}
|
||||
version.raw_json = {
|
||||
"version": version.schema_version or "1.0",
|
||||
"_retention": {
|
||||
"raw_json_redacted": True,
|
||||
"redacted_at": now.isoformat(),
|
||||
"original_sha256": snapshot.get("campaign_json_sha256") or _json_sha256(raw_json),
|
||||
},
|
||||
}
|
||||
session.add(version)
|
||||
result["redacted"] += 1
|
||||
return result
|
||||
|
||||
|
||||
def _apply_eml_retention(
|
||||
session: Session,
|
||||
*,
|
||||
dry_run: bool,
|
||||
now: datetime,
|
||||
policy_for_campaign_id: Callable[[str | None], object],
|
||||
) -> dict[str, int]:
|
||||
result = {"eligible": 0, "metadata_cleared": 0, "files_deleted": 0, "files_missing": 0, "skipped_not_final": 0}
|
||||
jobs = (
|
||||
session.query(CampaignJob)
|
||||
.filter((CampaignJob.eml_local_path.is_not(None)) | (CampaignJob.eml_storage_key.is_not(None)))
|
||||
.order_by(CampaignJob.updated_at.asc())
|
||||
.all()
|
||||
)
|
||||
for job in jobs:
|
||||
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):
|
||||
continue
|
||||
if job.queue_status in {JobQueueStatus.QUEUED.value, JobQueueStatus.SENDING.value} or job.send_status not in FINAL_EML_SEND_STATUSES:
|
||||
result["skipped_not_final"] += 1
|
||||
continue
|
||||
if job.imap_status == JobImapStatus.PENDING.value:
|
||||
result["skipped_not_final"] += 1
|
||||
continue
|
||||
result["eligible"] += 1
|
||||
if dry_run:
|
||||
continue
|
||||
if job.eml_local_path:
|
||||
path = Path(job.eml_local_path)
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
result["files_deleted"] += 1
|
||||
else:
|
||||
result["files_missing"] += 1
|
||||
job.eml_local_path = None
|
||||
job.eml_storage_key = None
|
||||
session.add(job)
|
||||
result["metadata_cleared"] += 1
|
||||
return result
|
||||
|
||||
|
||||
def _apply_report_detail_retention(
|
||||
session: Session,
|
||||
*,
|
||||
dry_run: bool,
|
||||
now: datetime,
|
||||
policy_for_campaign_id: Callable[[str | None], object],
|
||||
) -> dict[str, int]:
|
||||
result = {"eligible_versions": 0, "summaries_redacted": 0, "already_redacted": 0}
|
||||
versions = session.query(CampaignVersion).order_by(CampaignVersion.updated_at.asc()).all()
|
||||
for version in versions:
|
||||
policy = policy_for_campaign_id(version.campaign_id)
|
||||
cutoff = _cutoff(policy.stored_report_detail_retention_days, now=now)
|
||||
if not _is_before_cutoff(version.updated_at, cutoff):
|
||||
continue
|
||||
next_validation, validation_changed = _redacted_report_summary(version.validation_summary, now=now)
|
||||
next_build, build_changed = _redacted_report_summary(version.build_summary, now=now)
|
||||
if not validation_changed and not build_changed:
|
||||
if _summary_was_redacted(version.validation_summary) or _summary_was_redacted(version.build_summary):
|
||||
result["already_redacted"] += 1
|
||||
continue
|
||||
result["eligible_versions"] += 1
|
||||
if dry_run:
|
||||
continue
|
||||
version.validation_summary = next_validation
|
||||
version.build_summary = next_build
|
||||
session.add(version)
|
||||
result["summaries_redacted"] += int(validation_changed) + int(build_changed)
|
||||
return result
|
||||
|
||||
|
||||
def apply_campaign_retention(
|
||||
session: Session,
|
||||
*,
|
||||
dry_run: bool,
|
||||
now: datetime,
|
||||
policy_for_campaign_id: Callable[[str | None], object],
|
||||
) -> dict[str, dict[str, int]]:
|
||||
return {
|
||||
"raw_campaign_json": _apply_raw_json_retention(session, dry_run=dry_run, now=now, policy_for_campaign_id=policy_for_campaign_id),
|
||||
"generated_eml": _apply_eml_retention(session, dry_run=dry_run, now=now, policy_for_campaign_id=policy_for_campaign_id),
|
||||
"stored_report_detail": _apply_report_detail_retention(session, dry_run=dry_run, now=now, policy_for_campaign_id=policy_for_campaign_id),
|
||||
}
|
||||
@@ -41,9 +41,11 @@ from govoplan_campaign.backend.schemas import (
|
||||
ReportEmailRequest,
|
||||
ReportEmailResponse,
|
||||
)
|
||||
from govoplan_core.auth.dependencies import ApiPrincipal, has_scope, require_scope
|
||||
from govoplan_access.backend.auth.dependencies import ApiPrincipal, has_scope, require_scope
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignShare, CampaignVersion, Group, ImapAppendAttempt, RecipientImportMappingProfile, SendAttempt, User, UserGroupMembership
|
||||
from govoplan_core.core.access import CAPABILITY_ACCESS_DIRECTORY, AccessDirectory
|
||||
from govoplan_core.core.runtime import get_registry
|
||||
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignShare, CampaignVersion, ImapAppendAttempt, RecipientImportMappingProfile, SendAttempt
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_campaign.backend.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv
|
||||
from govoplan_campaign.backend.reports.emailing import CampaignReportEmailError, send_campaign_report_email
|
||||
@@ -79,6 +81,16 @@ from govoplan_campaign.backend.persistence.versions import (
|
||||
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
||||
|
||||
|
||||
def _access_directory() -> AccessDirectory:
|
||||
registry = get_registry()
|
||||
if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_ACCESS_DIRECTORY):
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Access directory capability is not configured")
|
||||
capability = registry.require_capability(CAPABILITY_ACCESS_DIRECTORY)
|
||||
if not isinstance(capability, AccessDirectory):
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Access directory capability is invalid")
|
||||
return capability
|
||||
|
||||
|
||||
def _get_campaign_for_tenant(session: Session, campaign_id: str, tenant_id: str) -> Campaign:
|
||||
campaign = session.get(Campaign, campaign_id)
|
||||
if not campaign or campaign.tenant_id != tenant_id:
|
||||
@@ -99,12 +111,8 @@ def _get_version_for_tenant(session: Session, version_id: str, tenant_id: str) -
|
||||
|
||||
|
||||
def _principal_group_ids(session: Session, principal: ApiPrincipal) -> set[str]:
|
||||
return {
|
||||
row[0]
|
||||
for row in session.query(UserGroupMembership.group_id)
|
||||
.filter(UserGroupMembership.tenant_id == principal.tenant_id, UserGroupMembership.user_id == principal.user.id)
|
||||
.all()
|
||||
}
|
||||
del session
|
||||
return {group.id for group in _access_directory().groups_for_user(principal.user.id, tenant_id=principal.tenant_id)}
|
||||
|
||||
|
||||
|
||||
@@ -1530,21 +1538,12 @@ def list_campaign_share_targets(
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
users = (
|
||||
session.query(User)
|
||||
.filter(User.tenant_id == principal.tenant_id, User.is_active.is_(True))
|
||||
.order_by(User.display_name.asc(), User.email.asc())
|
||||
.all()
|
||||
)
|
||||
groups = (
|
||||
session.query(Group)
|
||||
.filter(Group.tenant_id == principal.tenant_id, Group.is_active.is_(True))
|
||||
.order_by(Group.name.asc())
|
||||
.all()
|
||||
)
|
||||
directory = _access_directory()
|
||||
users = [user for user in directory.users_for_tenant(principal.tenant_id) if user.status == "active"]
|
||||
groups = [group for group in directory.groups_for_tenant(principal.tenant_id) if group.status == "active"]
|
||||
return CampaignShareTargetsResponse(
|
||||
users=[CampaignShareTargetItem(id=item.id, name=item.display_name or item.email, secondary=item.email) for item in users],
|
||||
groups=[CampaignShareTargetItem(id=item.id, name=item.name, secondary=item.slug) for item in groups],
|
||||
groups=[CampaignShareTargetItem(id=item.id, name=item.name, secondary=None) for item in groups],
|
||||
)
|
||||
|
||||
|
||||
@@ -1574,12 +1573,17 @@ def update_campaign_owner(
|
||||
campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
if payload.owner_user_id and payload.owner_group_id:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Choose either a user owner or a group owner, not both")
|
||||
directory = _access_directory()
|
||||
if payload.owner_user_id:
|
||||
owner = session.query(User).filter(User.id == payload.owner_user_id, User.tenant_id == principal.tenant_id, User.is_active.is_(True)).one_or_none()
|
||||
owner = directory.get_user(payload.owner_user_id)
|
||||
if owner is not None and (owner.tenant_id != principal.tenant_id or owner.status != "active"):
|
||||
owner = None
|
||||
if owner is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Owner user not found")
|
||||
if payload.owner_group_id:
|
||||
group = session.query(Group).filter(Group.id == payload.owner_group_id, Group.tenant_id == principal.tenant_id, Group.is_active.is_(True)).one_or_none()
|
||||
group = directory.get_group(payload.owner_group_id)
|
||||
if group is not None and (group.tenant_id != principal.tenant_id or group.status != "active"):
|
||||
group = None
|
||||
if group is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Owner group not found")
|
||||
campaign.owner_user_id = payload.owner_user_id
|
||||
@@ -1597,10 +1601,15 @@ def upsert_campaign_share(
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")),
|
||||
):
|
||||
campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
directory = _access_directory()
|
||||
if payload.target_type == "user":
|
||||
target = session.query(User).filter(User.id == payload.target_id, User.tenant_id == principal.tenant_id, User.is_active.is_(True)).one_or_none()
|
||||
target = directory.get_user(payload.target_id)
|
||||
if target is not None and (target.tenant_id != principal.tenant_id or target.status != "active"):
|
||||
target = None
|
||||
else:
|
||||
target = session.query(Group).filter(Group.id == payload.target_id, Group.tenant_id == principal.tenant_id, Group.is_active.is_(True)).one_or_none()
|
||||
target = directory.get_group(payload.target_id)
|
||||
if target is not None and (target.tenant_id != principal.tenant_id or target.status != "active"):
|
||||
target = None
|
||||
if target is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Share target not found")
|
||||
share = (
|
||||
|
||||
31
src/govoplan_campaign/backend/schema_router.py
Normal file
31
src/govoplan_campaign/backend/schema_router.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from govoplan_access.backend.auth.dependencies import ApiPrincipal, require_any_scope
|
||||
|
||||
from govoplan_campaign.backend.campaign.loader import load_campaign_schema, load_campaign_schema_ui
|
||||
|
||||
router = APIRouter(prefix="/schemas", tags=["schemas"])
|
||||
|
||||
|
||||
@router.get("/campaign")
|
||||
def get_campaign_schema(
|
||||
principal: ApiPrincipal = Depends(require_any_scope("campaigns:campaign:read", "campaigns:campaign:create", "admin:roles:read")),
|
||||
) -> dict[str, Any]:
|
||||
"""Return the authoritative campaign JSON Schema used by the backend."""
|
||||
|
||||
del principal
|
||||
return load_campaign_schema()
|
||||
|
||||
|
||||
@router.get("/campaign/ui")
|
||||
def get_campaign_schema_ui(
|
||||
principal: ApiPrincipal = Depends(require_any_scope("campaigns:campaign:read", "campaigns:campaign:create", "admin:roles:read")),
|
||||
) -> dict[str, Any]:
|
||||
"""Return UI metadata paired with the campaign JSON Schema."""
|
||||
|
||||
del principal
|
||||
return load_campaign_schema_ui()
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/campaign-webui",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -17,7 +17,7 @@
|
||||
"read-excel-file": "^9.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.4",
|
||||
"@govoplan/core-webui": "^0.1.5",
|
||||
"lucide-react": "^0.555.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
Reference in New Issue
Block a user