Release v0.1.4

This commit is contained in:
2026-07-02 14:59:52 +02:00
parent ab2343aa88
commit fb6fb67d45
27 changed files with 1437 additions and 1785 deletions

View File

@@ -489,6 +489,35 @@ class EntryConfig(StrictModel):
last_sent: str | None = None
class ImportColumnProvenance(StrictModel):
column_index: int = Field(ge=0)
header: str
kind: str
field_name: str | None = None
new_field_name: str | None = None
class ImportProvenance(StrictModel):
id: str
imported_at: str
mode: Literal["append", "replace"]
source_type: Literal["csv", "xlsx", "text"]
filename: str | None = None
sheet_name: str | None = None
encoding: str | None = None
delimiter: str | None = None
header_rows: int = Field(default=0, ge=0)
quoted: bool | None = None
value_separators: str | None = None
rows_total: int = Field(default=0, ge=0)
valid_rows: int = Field(default=0, ge=0)
invalid_rows: int = Field(default=0, ge=0)
imported_rows: int = Field(default=0, ge=0)
field_names_created: list[str] = Field(default_factory=list)
attachment_patterns: int = Field(default=0, ge=0)
mapping: list[ImportColumnProvenance] = Field(default_factory=list)
class SourceConfig(StrictModel):
type: SourceType
path: str
@@ -502,6 +531,7 @@ class EntriesConfig(StrictModel):
source: SourceConfig | None = None
mapping: dict[str, str] | None = None
defaults: EntryConfig | None = None
imports: list[ImportProvenance] = Field(default_factory=list)
@model_validator(mode="after")
def inline_or_external(self) -> "EntriesConfig":

View File

@@ -144,6 +144,33 @@ class CampaignShare(Base, TimestampMixin):
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
class RecipientImportMappingProfile(Base, TimestampMixin):
__tablename__ = "campaign_recipient_import_mapping_profiles"
__table_args__ = (
Index("ix_recipient_import_profiles_owner", "tenant_id", "owner_user_id"),
Index("ix_recipient_import_profiles_ordered_fp", "tenant_id", "owner_user_id", "ordered_header_fingerprint"),
Index("ix_recipient_import_profiles_unordered_fp", "tenant_id", "owner_user_id", "unordered_header_fingerprint"),
)
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)
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)
normalized_headers: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
ordered_header_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
unordered_header_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
delimiter: Mapped[str] = mapped_column(String(8), nullable=False)
header_rows: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
quoted: Mapped[bool] = mapped_column(Boolean, default=True, 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)
tenant: Mapped[Tenant] = relationship()
owner: Mapped[User] = relationship()
class CampaignVersion(Base, TimestampMixin):
__tablename__ = "campaign_versions"
__table_args__ = (UniqueConstraint("campaign_id", "version_number", name="uq_campaign_versions_campaign_number"),)

View File

@@ -109,7 +109,7 @@ def _campaigns_router(context: ModuleContext):
manifest = ModuleManifest(
id="campaigns",
name="Campaigns",
version="0.1.2",
version="0.1.4",
dependencies=("access",),
optional_dependencies=("files", "mail"),
permissions=PERMISSIONS,

View File

@@ -0,0 +1,65 @@
"""recipient import mapping profiles
Revision ID: 2c3d4e5f7081
Revises: 1b2c3d4e5f70
Create Date: 2026-06-26 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "2c3d4e5f7081"
down_revision = "1b2c3d4e5f70"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "campaign_recipient_import_mapping_profiles" in inspector.get_table_names():
return
op.create_table(
"campaign_recipient_import_mapping_profiles",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("owner_user_id", sa.String(length=36), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("column_count", sa.Integer(), nullable=False),
sa.Column("headers", sa.JSON(), nullable=False),
sa.Column("normalized_headers", sa.JSON(), nullable=False),
sa.Column("ordered_header_fingerprint", sa.String(length=64), nullable=False),
sa.Column("unordered_header_fingerprint", sa.String(length=64), nullable=False),
sa.Column("delimiter", sa.String(length=8), nullable=False),
sa.Column("header_rows", sa.Integer(), nullable=False),
sa.Column("quoted", sa.Boolean(), nullable=False),
sa.Column("value_separators", sa.String(length=50), nullable=False),
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.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)
op.create_index(op.f("ix_campaign_recipient_import_mapping_profiles_owner_user_id"), "campaign_recipient_import_mapping_profiles", ["owner_user_id"], unique=False)
op.create_index(op.f("ix_campaign_recipient_import_mapping_profiles_ordered_header_fingerprint"), "campaign_recipient_import_mapping_profiles", ["ordered_header_fingerprint"], unique=False)
op.create_index(op.f("ix_campaign_recipient_import_mapping_profiles_unordered_header_fingerprint"), "campaign_recipient_import_mapping_profiles", ["unordered_header_fingerprint"], unique=False)
op.create_index("ix_recipient_import_profiles_owner", "campaign_recipient_import_mapping_profiles", ["tenant_id", "owner_user_id"], unique=False)
op.create_index("ix_recipient_import_profiles_ordered_fp", "campaign_recipient_import_mapping_profiles", ["tenant_id", "owner_user_id", "ordered_header_fingerprint"], unique=False)
op.create_index("ix_recipient_import_profiles_unordered_fp", "campaign_recipient_import_mapping_profiles", ["tenant_id", "owner_user_id", "unordered_header_fingerprint"], unique=False)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "campaign_recipient_import_mapping_profiles" not in inspector.get_table_names():
return
op.drop_index("ix_recipient_import_profiles_unordered_fp", table_name="campaign_recipient_import_mapping_profiles")
op.drop_index("ix_recipient_import_profiles_ordered_fp", table_name="campaign_recipient_import_mapping_profiles")
op.drop_index("ix_recipient_import_profiles_owner", table_name="campaign_recipient_import_mapping_profiles")
op.drop_index(op.f("ix_campaign_recipient_import_mapping_profiles_unordered_header_fingerprint"), table_name="campaign_recipient_import_mapping_profiles")
op.drop_index(op.f("ix_campaign_recipient_import_mapping_profiles_ordered_header_fingerprint"), table_name="campaign_recipient_import_mapping_profiles")
op.drop_index(op.f("ix_campaign_recipient_import_mapping_profiles_owner_user_id"), table_name="campaign_recipient_import_mapping_profiles")
op.drop_index(op.f("ix_campaign_recipient_import_mapping_profiles_tenant_id"), table_name="campaign_recipient_import_mapping_profiles")
op.drop_table("campaign_recipient_import_mapping_profiles")

View File

@@ -19,6 +19,9 @@ from govoplan_campaign.backend.schemas import (
CampaignShareTargetsResponse,
CampaignShareUpsertRequest,
CampaignOwnerUpdateRequest,
RecipientImportMappingProfileListResponse,
RecipientImportMappingProfilePayload,
RecipientImportMappingProfileResponse,
CampaignJobsResponse,
CampaignJobDetailResponse,
CampaignRetryJobsRequest,
@@ -40,7 +43,7 @@ from govoplan_campaign.backend.schemas import (
)
from govoplan_core.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, SendAttempt, User, UserGroupMembership
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignShare, CampaignVersion, Group, ImapAppendAttempt, RecipientImportMappingProfile, SendAttempt, User, UserGroupMembership
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
@@ -181,6 +184,27 @@ def _require_permission(principal: ApiPrincipal, scope: str) -> None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}")
def _get_recipient_import_profile_for_principal(session: Session, profile_id: str, principal: ApiPrincipal) -> RecipientImportMappingProfile:
profile = session.get(RecipientImportMappingProfile, profile_id)
if not profile or profile.tenant_id != principal.tenant_id or profile.owner_user_id != principal.user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipient import mapping profile not found")
return profile
def _apply_recipient_import_profile_payload(profile: RecipientImportMappingProfile, payload: RecipientImportMappingProfilePayload) -> None:
profile.name = payload.name.strip()
profile.column_count = payload.column_count
profile.headers = list(payload.headers)
profile.normalized_headers = list(payload.normalized_headers)
profile.ordered_header_fingerprint = payload.ordered_header_fingerprint
profile.unordered_header_fingerprint = payload.unordered_header_fingerprint
profile.delimiter = payload.delimiter
profile.header_rows = payload.header_rows
profile.quoted = payload.quoted
profile.value_separators = payload.value_separators
profile.mappings = [mapping.model_dump(mode="json") for mapping in payload.mappings]
def _recipient_sections_changed(current: dict[str, object] | None, proposed: dict[str, object] | None) -> bool:
if proposed is None:
return False
@@ -346,6 +370,109 @@ def list_campaigns(
return CampaignListResponse(campaigns=[CampaignResponse.model_validate(item) for item in campaigns])
@router.get("/recipient-import/mapping-profiles", response_model=RecipientImportMappingProfileListResponse)
def list_recipient_import_mapping_profiles(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:recipient:import")),
):
profiles = (
session.query(RecipientImportMappingProfile)
.filter(
RecipientImportMappingProfile.tenant_id == principal.tenant_id,
RecipientImportMappingProfile.owner_user_id == principal.user.id,
)
.order_by(RecipientImportMappingProfile.updated_at.desc(), RecipientImportMappingProfile.name.asc())
.all()
)
return RecipientImportMappingProfileListResponse(
profiles=[RecipientImportMappingProfileResponse.model_validate(profile) for profile in profiles]
)
@router.post(
"/recipient-import/mapping-profiles",
response_model=RecipientImportMappingProfileResponse,
status_code=status.HTTP_201_CREATED,
)
def create_recipient_import_mapping_profile(
payload: RecipientImportMappingProfilePayload,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:recipient:import")),
):
profile = RecipientImportMappingProfile(
tenant_id=principal.tenant_id,
owner_user_id=principal.user.id,
name=payload.name.strip(),
column_count=payload.column_count,
headers=list(payload.headers),
normalized_headers=list(payload.normalized_headers),
ordered_header_fingerprint=payload.ordered_header_fingerprint,
unordered_header_fingerprint=payload.unordered_header_fingerprint,
delimiter=payload.delimiter,
header_rows=payload.header_rows,
quoted=payload.quoted,
value_separators=payload.value_separators,
mappings=[mapping.model_dump(mode="json") for mapping in payload.mappings],
)
session.add(profile)
session.flush()
audit_from_principal(
session,
principal,
action="campaign.recipient_import_mapping_profile_created",
object_type="recipient_import_mapping_profile",
object_id=profile.id,
details={"name": profile.name, "ordered_header_fingerprint": profile.ordered_header_fingerprint},
commit=True,
)
session.refresh(profile)
return RecipientImportMappingProfileResponse.model_validate(profile)
@router.put("/recipient-import/mapping-profiles/{profile_id}", response_model=RecipientImportMappingProfileResponse)
def update_recipient_import_mapping_profile(
profile_id: str,
payload: RecipientImportMappingProfilePayload,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:recipient:import")),
):
profile = _get_recipient_import_profile_for_principal(session, profile_id, principal)
_apply_recipient_import_profile_payload(profile, payload)
session.add(profile)
audit_from_principal(
session,
principal,
action="campaign.recipient_import_mapping_profile_updated",
object_type="recipient_import_mapping_profile",
object_id=profile.id,
details={"name": profile.name, "ordered_header_fingerprint": profile.ordered_header_fingerprint},
commit=True,
)
session.refresh(profile)
return RecipientImportMappingProfileResponse.model_validate(profile)
@router.delete("/recipient-import/mapping-profiles/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_recipient_import_mapping_profile(
profile_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:recipient:import")),
):
profile = _get_recipient_import_profile_for_principal(session, profile_id, principal)
details = {"name": profile.name, "ordered_header_fingerprint": profile.ordered_header_fingerprint}
session.delete(profile)
audit_from_principal(
session,
principal,
action="campaign.recipient_import_mapping_profile_deleted",
object_type="recipient_import_mapping_profile",
object_id=profile_id,
details=details,
commit=True,
)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/{campaign_id}", response_model=CampaignResponse)
def get_campaign(
campaign_id: str,
@@ -2008,4 +2135,3 @@ def preview_campaign_attachments(
rules=rules,
unused_shared_files=[_file_preview(session, asset) for asset in unused] if payload.include_unmatched else [],
)

View File

@@ -482,6 +482,13 @@
},
"defaults": {
"$ref": "#/$defs/entry"
},
"imports": {
"type": "array",
"items": {
"$ref": "#/$defs/import_provenance"
},
"default": []
}
},
"additionalProperties": false
@@ -505,6 +512,13 @@
},
"defaults": {
"$ref": "#/$defs/entry"
},
"imports": {
"type": "array",
"items": {
"$ref": "#/$defs/import_provenance"
},
"default": []
}
},
"additionalProperties": false
@@ -1027,6 +1041,151 @@
},
"additionalProperties": false
},
"import_provenance": {
"type": "object",
"required": [
"id",
"imported_at",
"mode",
"source_type",
"header_rows",
"rows_total",
"valid_rows",
"invalid_rows",
"imported_rows"
],
"properties": {
"id": {
"type": "string"
},
"imported_at": {
"type": "string",
"format": "date-time"
},
"mode": {
"type": "string",
"enum": [
"append",
"replace"
]
},
"source_type": {
"type": "string",
"enum": [
"csv",
"xlsx",
"text"
]
},
"filename": {
"type": [
"string",
"null"
]
},
"sheet_name": {
"type": [
"string",
"null"
]
},
"encoding": {
"type": [
"string",
"null"
]
},
"delimiter": {
"type": [
"string",
"null"
]
},
"header_rows": {
"type": "integer",
"minimum": 0,
"default": 0
},
"quoted": {
"type": [
"boolean",
"null"
]
},
"value_separators": {
"type": [
"string",
"null"
]
},
"rows_total": {
"type": "integer",
"minimum": 0
},
"valid_rows": {
"type": "integer",
"minimum": 0
},
"invalid_rows": {
"type": "integer",
"minimum": 0
},
"imported_rows": {
"type": "integer",
"minimum": 0
},
"field_names_created": {
"type": "array",
"items": {
"type": "string"
},
"default": []
},
"attachment_patterns": {
"type": "integer",
"minimum": 0,
"default": 0
},
"mapping": {
"type": "array",
"items": {
"type": "object",
"required": [
"column_index",
"header",
"kind"
],
"properties": {
"column_index": {
"type": "integer",
"minimum": 0
},
"header": {
"type": "string"
},
"kind": {
"type": "string"
},
"field_name": {
"type": [
"string",
"null"
]
},
"new_field_name": {
"type": [
"string",
"null"
]
}
},
"additionalProperties": false
},
"default": []
}
},
"additionalProperties": false
},
"attachment_base_path": {
"type": "object",
"required": [

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator
from govoplan_core.mail.config import ImapConfig, SmtpConfig
@@ -186,6 +186,70 @@ class CampaignOwnerUpdateRequest(BaseModel):
owner_group_id: str | None = None
RecipientImportColumnKind = Literal[
"ignore",
"id",
"active",
"name",
"from",
"to",
"cc",
"bcc",
"reply_to",
"field",
"new_field",
"attachment_pattern",
]
class RecipientImportColumnMappingPayload(BaseModel):
model_config = ConfigDict(extra="forbid", populate_by_name=True)
column_index: int = Field(ge=0, alias="columnIndex")
kind: RecipientImportColumnKind
field_name: str | None = Field(default=None, max_length=255, alias="fieldName")
new_field_name: str | None = Field(default=None, max_length=255, alias="newFieldName")
class RecipientImportMappingProfilePayload(BaseModel):
model_config = ConfigDict(extra="forbid", populate_by_name=True)
name: str = Field(min_length=1, max_length=255)
column_count: int = Field(ge=0, le=500, alias="columnCount")
headers: list[str] = Field(default_factory=list, max_length=500)
normalized_headers: list[str] = Field(default_factory=list, max_length=500, alias="normalizedHeaders")
ordered_header_fingerprint: str = Field(min_length=1, max_length=64, alias="orderedHeaderFingerprint")
unordered_header_fingerprint: str = Field(min_length=1, max_length=64, alias="unorderedHeaderFingerprint")
delimiter: Literal[",", ";", "\t"]
header_rows: int = Field(ge=0, le=10, alias="headerRows")
quoted: bool = True
value_separators: str = Field(default=",;|", max_length=50, alias="valueSeparators")
mappings: list[RecipientImportColumnMappingPayload] = Field(default_factory=list, max_length=500)
@model_validator(mode="after")
def validate_column_shape(self) -> "RecipientImportMappingProfilePayload":
if len(self.headers) != self.column_count:
raise ValueError("headers length must match columnCount")
if len(self.normalized_headers) != self.column_count:
raise ValueError("normalizedHeaders length must match columnCount")
for mapping in self.mappings:
if mapping.column_index >= self.column_count:
raise ValueError("mapping columnIndex exceeds columnCount")
return self
class RecipientImportMappingProfileResponse(RecipientImportMappingProfilePayload):
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
id: str
created_at: datetime = Field(alias="createdAt")
updated_at: datetime = Field(alias="updatedAt")
class RecipientImportMappingProfileListResponse(BaseModel):
profiles: list[RecipientImportMappingProfileResponse] = Field(default_factory=list)
class CampaignJobsResponse(BaseModel):
jobs: list[dict[str, Any]]
page: int = 1