From 533c4c8fe3acb06bb0754e04f1daa316d9085cad Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 7 Jul 2026 15:49:06 +0200 Subject: [PATCH] Release v0.1.5 --- .gitea/ISSUE_TEMPLATE/bug_report.md | 35 ++++ .gitea/ISSUE_TEMPLATE/config.yaml | 1 + .gitea/ISSUE_TEMPLATE/docs_workflow.md | 27 +++ .gitea/ISSUE_TEMPLATE/feature_request.md | 32 +++ .gitea/ISSUE_TEMPLATE/task.md | 28 +++ .gitea/ISSUE_TEMPLATE/tech_debt.md | 25 +++ .gitea/PULL_REQUEST_TEMPLATE.md | 15 ++ README.md | 13 ++ package.json | 4 +- pyproject.toml | 4 +- src/govoplan_campaign/backend/capabilities.py | 177 ++++++++++++++++ src/govoplan_campaign/backend/db/models.py | 15 +- .../backend/dev/mock_campaign.py | 15 +- src/govoplan_campaign/backend/integrations.py | 11 +- src/govoplan_campaign/backend/manifest.py | 76 ++++++- src/govoplan_campaign/backend/retention.py | 191 ++++++++++++++++++ src/govoplan_campaign/backend/router.py | 59 +++--- .../backend/schema_router.py | 31 +++ webui/package.json | 4 +- 19 files changed, 705 insertions(+), 58 deletions(-) create mode 100644 .gitea/ISSUE_TEMPLATE/bug_report.md create mode 100644 .gitea/ISSUE_TEMPLATE/config.yaml create mode 100644 .gitea/ISSUE_TEMPLATE/docs_workflow.md create mode 100644 .gitea/ISSUE_TEMPLATE/feature_request.md create mode 100644 .gitea/ISSUE_TEMPLATE/task.md create mode 100644 .gitea/ISSUE_TEMPLATE/tech_debt.md create mode 100644 .gitea/PULL_REQUEST_TEMPLATE.md create mode 100644 src/govoplan_campaign/backend/capabilities.py create mode 100644 src/govoplan_campaign/backend/retention.py create mode 100644 src/govoplan_campaign/backend/schema_router.py diff --git a/.gitea/ISSUE_TEMPLATE/bug_report.md b/.gitea/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..47a9c1a --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/bug_report.md @@ -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: diff --git a/.gitea/ISSUE_TEMPLATE/config.yaml b/.gitea/ISSUE_TEMPLATE/config.yaml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/config.yaml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.gitea/ISSUE_TEMPLATE/docs_workflow.md b/.gitea/ISSUE_TEMPLATE/docs_workflow.md new file mode 100644 index 0000000..e982962 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/docs_workflow.md @@ -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? diff --git a/.gitea/ISSUE_TEMPLATE/feature_request.md b/.gitea/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..304d3d7 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/feature_request.md @@ -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: diff --git a/.gitea/ISSUE_TEMPLATE/task.md b/.gitea/ISSUE_TEMPLATE/task.md new file mode 100644 index 0000000..9c3f303 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/task.md @@ -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: diff --git a/.gitea/ISSUE_TEMPLATE/tech_debt.md b/.gitea/ISSUE_TEMPLATE/tech_debt.md new file mode 100644 index 0000000..6f64e0a --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/tech_debt.md @@ -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: diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..1984736 --- /dev/null +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ +## Issue + +Closes # + +## Summary + +- + +## Verification + +- + +## Notes + +Follow-up issues: diff --git a/README.md b/README.md index 4a41cd8..0674c49 100644 --- a/README.md +++ b/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: diff --git a/package.json b/package.json index 7eb8c9b..aaab1ab 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pyproject.toml b/pyproject.toml index ed043b0..9c6ab31 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/src/govoplan_campaign/backend/capabilities.py b/src/govoplan_campaign/backend/capabilities.py new file mode 100644 index 0000000..a626dfc --- /dev/null +++ b/src/govoplan_campaign/backend/capabilities.py @@ -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() diff --git a/src/govoplan_campaign/backend/db/models.py b/src/govoplan_campaign/backend/db/models.py index 5cf38c0..cc097fe 100644 --- a/src/govoplan_campaign/backend/db/models.py +++ b/src/govoplan_campaign/backend/db/models.py @@ -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", ] diff --git a/src/govoplan_campaign/backend/dev/mock_campaign.py b/src/govoplan_campaign/backend/dev/mock_campaign.py index 3f56165..b71bc7a 100644 --- a/src/govoplan_campaign/backend/dev/mock_campaign.py +++ b/src/govoplan_campaign/backend/dev/mock_campaign.py @@ -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") diff --git a/src/govoplan_campaign/backend/integrations.py b/src/govoplan_campaign/backend/integrations.py index 16af67e..c74770c 100644 --- a/src/govoplan_campaign/backend/integrations.py +++ b/src/govoplan_campaign/backend/integrations.py @@ -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)) diff --git a/src/govoplan_campaign/backend/manifest.py b/src/govoplan_campaign/backend/manifest.py index 386b6b6..241dd23 100644 --- a/src/govoplan_campaign/backend/manifest.py +++ b/src/govoplan_campaign/backend/manifest.py @@ -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), + }, ) diff --git a/src/govoplan_campaign/backend/retention.py b/src/govoplan_campaign/backend/retention.py new file mode 100644 index 0000000..7c19536 --- /dev/null +++ b/src/govoplan_campaign/backend/retention.py @@ -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), + } diff --git a/src/govoplan_campaign/backend/router.py b/src/govoplan_campaign/backend/router.py index 5f198e7..8a64022 100644 --- a/src/govoplan_campaign/backend/router.py +++ b/src/govoplan_campaign/backend/router.py @@ -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 = ( diff --git a/src/govoplan_campaign/backend/schema_router.py b/src/govoplan_campaign/backend/schema_router.py new file mode 100644 index 0000000..9620c6f --- /dev/null +++ b/src/govoplan_campaign/backend/schema_router.py @@ -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() diff --git a/webui/package.json b/webui/package.json index 2729aca..10b980a 100644 --- a/webui/package.json +++ b/webui/package.json @@ -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",