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.
|
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:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
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
|
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",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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,
|
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 = (
|
||||||
|
|||||||
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",
|
"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",
|
||||||
|
|||||||
Reference in New Issue
Block a user