Release v0.1.5

This commit is contained in:
2026-07-07 15:49:06 +02:00
parent fb6fb67d45
commit 533c4c8fe3
19 changed files with 705 additions and 58 deletions

View 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:

View File

@@ -0,0 +1 @@
blank_issues_enabled: false

View 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?

View 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:

View 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:

View 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:

View File

@@ -0,0 +1,15 @@
## Issue
Closes #
## Summary
-
## Verification
-
## Notes
Follow-up issues:

View File

@@ -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. 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 ## Development
Install through the core environment: Install through the core environment:

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/campaign-webui", "name": "@govoplan/campaign-webui",
"version": "0.1.4", "version": "0.1.5",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
@@ -22,7 +22,7 @@
"read-excel-file": "^9.2.0" "read-excel-file": "^9.2.0"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.4", "@govoplan/core-webui": "^0.1.5",
"lucide-react": "^0.555.0", "lucide-react": "^0.555.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View File

@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-campaign" name = "govoplan-campaign"
version = "0.1.4" version = "0.1.5"
description = "GovOPlaN campaigns module with backend and WebUI integration." description = "GovOPlaN campaigns module with backend and WebUI integration."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
license = { file = "LICENSE" } license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.4", "govoplan-core>=0.1.5",
"jsonschema>=4,<5", "jsonschema>=4,<5",
"pydantic>=2,<3", "pydantic>=2,<3",
"SQLAlchemy>=2,<3", "SQLAlchemy>=2,<3",

View 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()

View File

@@ -10,11 +10,6 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from govoplan_core.db.base import Base, TimestampMixin 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: def new_uuid() -> str:
return str(uuid.uuid4()) return str(uuid.uuid4())
@@ -125,7 +120,7 @@ class Campaign(Base, TimestampMixin):
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) 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) 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") versions: Mapped[list[CampaignVersion]] = relationship(back_populates="campaign", cascade="all, delete-orphan")
jobs: Mapped[list[CampaignJob]] = 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) value_separators: Mapped[str] = mapped_column(String(50), default=",;|", nullable=False)
mappings: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False) mappings: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
tenant: Mapped[Tenant] = relationship() tenant: Mapped["Tenant"] = relationship("Tenant")
owner: Mapped[User] = relationship() owner: Mapped["User"] = relationship("User")
class CampaignVersion(Base, TimestampMixin): class CampaignVersion(Base, TimestampMixin):
@@ -346,7 +341,6 @@ __all__ = [
"CampaignVersion", "CampaignVersion",
"CampaignVersionFlow", "CampaignVersionFlow",
"CampaignVersionWorkflowState", "CampaignVersionWorkflowState",
"Group",
"ImapAppendAttempt", "ImapAppendAttempt",
"IssueSeverity", "IssueSeverity",
"JobBuildStatus", "JobBuildStatus",
@@ -355,7 +349,4 @@ __all__ = [
"JobSendStatus", "JobSendStatus",
"JobValidationStatus", "JobValidationStatus",
"SendAttempt", "SendAttempt",
"Tenant",
"User",
"UserGroupMembership",
] ]

View File

@@ -2,35 +2,28 @@ from __future__ import annotations
from email import policy from email import policy
from email.message import EmailMessage from email.message import EmailMessage
from importlib import import_module
from types import ModuleType
from typing import Any from typing import Any
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_campaign.backend.db.models import Campaign, CampaignVersion 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.loader import load_campaign_json
from govoplan_campaign.backend.campaign.validation import validate_campaign_config 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.persistence.campaigns import load_campaign_config_from_json
from govoplan_campaign.backend.messages.builder import build_campaign_messages from govoplan_campaign.backend.messages.builder import build_campaign_messages
from govoplan_campaign.backend.messages.models import MessageAddress, MessageDraft, MessageValidationStatus from govoplan_campaign.backend.messages.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): class MockCampaignSendError(RuntimeError):
pass pass
def _mock_mailbox() -> ModuleType | None: def _mock_mailbox() -> Any | None:
try: return mail_integration().mock_mailbox()
return import_module("govoplan_mail.backend.dev.mock_mailbox")
except ModuleNotFoundError as exc:
reraise_unless_missing_package(exc, "govoplan_mail")
return None
def _require_mock_mailbox() -> ModuleType: def _require_mock_mailbox() -> Any:
mailbox = _mock_mailbox() mailbox = _mock_mailbox()
if mailbox is None: if mailbox is None:
raise MockCampaignSendError("Mail module is not available") raise MockCampaignSendError("Mail module is not available")

View File

@@ -34,7 +34,9 @@ class ImapConfigurationError(RuntimeError):
class ImapAppendError(RuntimeError): class ImapAppendError(RuntimeError):
pass def __init__(self, message: str, *, temporary: bool | None = None) -> None:
super().__init__(message)
self.temporary = temporary
class MailProfileError(OptionalModuleUnavailable): class MailProfileError(OptionalModuleUnavailable):
@@ -208,7 +210,7 @@ class MailCampaignIntegration:
try: try:
return delegate.append_message_to_sent(*args, **kwargs) return delegate.append_message_to_sent(*args, **kwargs)
except getattr(delegate, "ImapAppendError", ImapAppendError) as exc: 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: except getattr(delegate, "ImapConfigurationError", ImapConfigurationError) as exc:
raise ImapConfigurationError(str(exc)) from exc raise ImapConfigurationError(str(exc)) from exc
@@ -221,6 +223,11 @@ class MailCampaignIntegration:
except getattr(delegate, "SmtpConfigurationError", SmtpConfigurationError) as exc: except getattr(delegate, "SmtpConfigurationError", SmtpConfigurationError) as exc:
raise SmtpConfigurationError(str(exc)) from 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: def files_integration() -> FilesCampaignIntegration:
return FilesCampaignIntegration(capability(FILES_CAPABILITY)) return FilesCampaignIntegration(capability(FILES_CAPABILITY))

View File

@@ -2,6 +2,14 @@ from __future__ import annotations
from pathlib import Path 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.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
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
@@ -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): def _campaigns_router(context: ModuleContext):
from govoplan_campaign.backend.runtime import configure_runtime from govoplan_campaign.backend.runtime import configure_runtime
configure_runtime(registry=context.registry, settings=context.settings) configure_runtime(registry=context.registry, settings=context.settings)
from fastapi import APIRouter
from govoplan_campaign.backend.router import router 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( manifest = ModuleManifest(
id="campaigns", id="campaigns",
name="Campaigns", name="Campaigns",
version="0.1.4", version="0.1.5",
dependencies=("access",), dependencies=("access",),
optional_dependencies=("files", "mail"), optional_dependencies=("files", "mail"),
permissions=PERMISSIONS, permissions=PERMISSIONS,
route_factory=_campaigns_router, route_factory=_campaigns_router,
role_templates=ROLE_TEMPLATES, role_templates=ROLE_TEMPLATES,
tenant_summary_providers=(_tenant_summary,),
nav_items=( nav_items=(
NavItem(path="/campaigns", label="Campaigns", icon="campaign", required_any=("campaigns:campaign:read",), order=20), NavItem(path="/campaigns", label="Campaigns", icon="campaign", required_any=("campaigns:campaign:read",), order=20),
NavItem( NavItem(
@@ -159,7 +179,59 @@ manifest = ModuleManifest(
module_id="campaigns", module_id="campaigns",
metadata=Base.metadata, metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"), 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),
},
) )

View 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),
}

View File

@@ -41,9 +41,11 @@ from govoplan_campaign.backend.schemas import (
ReportEmailRequest, ReportEmailRequest,
ReportEmailResponse, 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_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_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.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv
from govoplan_campaign.backend.reports.emailing import CampaignReportEmailError, send_campaign_report_email 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"]) 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: def _get_campaign_for_tenant(session: Session, campaign_id: str, tenant_id: str) -> Campaign:
campaign = session.get(Campaign, campaign_id) campaign = session.get(Campaign, campaign_id)
if not campaign or campaign.tenant_id != tenant_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]: def _principal_group_ids(session: Session, principal: ApiPrincipal) -> set[str]:
return { del session
row[0] return {group.id for group in _access_directory().groups_for_user(principal.user.id, tenant_id=principal.tenant_id)}
for row in session.query(UserGroupMembership.group_id)
.filter(UserGroupMembership.tenant_id == principal.tenant_id, UserGroupMembership.user_id == principal.user.id)
.all()
}
@@ -1530,21 +1538,12 @@ def list_campaign_share_targets(
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")), principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")),
): ):
_get_campaign_for_principal(session, campaign_id, principal, write=True) _get_campaign_for_principal(session, campaign_id, principal, write=True)
users = ( directory = _access_directory()
session.query(User) users = [user for user in directory.users_for_tenant(principal.tenant_id) if user.status == "active"]
.filter(User.tenant_id == principal.tenant_id, User.is_active.is_(True)) groups = [group for group in directory.groups_for_tenant(principal.tenant_id) if group.status == "active"]
.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()
)
return CampaignShareTargetsResponse( return CampaignShareTargetsResponse(
users=[CampaignShareTargetItem(id=item.id, name=item.display_name or item.email, secondary=item.email) for item in users], 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) campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True)
if payload.owner_user_id and payload.owner_group_id: 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") 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: 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: if owner is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Owner user not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Owner user not found")
if payload.owner_group_id: 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: if group is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Owner group not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Owner group not found")
campaign.owner_user_id = payload.owner_user_id campaign.owner_user_id = payload.owner_user_id
@@ -1597,10 +1601,15 @@ def upsert_campaign_share(
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")), principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")),
): ):
campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True) campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True)
directory = _access_directory()
if payload.target_type == "user": 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: 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: if target is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Share target not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Share target not found")
share = ( share = (

View 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()

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/campaign-webui", "name": "@govoplan/campaign-webui",
"version": "0.1.4", "version": "0.1.5",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -17,7 +17,7 @@
"read-excel-file": "^9.2.0" "read-excel-file": "^9.2.0"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.4", "@govoplan/core-webui": "^0.1.5",
"lucide-react": "^0.555.0", "lucide-react": "^0.555.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",