from __future__ import annotations from datetime import datetime from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, model_validator AddressImportFormat = Literal["csv", "xlsx", "ldif"] AddressImportScope = Literal["user", "group", "tenant", "system"] IMPORT_TARGET_FIELDS = frozenset( { "source_key", "display_name", "given_name", "family_name", "organization", "role_title", "note", "email", "phone", "street", "postal_code", "locality", "region", "country", "tags", "visibility", } ) class AddressImportConfiguration(BaseModel): field_mappings: dict[str, str] = Field(default_factory=dict, max_length=40) delimiter: Literal[",", ";", "\t", "|"] = "," encoding: Literal["utf-8", "utf-8-sig", "cp1252", "latin-1"] = "utf-8-sig" header_row: int = Field(default=1, ge=1, le=100) sheet_name: str | None = Field(default=None, max_length=255) source_key_column: str | None = Field(default=None, max_length=255) duplicate_source_key_policy: Literal["reject", "first", "last"] = "reject" existing_contact_policy: Literal["update", "ignore", "reject"] = "update" blank_value_policy: Literal["ignore", "clear", "reject"] = "ignore" ldif_change_record_policy: Literal["reject", "ignore", "treat_add_as_entry"] = "reject" locale: str | None = Field(default=None, max_length=35) default_tags: list[str] = Field(default_factory=list, max_length=100) max_rows: int = Field(default=10_000, ge=1, le=10_000) @model_validator(mode="after") def validate_mappings(self) -> "AddressImportConfiguration": invalid = sorted(set(self.field_mappings).difference(IMPORT_TARGET_FIELDS)) if invalid: raise ValueError(f"Unsupported address import target fields: {', '.join(invalid)}") for target, column in self.field_mappings.items(): if not target.strip() or not column.strip(): raise ValueError("Import mapping targets and source columns cannot be blank.") if "source_key" not in self.field_mappings and not self.source_key_column: raise ValueError("Address import profiles require a stable source-key column.") return self class AddressImportProfileCreateRequest(BaseModel): scope_type: AddressImportScope = "tenant" scope_id: str | None = Field(default=None, max_length=36) name: str = Field(min_length=1, max_length=255) description: str | None = Field(default=None, max_length=4000) source_format: AddressImportFormat configuration: AddressImportConfiguration class AddressImportProfileUpdateRequest(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=255) description: str | None = Field(default=None, max_length=4000) configuration: AddressImportConfiguration | None = None class AddressImportProfileResponse(BaseModel): model_config = ConfigDict(from_attributes=True) id: str profile_key: str version: int tenant_id: str | None = None scope_type: str scope_id: str | None = None name: str description: str | None = None source_format: str configuration: AddressImportConfiguration is_current: bool created_by_account_id: str | None = None superseded_at: datetime | None = None created_at: datetime updated_at: datetime class AddressImportProfileListResponse(BaseModel): profiles: list[AddressImportProfileResponse] = Field(default_factory=list) class AddressImportFilePayload(BaseModel): filename: str = Field(min_length=1, max_length=500) content_base64: str = Field(min_length=1, max_length=14_000_000) class AddressImportPreviewRequest(AddressImportFilePayload): profile_id: str = Field(min_length=1, max_length=36) class AddressImportEffectResponse(BaseModel): row_number: int action: Literal["create", "update", "conflict", "unchanged", "ignored"] source_key: str | None = None contact_id: str | None = None display_name: str | None = None changed_fields: list[str] = Field(default_factory=list) message: str | None = None class AddressImportDiagnosticResponse(BaseModel): severity: Literal["info", "warning", "error"] code: str message: str row_number: int | None = None field: str | None = None details: dict[str, Any] = Field(default_factory=dict) class AddressImportRunResponse(BaseModel): id: str address_book_id: str profile_id: str | None source_filename: str source_format: str input_hash: str plan_hash: str status: str row_count: int statistics: dict[str, int] = Field(default_factory=dict) diagnostics: list[AddressImportDiagnosticResponse] = Field(default_factory=list) effects: list[AddressImportEffectResponse] = Field(default_factory=list) can_apply: bool result_evidence: dict[str, Any] = Field(default_factory=dict) created_at: datetime updated_at: datetime applied_at: datetime | None = None rolled_back_at: datetime | None = None class AddressImportCommitRequest(BaseModel): expected_plan_hash: str = Field(min_length=64, max_length=64) class AddressImportRollbackRequest(BaseModel): expected_plan_hash: str = Field(min_length=64, max_length=64) reason: str = Field(min_length=3, max_length=2000) __all__ = [ "AddressImportCommitRequest", "AddressImportConfiguration", "AddressImportDiagnosticResponse", "AddressImportEffectResponse", "AddressImportFilePayload", "AddressImportFormat", "AddressImportPreviewRequest", "AddressImportProfileCreateRequest", "AddressImportProfileListResponse", "AddressImportProfileResponse", "AddressImportProfileUpdateRequest", "AddressImportRollbackRequest", "AddressImportRunResponse", "IMPORT_TARGET_FIELDS", ]