chore: sync GovOPlaN module split state
This commit is contained in:
350
src/govoplan_campaign/backend/change_tracking.py
Normal file
350
src/govoplan_campaign/backend/change_tracking.py
Normal file
@@ -0,0 +1,350 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import event, inspect
|
||||
from sqlalchemy.orm import Session as OrmSession
|
||||
|
||||
from govoplan_core.core.change_sequence import record_change
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignIssue,
|
||||
CampaignJob,
|
||||
CampaignShare,
|
||||
CampaignVersion,
|
||||
ImapAppendAttempt,
|
||||
SendAttempt,
|
||||
new_uuid,
|
||||
)
|
||||
|
||||
CAMPAIGNS_MODULE_ID = "campaigns"
|
||||
CAMPAIGNS_COLLECTION = "campaigns.campaigns"
|
||||
CAMPAIGN_VERSIONS_COLLECTION = "campaigns.versions"
|
||||
CAMPAIGN_JOBS_COLLECTION = "campaigns.jobs"
|
||||
CAMPAIGN_ISSUES_COLLECTION = "campaigns.issues"
|
||||
CAMPAIGN_ATTEMPTS_COLLECTION = "campaigns.attempts"
|
||||
|
||||
_REGISTERED = False
|
||||
|
||||
|
||||
def register_campaign_change_tracking() -> None:
|
||||
global _REGISTERED
|
||||
if _REGISTERED:
|
||||
return
|
||||
event.listen(OrmSession, "before_flush", _record_campaign_changes)
|
||||
_REGISTERED = True
|
||||
|
||||
|
||||
def _record_campaign_changes(session: OrmSession, _flush_context: object, _instances: object) -> None:
|
||||
for obj in tuple(session.new) + tuple(session.dirty) + tuple(session.deleted):
|
||||
if isinstance(obj, Campaign):
|
||||
_record_campaign_change(session, obj)
|
||||
elif isinstance(obj, CampaignShare):
|
||||
_record_share_visibility_change(session, obj)
|
||||
elif isinstance(obj, CampaignVersion):
|
||||
_record_version_change(session, obj)
|
||||
elif isinstance(obj, CampaignJob):
|
||||
_record_job_change(session, obj)
|
||||
elif isinstance(obj, CampaignIssue):
|
||||
_record_issue_change(session, obj)
|
||||
elif isinstance(obj, (SendAttempt, ImapAppendAttempt)):
|
||||
_record_attempt_change(session, obj)
|
||||
|
||||
|
||||
def _record_campaign_change(session: OrmSession, campaign: Campaign) -> None:
|
||||
operation = _operation_for_campaign(
|
||||
campaign,
|
||||
changed_attrs=(
|
||||
"external_id",
|
||||
"name",
|
||||
"description",
|
||||
"status",
|
||||
"current_version_id",
|
||||
"owner_user_id",
|
||||
"owner_group_id",
|
||||
"settings",
|
||||
"mail_profile_policy",
|
||||
),
|
||||
)
|
||||
if operation is None:
|
||||
return
|
||||
campaign_id = _ensure_id(campaign)
|
||||
record_change(
|
||||
session,
|
||||
module_id=CAMPAIGNS_MODULE_ID,
|
||||
collection=CAMPAIGNS_COLLECTION,
|
||||
resource_type="campaign",
|
||||
resource_id=campaign_id,
|
||||
operation=operation,
|
||||
tenant_id=campaign.tenant_id,
|
||||
actor_type="user" if campaign.created_by_user_id else None,
|
||||
actor_id=campaign.created_by_user_id,
|
||||
payload=_campaign_payload(campaign),
|
||||
)
|
||||
|
||||
|
||||
def _record_share_visibility_change(session: OrmSession, share: CampaignShare) -> None:
|
||||
state = inspect(share)
|
||||
if not share.campaign_id:
|
||||
return
|
||||
if not state.deleted and not state.pending and not _has_attr_changes(
|
||||
state,
|
||||
("campaign_id", "target_type", "target_id", "permission", "revoked_at"),
|
||||
):
|
||||
return
|
||||
_ensure_id(share)
|
||||
operation = "deleted" if state.deleted or share.revoked_at is not None else "updated"
|
||||
campaign = session.get(Campaign, share.campaign_id)
|
||||
payload = _campaign_payload(campaign) if campaign is not None else {"campaign_id": share.campaign_id}
|
||||
payload.update({
|
||||
"share_id": share.id,
|
||||
"share_target_type": share.target_type,
|
||||
"share_target_id": share.target_id,
|
||||
"share_permission": share.permission,
|
||||
"share_revoked_at": _isoformat(share.revoked_at),
|
||||
})
|
||||
record_change(
|
||||
session,
|
||||
module_id=CAMPAIGNS_MODULE_ID,
|
||||
collection=CAMPAIGNS_COLLECTION,
|
||||
resource_type="campaign",
|
||||
resource_id=share.campaign_id,
|
||||
operation=operation,
|
||||
tenant_id=share.tenant_id,
|
||||
actor_type="user" if share.created_by_user_id else None,
|
||||
actor_id=share.created_by_user_id,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
def _record_version_change(session: OrmSession, version: CampaignVersion) -> None:
|
||||
operation = _operation_for_object(
|
||||
version,
|
||||
changed_attrs=(
|
||||
"raw_json",
|
||||
"schema_version",
|
||||
"source_filename",
|
||||
"source_base_path",
|
||||
"workflow_state",
|
||||
"current_flow",
|
||||
"current_step",
|
||||
"is_complete",
|
||||
"editor_state",
|
||||
"autosaved_at",
|
||||
"published_at",
|
||||
"locked_at",
|
||||
"locked_by_user_id",
|
||||
"user_lock_state",
|
||||
"user_locked_at",
|
||||
"user_locked_by_user_id",
|
||||
"validation_summary",
|
||||
"build_summary",
|
||||
"execution_snapshot_hash",
|
||||
"execution_snapshot_at",
|
||||
),
|
||||
)
|
||||
if operation is None:
|
||||
return
|
||||
version_id = _ensure_id(version)
|
||||
campaign = session.get(Campaign, version.campaign_id) if version.campaign_id else None
|
||||
record_change(
|
||||
session,
|
||||
module_id=CAMPAIGNS_MODULE_ID,
|
||||
collection=CAMPAIGN_VERSIONS_COLLECTION,
|
||||
resource_type="campaign_version",
|
||||
resource_id=version_id,
|
||||
operation=operation,
|
||||
tenant_id=campaign.tenant_id if campaign is not None else None,
|
||||
payload={
|
||||
**(_campaign_payload(campaign) if campaign is not None else {"campaign_id": version.campaign_id}),
|
||||
"version_id": version_id,
|
||||
"version_number": version.version_number,
|
||||
"workflow_state": version.workflow_state,
|
||||
"current_flow": version.current_flow,
|
||||
"current_step": version.current_step,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _record_job_change(session: OrmSession, job: CampaignJob) -> None:
|
||||
operation = _operation_for_object(
|
||||
job,
|
||||
changed_attrs=(
|
||||
"build_status",
|
||||
"validation_status",
|
||||
"queue_status",
|
||||
"send_status",
|
||||
"imap_status",
|
||||
"attempt_count",
|
||||
"last_error",
|
||||
"queued_at",
|
||||
"claimed_at",
|
||||
"smtp_started_at",
|
||||
"outcome_unknown_at",
|
||||
"sent_at",
|
||||
"resolved_recipients",
|
||||
"resolved_attachments",
|
||||
"issues_snapshot",
|
||||
),
|
||||
)
|
||||
if operation is None:
|
||||
return
|
||||
job_id = _ensure_id(job)
|
||||
campaign = session.get(Campaign, job.campaign_id) if job.campaign_id else None
|
||||
record_change(
|
||||
session,
|
||||
module_id=CAMPAIGNS_MODULE_ID,
|
||||
collection=CAMPAIGN_JOBS_COLLECTION,
|
||||
resource_type="campaign_job",
|
||||
resource_id=job_id,
|
||||
operation=operation,
|
||||
tenant_id=job.tenant_id,
|
||||
payload={
|
||||
**(_campaign_payload(campaign) if campaign is not None else {"campaign_id": job.campaign_id}),
|
||||
"job_id": job_id,
|
||||
"version_id": job.campaign_version_id,
|
||||
"entry_index": job.entry_index,
|
||||
"entry_id": job.entry_id,
|
||||
"build_status": job.build_status,
|
||||
"validation_status": job.validation_status,
|
||||
"queue_status": job.queue_status,
|
||||
"send_status": job.send_status,
|
||||
"imap_status": job.imap_status,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _record_issue_change(session: OrmSession, issue: CampaignIssue) -> None:
|
||||
operation = _operation_for_object(issue, changed_attrs=("severity", "code", "message", "source", "behavior"))
|
||||
if operation is None:
|
||||
return
|
||||
issue_id = _ensure_id(issue)
|
||||
campaign = session.get(Campaign, issue.campaign_id) if issue.campaign_id else None
|
||||
record_change(
|
||||
session,
|
||||
module_id=CAMPAIGNS_MODULE_ID,
|
||||
collection=CAMPAIGN_ISSUES_COLLECTION,
|
||||
resource_type="campaign_issue",
|
||||
resource_id=issue_id,
|
||||
operation=operation,
|
||||
tenant_id=issue.tenant_id,
|
||||
payload={
|
||||
**(_campaign_payload(campaign) if campaign is not None else {"campaign_id": issue.campaign_id}),
|
||||
"issue_id": issue_id,
|
||||
"version_id": issue.campaign_version_id,
|
||||
"job_id": issue.job_id,
|
||||
"severity": issue.severity,
|
||||
"code": issue.code,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _record_attempt_change(session: OrmSession, attempt: SendAttempt | ImapAppendAttempt) -> None:
|
||||
operation = _operation_for_object(
|
||||
attempt,
|
||||
changed_attrs=("status", "claim_token", "smtp_status_code", "smtp_response", "error_type", "error_message", "folder"),
|
||||
)
|
||||
if operation is None:
|
||||
return
|
||||
attempt_id = _ensure_id(attempt)
|
||||
job = session.get(CampaignJob, attempt.job_id) if attempt.job_id else None
|
||||
if job is None:
|
||||
return
|
||||
campaign = session.get(Campaign, job.campaign_id) if job.campaign_id else None
|
||||
record_change(
|
||||
session,
|
||||
module_id=CAMPAIGNS_MODULE_ID,
|
||||
collection=CAMPAIGN_ATTEMPTS_COLLECTION,
|
||||
resource_type="campaign_attempt",
|
||||
resource_id=attempt_id,
|
||||
operation=operation,
|
||||
tenant_id=job.tenant_id,
|
||||
payload={
|
||||
**(_campaign_payload(campaign) if campaign is not None else {"campaign_id": job.campaign_id}),
|
||||
"attempt_id": attempt_id,
|
||||
"attempt_kind": "imap" if isinstance(attempt, ImapAppendAttempt) else "smtp",
|
||||
"job_id": job.id,
|
||||
"version_id": job.campaign_version_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _operation_for_campaign(obj: Campaign, *, changed_attrs: tuple[str, ...]) -> str | None:
|
||||
state = inspect(obj)
|
||||
if state.pending:
|
||||
return "created"
|
||||
if state.deleted:
|
||||
return "deleted"
|
||||
if not _has_attr_changes(state, changed_attrs):
|
||||
return None
|
||||
status_history = state.attrs.status.history
|
||||
if status_history.has_changes() and any(value == "deleted" for value in status_history.added):
|
||||
return "deleted"
|
||||
return "updated"
|
||||
|
||||
|
||||
def _operation_for_object(obj: object, *, changed_attrs: tuple[str, ...]) -> str | None:
|
||||
state = inspect(obj)
|
||||
if state.pending:
|
||||
return "created"
|
||||
if state.deleted:
|
||||
return "deleted"
|
||||
if not _has_attr_changes(state, changed_attrs):
|
||||
return None
|
||||
return "updated"
|
||||
|
||||
|
||||
def _has_attr_changes(state: Any, attrs: tuple[str, ...]) -> bool:
|
||||
return any(name in state.attrs and state.attrs[name].history.has_changes() for name in attrs)
|
||||
|
||||
|
||||
def _previous_value(obj: object, attr_name: str) -> str | None:
|
||||
state = inspect(obj)
|
||||
if attr_name not in state.attrs:
|
||||
return None
|
||||
history = state.attrs[attr_name].history
|
||||
if not history.has_changes() or not history.deleted:
|
||||
return None
|
||||
value = history.deleted[0]
|
||||
return str(value) if value is not None else None
|
||||
|
||||
|
||||
def _ensure_id(obj: object) -> str:
|
||||
resource_id = getattr(obj, "id", None)
|
||||
if resource_id:
|
||||
return str(resource_id)
|
||||
resource_id = new_uuid()
|
||||
setattr(obj, "id", resource_id)
|
||||
return resource_id
|
||||
|
||||
|
||||
def _campaign_payload(campaign: Campaign | None) -> dict[str, Any]:
|
||||
if campaign is None:
|
||||
return {}
|
||||
return {
|
||||
"campaign_id": campaign.id,
|
||||
"owner_user_id": campaign.owner_user_id,
|
||||
"owner_group_id": campaign.owner_group_id,
|
||||
"previous_owner_user_id": _previous_value(campaign, "owner_user_id"),
|
||||
"previous_owner_group_id": _previous_value(campaign, "owner_group_id"),
|
||||
"status": campaign.status,
|
||||
"previous_status": _previous_value(campaign, "status"),
|
||||
"current_version_id": campaign.current_version_id,
|
||||
"previous_current_version_id": _previous_value(campaign, "current_version_id"),
|
||||
}
|
||||
|
||||
|
||||
def _isoformat(value: datetime | None) -> str | None:
|
||||
return value.isoformat() if value else None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAMPAIGNS_COLLECTION",
|
||||
"CAMPAIGNS_MODULE_ID",
|
||||
"CAMPAIGN_ATTEMPTS_COLLECTION",
|
||||
"CAMPAIGN_ISSUES_COLLECTION",
|
||||
"CAMPAIGN_JOBS_COLLECTION",
|
||||
"CAMPAIGN_VERSIONS_COLLECTION",
|
||||
"register_campaign_change_tracking",
|
||||
]
|
||||
@@ -108,10 +108,10 @@ class Campaign(Base, TimestampMixin):
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "external_id", name="uq_campaigns_tenant_external_id"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
owner_group_id: Mapped[str | None] = mapped_column(ForeignKey("groups.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
owner_group_id: Mapped[str | None] = mapped_column(ForeignKey("access_groups.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
external_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
@@ -130,12 +130,12 @@ class CampaignShare(Base, TimestampMixin):
|
||||
__table_args__ = (UniqueConstraint("campaign_id", "target_type", "target_id", name="uq_campaign_share_target"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
target_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||
target_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
permission: Mapped[str] = mapped_column(String(20), default="read", nullable=False)
|
||||
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
|
||||
|
||||
@@ -148,8 +148,8 @@ class RecipientImportMappingProfile(Base, TimestampMixin):
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
owner_user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
owner_user_id: Mapped[str] = mapped_column(ForeignKey("access_users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
column_count: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
headers: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||
@@ -199,7 +199,7 @@ class CampaignVersion(Base, TimestampMixin):
|
||||
autosaved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
locked_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
locked_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
# Explicit user-requested lock. This is deliberately separate from
|
||||
# locked_at, which represents the reversible validation lock used by the
|
||||
@@ -207,7 +207,7 @@ class CampaignVersion(Base, TimestampMixin):
|
||||
# RBAC permission for unlocking; permanent locks never unlock in place.
|
||||
user_lock_state: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
user_locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
user_locked_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
user_locked_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
validation_summary: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
build_summary: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
@@ -223,7 +223,7 @@ class CampaignJob(Base, TimestampMixin):
|
||||
__table_args__ = (UniqueConstraint("campaign_version_id", "entry_index", name="uq_campaign_jobs_version_entry"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
campaign_version_id: Mapped[str] = mapped_column(ForeignKey("campaign_versions.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
entry_index: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
@@ -263,7 +263,7 @@ class CampaignIssue(Base, TimestampMixin):
|
||||
__tablename__ = "campaign_issues"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
campaign_version_id: Mapped[str | None] = mapped_column(ForeignKey("campaign_versions.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||
job_id: Mapped[str | None] = mapped_column(ForeignKey("campaign_jobs.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||
@@ -279,7 +279,7 @@ class AttachmentBlob(Base, TimestampMixin):
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "sha256", name="uq_attachment_blobs_tenant_sha256"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
mime_type: Mapped[str | None] = mapped_column(String(255))
|
||||
@@ -291,8 +291,8 @@ class AttachmentInstance(Base, TimestampMixin):
|
||||
__tablename__ = "attachment_instances"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
campaign_id: Mapped[str | None] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||
blob_id: Mapped[str] = mapped_column(ForeignKey("attachment_blobs.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
logical_name: Mapped[str | None] = mapped_column(String(500))
|
||||
|
||||
@@ -12,8 +12,11 @@ from govoplan_core.core.campaigns import (
|
||||
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.change_tracking import register_campaign_change_tracking
|
||||
from govoplan_campaign.backend.db import models as campaign_models # noqa: F401 - populate Campaign ORM metadata
|
||||
|
||||
register_campaign_change_tracking()
|
||||
|
||||
|
||||
def _permission(scope: str, label: str, description: str, category: str) -> PermissionDefinition:
|
||||
module_id, resource, action = scope.split(":", 2)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""recipient import mapping profiles
|
||||
|
||||
Revision ID: 2c3d4e5f7081
|
||||
Revises: 1b2c3d4e5f70
|
||||
Revises: 2e3f4a5b6c7d
|
||||
Create Date: 2026-06-26 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -11,7 +11,7 @@ import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "2c3d4e5f7081"
|
||||
down_revision = "1b2c3d4e5f70"
|
||||
down_revision = "2e3f4a5b6c7d"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
@@ -38,8 +38,8 @@ def upgrade() -> None:
|
||||
sa.Column("mappings", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["owner_user_id"], ["users.id"], name=op.f("fk_campaign_recipient_import_mapping_profiles_owner_user_id_users"), ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name=op.f("fk_campaign_recipient_import_mapping_profiles_tenant_id_tenants"), ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["owner_user_id"], ["access_users.id"], name=op.f("fk_campaign_recipient_import_mapping_profiles_owner_user_id_users"), ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], name=op.f("fk_campaign_recipient_import_mapping_profiles_tenant_id_tenants"), ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_campaign_recipient_import_mapping_profiles")),
|
||||
)
|
||||
op.create_index(op.f("ix_campaign_recipient_import_mapping_profiles_tenant_id"), "campaign_recipient_import_mapping_profiles", ["tenant_id"], unique=False)
|
||||
|
||||
@@ -256,6 +256,99 @@ def _job_row(job: CampaignJob) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _address_summary(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
email = str(value.get("email") or "").strip()
|
||||
name = str(value.get("name") or "").strip()
|
||||
if name and email:
|
||||
return f"{name} <{email}>"
|
||||
return email or name
|
||||
if isinstance(value, list):
|
||||
return "; ".join(item for item in (_address_summary(item) for item in value) if item)
|
||||
return str(value)
|
||||
|
||||
|
||||
def _latest_by_job_id(attempts: list[Any]) -> dict[str, Any]:
|
||||
latest: dict[str, Any] = {}
|
||||
for attempt in attempts:
|
||||
current = latest.get(attempt.job_id)
|
||||
if current is None or (attempt.attempt_number or 0) >= (current.attempt_number or 0):
|
||||
latest[attempt.job_id] = attempt
|
||||
return latest
|
||||
|
||||
|
||||
def _attachment_names(attachments: list[dict[str, Any]] | None) -> str:
|
||||
names: list[str] = []
|
||||
for attachment in attachments or []:
|
||||
if not isinstance(attachment, dict):
|
||||
continue
|
||||
for key in ("message_filenames", "zip_entry_names"):
|
||||
values = attachment.get(key)
|
||||
if isinstance(values, list):
|
||||
names.extend(str(value) for value in values if value)
|
||||
zip_filename = attachment.get("zip_filename")
|
||||
if zip_filename:
|
||||
names.append(str(zip_filename))
|
||||
matches = attachment.get("matches")
|
||||
if isinstance(matches, list):
|
||||
for match in matches:
|
||||
if isinstance(match, dict):
|
||||
filename = match.get("filename") or match.get("name") or match.get("display_name")
|
||||
if filename:
|
||||
names.append(str(filename))
|
||||
elif match:
|
||||
names.append(str(match).rsplit("/", 1)[-1])
|
||||
seen: set[str] = set()
|
||||
deduplicated = []
|
||||
for name in names:
|
||||
if name and name not in seen:
|
||||
seen.add(name)
|
||||
deduplicated.append(name)
|
||||
return "; ".join(deduplicated)
|
||||
|
||||
|
||||
def _job_evidence_row(
|
||||
job: CampaignJob,
|
||||
*,
|
||||
latest_smtp: SendAttempt | None = None,
|
||||
latest_imap: ImapAppendAttempt | None = None,
|
||||
) -> dict[str, Any]:
|
||||
row = _job_row(job)
|
||||
recipients = job.resolved_recipients or {}
|
||||
row.update({
|
||||
"campaign_id": job.campaign_id,
|
||||
"campaign_version_id": job.campaign_version_id,
|
||||
"message_id_header": job.message_id_header,
|
||||
"eml_storage_key": job.eml_storage_key,
|
||||
"eml_local_path": job.eml_local_path,
|
||||
"from": _address_summary(recipients.get("from")),
|
||||
"to": _address_summary(recipients.get("to")),
|
||||
"cc": _address_summary(recipients.get("cc")),
|
||||
"bcc": _address_summary(recipients.get("bcc")),
|
||||
"reply_to": _address_summary(recipients.get("reply_to")),
|
||||
"attachment_names": _attachment_names(job.resolved_attachments),
|
||||
"latest_smtp_attempt_number": latest_smtp.attempt_number if latest_smtp else None,
|
||||
"latest_smtp_status": latest_smtp.status if latest_smtp else None,
|
||||
"latest_smtp_status_code": latest_smtp.smtp_status_code if latest_smtp else None,
|
||||
"latest_smtp_response": latest_smtp.smtp_response if latest_smtp else None,
|
||||
"latest_smtp_error_type": latest_smtp.error_type if latest_smtp else None,
|
||||
"latest_smtp_error_message": latest_smtp.error_message if latest_smtp else None,
|
||||
"latest_smtp_started_at": latest_smtp.started_at.isoformat() if latest_smtp and latest_smtp.started_at else None,
|
||||
"latest_smtp_finished_at": latest_smtp.finished_at.isoformat() if latest_smtp and latest_smtp.finished_at else None,
|
||||
"latest_imap_attempt_number": latest_imap.attempt_number if latest_imap else None,
|
||||
"latest_imap_status": latest_imap.status if latest_imap else None,
|
||||
"latest_imap_folder": latest_imap.folder if latest_imap else None,
|
||||
"latest_imap_error_message": latest_imap.error_message if latest_imap else None,
|
||||
"latest_imap_created_at": latest_imap.created_at.isoformat() if latest_imap and latest_imap.created_at else None,
|
||||
"latest_imap_updated_at": latest_imap.updated_at.isoformat() if latest_imap and latest_imap.updated_at else None,
|
||||
})
|
||||
return row
|
||||
|
||||
|
||||
def generate_campaign_report(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -401,13 +494,47 @@ def generate_jobs_csv(
|
||||
.order_by(CampaignJob.entry_index.asc())
|
||||
.all()
|
||||
)
|
||||
rows = [_job_row(job) for job in jobs]
|
||||
job_ids = [job.id for job in jobs]
|
||||
smtp_attempts = (
|
||||
session.query(SendAttempt)
|
||||
.filter(SendAttempt.job_id.in_(job_ids))
|
||||
.order_by(SendAttempt.job_id.asc(), SendAttempt.attempt_number.asc())
|
||||
.all()
|
||||
if job_ids
|
||||
else []
|
||||
)
|
||||
imap_attempts = (
|
||||
session.query(ImapAppendAttempt)
|
||||
.filter(ImapAppendAttempt.job_id.in_(job_ids))
|
||||
.order_by(ImapAppendAttempt.job_id.asc(), ImapAppendAttempt.attempt_number.asc())
|
||||
.all()
|
||||
if job_ids
|
||||
else []
|
||||
)
|
||||
latest_smtp = _latest_by_job_id(smtp_attempts)
|
||||
latest_imap = _latest_by_job_id(imap_attempts)
|
||||
rows = [
|
||||
_job_evidence_row(
|
||||
job,
|
||||
latest_smtp=latest_smtp.get(job.id),
|
||||
latest_imap=latest_imap.get(job.id),
|
||||
)
|
||||
for job in jobs
|
||||
]
|
||||
fieldnames = [
|
||||
"job_id",
|
||||
"campaign_id",
|
||||
"campaign_version_id",
|
||||
"entry_index",
|
||||
"entry_id",
|
||||
"recipient_email",
|
||||
"from",
|
||||
"to",
|
||||
"cc",
|
||||
"bcc",
|
||||
"reply_to",
|
||||
"subject",
|
||||
"message_id_header",
|
||||
"build_status",
|
||||
"validation_status",
|
||||
"queue_status",
|
||||
@@ -422,9 +549,26 @@ def generate_jobs_csv(
|
||||
"last_error",
|
||||
"eml_size_bytes",
|
||||
"eml_sha256",
|
||||
"eml_storage_key",
|
||||
"eml_local_path",
|
||||
"issues_count",
|
||||
"attachment_config_count",
|
||||
"matched_file_count",
|
||||
"attachment_names",
|
||||
"latest_smtp_attempt_number",
|
||||
"latest_smtp_status",
|
||||
"latest_smtp_status_code",
|
||||
"latest_smtp_response",
|
||||
"latest_smtp_error_type",
|
||||
"latest_smtp_error_message",
|
||||
"latest_smtp_started_at",
|
||||
"latest_smtp_finished_at",
|
||||
"latest_imap_attempt_number",
|
||||
"latest_imap_status",
|
||||
"latest_imap_folder",
|
||||
"latest_imap_error_message",
|
||||
"latest_imap_created_at",
|
||||
"latest_imap_updated_at",
|
||||
]
|
||||
buffer = io.StringIO()
|
||||
writer = csv.DictWriter(buffer, fieldnames=fieldnames)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from govoplan_access.backend.auth.dependencies import ApiPrincipal, require_any_scope
|
||||
from govoplan_access.auth import ApiPrincipal, require_any_scope
|
||||
|
||||
from govoplan_campaign.backend.campaign.loader import load_campaign_schema, load_campaign_schema_ui
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
||||
from govoplan_core.mail.config import ImapConfig, SmtpConfig
|
||||
|
||||
|
||||
@@ -145,6 +146,21 @@ class CampaignWorkspaceResponse(BaseModel):
|
||||
selected_version_id: str | None = None
|
||||
|
||||
|
||||
class CampaignDeltaResponse(BaseModel):
|
||||
campaigns: list[CampaignResponse] = Field(default_factory=list)
|
||||
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
|
||||
watermark: str | None = None
|
||||
has_more: bool = False
|
||||
full: bool = False
|
||||
|
||||
|
||||
class CampaignWorkspaceDeltaResponse(CampaignWorkspaceResponse):
|
||||
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
|
||||
watermark: str | None = None
|
||||
has_more: bool = False
|
||||
full: bool = False
|
||||
|
||||
|
||||
class CampaignShareItem(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -257,11 +273,20 @@ class CampaignJobsResponse(BaseModel):
|
||||
total: int = 0
|
||||
total_unfiltered: int = 0
|
||||
pages: int = 0
|
||||
cursor: str | None = None
|
||||
next_cursor: str | None = None
|
||||
counts: dict[str, dict[str, int]] = Field(default_factory=dict)
|
||||
filtered_counts: dict[str, dict[str, int]] = Field(default_factory=dict)
|
||||
review: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CampaignJobsDeltaResponse(CampaignJobsResponse):
|
||||
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
|
||||
watermark: str | None = None
|
||||
has_more: bool = False
|
||||
full: bool = False
|
||||
|
||||
|
||||
class CampaignJobDetailResponse(BaseModel):
|
||||
job: dict[str, Any]
|
||||
attempts: dict[str, list[dict[str, Any]]] = Field(default_factory=dict)
|
||||
|
||||
Reference in New Issue
Block a user