Release v0.1.8

This commit is contained in:
2026-07-11 17:00:37 +02:00
parent a00ef54821
commit 9a0c467d55
102 changed files with 2150 additions and 9272 deletions

View File

@@ -0,0 +1,85 @@
"""split mail profile usernames from server config
Revision ID: 0a1b2c3d4e6f
Revises: f5a6b7c8d9e0
Create Date: 2026-06-25 15:20:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0a1b2c3d4e6f"
down_revision = "f5a6b7c8d9e0"
branch_labels = None
depends_on = None
def _profiles_table() -> sa.Table:
return sa.table(
"mail_server_profiles",
sa.column("id", sa.String(length=36)),
sa.column("smtp_config", sa.JSON()),
sa.column("smtp_username", sa.String(length=320)),
sa.column("imap_config", sa.JSON()),
sa.column("imap_username", sa.String(length=320)),
)
def _without_username(value):
if not isinstance(value, dict):
return value, None
data = dict(value)
username = data.pop("username", None)
data.pop("enabled", None)
return data, username
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if "mail_server_profiles" not in inspector.get_table_names():
return
columns = {column["name"] for column in inspector.get_columns("mail_server_profiles")}
if "smtp_username" not in columns:
op.add_column("mail_server_profiles", sa.Column("smtp_username", sa.String(length=320), nullable=True))
if "imap_username" not in columns:
op.add_column("mail_server_profiles", sa.Column("imap_username", sa.String(length=320), nullable=True))
profiles = _profiles_table()
rows = bind.execute(sa.select(profiles.c.id, profiles.c.smtp_config, profiles.c.smtp_username, profiles.c.imap_config, profiles.c.imap_username)).mappings().all()
for row in rows:
smtp_config, smtp_username = _without_username(row["smtp_config"] or {})
imap_config, imap_username = _without_username(row["imap_config"] or {}) if row["imap_config"] is not None else (None, None)
values = {
"smtp_config": smtp_config,
"imap_config": imap_config,
}
if row["smtp_username"] in (None, "") and smtp_username not in (None, ""):
values["smtp_username"] = str(smtp_username)
if row["imap_username"] in (None, "") and imap_username not in (None, ""):
values["imap_username"] = str(imap_username)
bind.execute(sa.update(profiles).where(profiles.c.id == row["id"]).values(**values))
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if "mail_server_profiles" not in inspector.get_table_names():
return
columns = {column["name"] for column in inspector.get_columns("mail_server_profiles")}
profiles = _profiles_table()
if {"smtp_username", "imap_username"}.issubset(columns):
rows = bind.execute(sa.select(profiles.c.id, profiles.c.smtp_config, profiles.c.smtp_username, profiles.c.imap_config, profiles.c.imap_username)).mappings().all()
for row in rows:
smtp_config = dict(row["smtp_config"] or {})
if row["smtp_username"] not in (None, ""):
smtp_config["username"] = row["smtp_username"]
imap_config = dict(row["imap_config"] or {}) if row["imap_config"] is not None else None
if imap_config is not None and row["imap_username"] not in (None, ""):
imap_config["username"] = row["imap_username"]
bind.execute(sa.update(profiles).where(profiles.c.id == row["id"]).values(smtp_config=smtp_config, imap_config=imap_config))
if "imap_username" in columns:
op.drop_column("mail_server_profiles", "imap_username")
if "smtp_username" in columns:
op.drop_column("mail_server_profiles", "smtp_username")

View File

@@ -0,0 +1,52 @@
"""add audit outbox events to detailed dev track
Revision ID: 0f1e2d3c4b5a
Revises: 4f2a9c8e7b6d
Create Date: 2026-07-11 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0f1e2d3c4b5a"
down_revision = "4f2a9c8e7b6d"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"audit_outbox_events",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("event_id", sa.String(length=36), nullable=False),
sa.Column("event_type", sa.String(length=200), nullable=False),
sa.Column("module_id", sa.String(length=100), nullable=False),
sa.Column("correlation_id", sa.String(length=128), nullable=True),
sa.Column("causation_id", sa.String(length=128), nullable=True),
sa.Column("classification", sa.String(length=40), nullable=False),
sa.Column("payload", sa.JSON(), nullable=False),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column("attempts", sa.Integer(), nullable=False),
sa.Column("next_attempt_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("dispatched_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_audit_outbox_events")),
sa.UniqueConstraint("event_id", name="uq_audit_outbox_events_event_id"),
)
op.create_index("ix_audit_outbox_events_correlation_id", "audit_outbox_events", ["correlation_id"], unique=False)
op.create_index("ix_audit_outbox_events_event_type", "audit_outbox_events", ["event_type"], unique=False)
op.create_index(op.f("ix_audit_outbox_events_status"), "audit_outbox_events", ["status"], unique=False)
op.create_index(
"ix_audit_outbox_events_status_next_attempt_at",
"audit_outbox_events",
["status", "next_attempt_at"],
unique=False,
)
def downgrade() -> None:
op.drop_table("audit_outbox_events")

View File

@@ -0,0 +1,108 @@
"""drop mail credential override policy fields
Revision ID: 1b2c3d4e5f70
Revises: 0a1b2c3d4e6f
Create Date: 2026-06-25 18:30:00.000000
"""
from __future__ import annotations
import json
from typing import Any
from alembic import op
import sqlalchemy as sa
revision = "1b2c3d4e5f70"
down_revision = "0a1b2c3d4e6f"
branch_labels = None
depends_on = None
_MAIL_POLICY_KEY = "mail_profile_policy"
_CREDENTIAL_KEYS = ("smtp_credentials", "imap_credentials")
_DEPRECATED_LIMIT_KEYS = ("smtp_credentials.allow_override", "imap_credentials.allow_override")
def _json_table(table_name: str, column_name: str) -> sa.Table:
return sa.table(
table_name,
sa.column("id", sa.String(length=36)),
sa.column(column_name, sa.JSON()),
)
def _as_dict(value: Any) -> dict[str, Any] | None:
if isinstance(value, dict):
return dict(value)
if isinstance(value, str):
try:
parsed = json.loads(value)
except json.JSONDecodeError:
return None
return dict(parsed) if isinstance(parsed, dict) else None
return None
def _scrub_policy(value: Any) -> tuple[dict[str, Any] | None, bool]:
policy = _as_dict(value)
if policy is None:
return None, False
changed = False
for key in _CREDENTIAL_KEYS:
credential = _as_dict(policy.get(key))
if credential is not None and "allow_override" in credential:
credential.pop("allow_override", None)
policy[key] = credential
changed = True
limits = _as_dict(policy.get("allow_lower_level_limits"))
if limits is not None:
for key in _DEPRECATED_LIMIT_KEYS:
if key in limits:
limits.pop(key, None)
changed = True
if changed:
policy["allow_lower_level_limits"] = limits
return policy, changed
def _scrub_settings_table(table_name: str) -> None:
bind = op.get_bind()
table = _json_table(table_name, "settings")
rows = bind.execute(sa.select(table.c.id, table.c.settings)).mappings().all()
for row in rows:
settings = _as_dict(row["settings"])
if settings is None:
continue
policy, changed = _scrub_policy(settings.get(_MAIL_POLICY_KEY))
if changed and policy is not None:
settings[_MAIL_POLICY_KEY] = policy
bind.execute(sa.update(table).where(table.c.id == row["id"]).values(settings=settings))
def _scrub_policy_column(table_name: str) -> None:
bind = op.get_bind()
table = _json_table(table_name, "mail_profile_policy")
rows = bind.execute(sa.select(table.c.id, table.c.mail_profile_policy)).mappings().all()
for row in rows:
policy, changed = _scrub_policy(row["mail_profile_policy"])
if changed and policy is not None:
bind.execute(sa.update(table).where(table.c.id == row["id"]).values(mail_profile_policy=policy))
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
for table_name in ("core_system_settings", "tenancy_tenants"):
if table_name in tables and "settings" in {column["name"] for column in inspector.get_columns(table_name)}:
_scrub_settings_table(table_name)
for table_name in ("access_users", "access_groups", "campaigns"):
if table_name in tables and "mail_profile_policy" in {column["name"] for column in inspector.get_columns(table_name)}:
_scrub_policy_column(table_name)
def downgrade() -> None:
# The removed fields are redundant with the surviving lower-level limit
# switches, so they cannot be reconstructed safely.
pass

View File

@@ -0,0 +1,58 @@
"""editable campaign versions
Revision ID: 1f8d4c2a0b7e
Revises: b57c5b216bce
Create Date: 2026-06-08 08:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "1f8d4c2a0b7e"
down_revision = "b57c5b216bce"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("campaign_versions") as batch_op:
batch_op.add_column(sa.Column("source_base_path", sa.String(length=1000), nullable=True))
batch_op.add_column(sa.Column("workflow_state", sa.String(length=50), nullable=False, server_default="editing"))
batch_op.add_column(sa.Column("current_flow", sa.String(length=50), nullable=False, server_default="manual"))
batch_op.add_column(sa.Column("current_step", sa.String(length=100), nullable=True))
batch_op.add_column(sa.Column("is_complete", sa.Boolean(), nullable=False, server_default=sa.false()))
batch_op.add_column(sa.Column("editor_state", sa.JSON(), nullable=False, server_default=sa.text("'{}'")))
batch_op.add_column(sa.Column("autosaved_at", sa.DateTime(timezone=True), nullable=True))
batch_op.add_column(sa.Column("published_at", sa.DateTime(timezone=True), nullable=True))
batch_op.add_column(sa.Column("locked_at", sa.DateTime(timezone=True), nullable=True))
batch_op.add_column(sa.Column("locked_by_user_id", sa.String(length=36), nullable=True))
batch_op.create_foreign_key(
op.f("fk_campaign_versions_locked_by_user_id_users"),
"access_users",
["locked_by_user_id"],
["id"],
ondelete="SET NULL",
)
batch_op.create_index(op.f("ix_campaign_versions_workflow_state"), ["workflow_state"], unique=False)
batch_op.create_index(op.f("ix_campaign_versions_current_flow"), ["current_flow"], unique=False)
batch_op.create_index(op.f("ix_campaign_versions_locked_by_user_id"), ["locked_by_user_id"], unique=False)
def downgrade() -> None:
with op.batch_alter_table("campaign_versions") as batch_op:
batch_op.drop_index(op.f("ix_campaign_versions_locked_by_user_id"))
batch_op.drop_index(op.f("ix_campaign_versions_current_flow"))
batch_op.drop_index(op.f("ix_campaign_versions_workflow_state"))
batch_op.drop_constraint(op.f("fk_campaign_versions_locked_by_user_id_users"), type_="foreignkey")
batch_op.drop_column("locked_by_user_id")
batch_op.drop_column("locked_at")
batch_op.drop_column("published_at")
batch_op.drop_column("autosaved_at")
batch_op.drop_column("editor_state")
batch_op.drop_column("is_complete")
batch_op.drop_column("current_step")
batch_op.drop_column("current_flow")
batch_op.drop_column("workflow_state")
batch_op.drop_column("source_base_path")

View File

@@ -0,0 +1,130 @@
"""auth sessions and RBAC assignments
Revision ID: 2c3d4e5f6a7b
Revises: 1f8d4c2a0b7e
Create Date: 2026-06-08 10:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "2c3d4e5f6a7b"
down_revision = "1f8d4c2a0b7e"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("access_users") as batch_op:
batch_op.add_column(sa.Column("auth_provider", sa.String(length=50), nullable=False, server_default="local"))
batch_op.add_column(sa.Column("password_hash", sa.String(length=500), nullable=True))
batch_op.add_column(sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True))
op.create_table(
"access_user_group_memberships",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("user_id", sa.String(length=36), nullable=False),
sa.Column("group_id", sa.String(length=36), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["access_users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["group_id"], ["access_groups.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tenant_id", "user_id", "group_id", name="uq_user_group_memberships"),
)
op.create_index(op.f("ix_access_user_group_memberships_tenant_id"), "access_user_group_memberships", ["tenant_id"])
op.create_index(op.f("ix_access_user_group_memberships_user_id"), "access_user_group_memberships", ["user_id"])
op.create_index(op.f("ix_access_user_group_memberships_group_id"), "access_user_group_memberships", ["group_id"])
op.create_table(
"access_user_role_assignments",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("user_id", sa.String(length=36), nullable=False),
sa.Column("role_id", sa.String(length=36), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["access_users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["role_id"], ["access_roles.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tenant_id", "user_id", "role_id", name="uq_user_role_assignments"),
)
op.create_index(op.f("ix_access_user_role_assignments_tenant_id"), "access_user_role_assignments", ["tenant_id"])
op.create_index(op.f("ix_access_user_role_assignments_user_id"), "access_user_role_assignments", ["user_id"])
op.create_index(op.f("ix_access_user_role_assignments_role_id"), "access_user_role_assignments", ["role_id"])
op.create_table(
"access_group_role_assignments",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("group_id", sa.String(length=36), nullable=False),
sa.Column("role_id", sa.String(length=36), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["group_id"], ["access_groups.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["role_id"], ["access_roles.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tenant_id", "group_id", "role_id", name="uq_group_role_assignments"),
)
op.create_index(op.f("ix_access_group_role_assignments_tenant_id"), "access_group_role_assignments", ["tenant_id"])
op.create_index(op.f("ix_access_group_role_assignments_group_id"), "access_group_role_assignments", ["group_id"])
op.create_index(op.f("ix_access_group_role_assignments_role_id"), "access_group_role_assignments", ["role_id"])
op.create_table(
"access_auth_sessions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("user_id", sa.String(length=36), nullable=False),
sa.Column("token_hash", sa.String(length=128), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("user_agent", sa.String(length=500), nullable=True),
sa.Column("ip_address", sa.String(length=100), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["access_users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("token_hash"),
)
op.create_index(op.f("ix_access_auth_sessions_tenant_id"), "access_auth_sessions", ["tenant_id"])
op.create_index(op.f("ix_access_auth_sessions_user_id"), "access_auth_sessions", ["user_id"])
op.create_index(op.f("ix_access_auth_sessions_token_hash"), "access_auth_sessions", ["token_hash"])
op.create_index(op.f("ix_access_auth_sessions_expires_at"), "access_auth_sessions", ["expires_at"])
op.create_index(op.f("ix_access_auth_sessions_revoked_at"), "access_auth_sessions", ["revoked_at"])
def downgrade() -> None:
op.drop_index(op.f("ix_access_auth_sessions_revoked_at"), table_name="access_auth_sessions")
op.drop_index(op.f("ix_access_auth_sessions_expires_at"), table_name="access_auth_sessions")
op.drop_index(op.f("ix_access_auth_sessions_token_hash"), table_name="access_auth_sessions")
op.drop_index(op.f("ix_access_auth_sessions_user_id"), table_name="access_auth_sessions")
op.drop_index(op.f("ix_access_auth_sessions_tenant_id"), table_name="access_auth_sessions")
op.drop_table("access_auth_sessions")
op.drop_index(op.f("ix_access_group_role_assignments_role_id"), table_name="access_group_role_assignments")
op.drop_index(op.f("ix_access_group_role_assignments_group_id"), table_name="access_group_role_assignments")
op.drop_index(op.f("ix_access_group_role_assignments_tenant_id"), table_name="access_group_role_assignments")
op.drop_table("access_group_role_assignments")
op.drop_index(op.f("ix_access_user_role_assignments_role_id"), table_name="access_user_role_assignments")
op.drop_index(op.f("ix_access_user_role_assignments_user_id"), table_name="access_user_role_assignments")
op.drop_index(op.f("ix_access_user_role_assignments_tenant_id"), table_name="access_user_role_assignments")
op.drop_table("access_user_role_assignments")
op.drop_index(op.f("ix_access_user_group_memberships_group_id"), table_name="access_user_group_memberships")
op.drop_index(op.f("ix_access_user_group_memberships_user_id"), table_name="access_user_group_memberships")
op.drop_index(op.f("ix_access_user_group_memberships_tenant_id"), table_name="access_user_group_memberships")
op.drop_table("access_user_group_memberships")
with op.batch_alter_table("access_users") as batch_op:
batch_op.drop_column("last_login_at")
batch_op.drop_column("password_hash")
batch_op.drop_column("auth_provider")

View File

@@ -0,0 +1,78 @@
"""namespace platform-owned tables
Revision ID: 2e3f4a5b6c7d
Revises: 1b2c3d4e5f70
Create Date: 2026-07-09 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "2e3f4a5b6c7d"
down_revision = "1b2c3d4e5f70"
branch_labels = None
depends_on = None
_TABLE_RENAMES = (
("tenants", "tenancy_tenants"),
("accounts", "access_accounts"),
("users", "access_users"),
("groups", "access_groups"),
("roles", "access_roles"),
("system_role_assignments", "access_system_role_assignments"),
("user_group_memberships", "access_user_group_memberships"),
("user_role_assignments", "access_user_role_assignments"),
("group_role_assignments", "access_group_role_assignments"),
("api_keys", "access_api_keys"),
("auth_sessions", "access_auth_sessions"),
("system_settings", "core_system_settings"),
("governance_templates", "admin_governance_templates"),
("governance_template_assignments", "admin_governance_template_assignments"),
)
def _row_count(bind: sa.Connection, table_name: str) -> int:
return int(bind.execute(sa.text(f"SELECT COUNT(*) FROM {table_name}")).scalar_one())
def _rename_tables(renames: tuple[tuple[str, str], ...]) -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
tables = set(inspector.get_table_names())
old_tables_to_drop: set[str] = set()
new_tables_to_drop: set[str] = set()
for old_name, new_name in renames:
if old_name not in tables or new_name not in tables:
continue
if _row_count(bind, old_name) == 0:
old_tables_to_drop.add(old_name)
elif _row_count(bind, new_name) == 0:
new_tables_to_drop.add(new_name)
else:
raise RuntimeError(f"Cannot rename non-empty {old_name} over non-empty {new_name}")
for old_name, _new_name in reversed(renames):
if old_name in old_tables_to_drop:
op.drop_table(old_name)
tables.remove(old_name)
for _old_name, new_name in reversed(renames):
if new_name in new_tables_to_drop:
op.drop_table(new_name)
tables.remove(new_name)
for old_name, new_name in renames:
if old_name not in tables:
continue
op.rename_table(old_name, new_name)
tables.remove(old_name)
tables.add(new_name)
def upgrade() -> None:
_rename_tables(_TABLE_RENAMES)
def downgrade() -> None:
_rename_tables(tuple((new_name, old_name) for old_name, new_name in reversed(_TABLE_RENAMES)))

View File

@@ -0,0 +1,152 @@
"""file storage backend
Revision ID: 3d4e5f6a7b8c
Revises: 2c3d4e5f6a7b
Create Date: 2026-06-12 00:00:00.000000
"""
from __future__ import annotations
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "3d4e5f6a7b8c"
down_revision: Union[str, None] = "2c3d4e5f6a7b"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"file_blobs",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("storage_backend", sa.String(length=50), nullable=False),
sa.Column("storage_bucket", sa.String(length=255), nullable=True),
sa.Column("storage_key", sa.String(length=1000), nullable=False),
sa.Column("checksum_sha256", sa.String(length=64), nullable=False),
sa.Column("size_bytes", sa.Integer(), nullable=False),
sa.Column("content_type", sa.String(length=255), nullable=True),
sa.Column("ref_count", sa.Integer(), nullable=False, server_default="1"),
sa.Column("retained_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tenant_id", "checksum_sha256", "size_bytes", name="uq_file_blobs_tenant_checksum_size"),
)
op.create_index(op.f("ix_file_blobs_tenant_id"), "file_blobs", ["tenant_id"])
op.create_index(op.f("ix_file_blobs_checksum_sha256"), "file_blobs", ["checksum_sha256"])
op.create_table(
"file_assets",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("owner_type", sa.String(length=20), nullable=False),
sa.Column("owner_user_id", sa.String(length=36), nullable=True),
sa.Column("owner_group_id", sa.String(length=36), nullable=True),
sa.Column("current_version_id", sa.String(length=36), nullable=True),
sa.Column("display_path", sa.String(length=1000), nullable=False),
sa.Column("filename", sa.String(length=500), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("metadata", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["created_by_user_id"], ["access_users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["owner_group_id"], ["access_groups.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["owner_user_id"], ["access_users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
for col in ["tenant_id", "owner_type", "owner_user_id", "owner_group_id", "current_version_id", "display_path", "filename", "created_by_user_id", "deleted_at"]:
op.create_index(op.f(f"ix_file_assets_{col}"), "file_assets", [col])
op.create_table(
"file_versions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("file_asset_id", sa.String(length=36), nullable=False),
sa.Column("blob_id", sa.String(length=36), nullable=False),
sa.Column("version_number", sa.Integer(), nullable=False),
sa.Column("filename_at_upload", sa.String(length=500), nullable=False),
sa.Column("display_path_at_upload", sa.String(length=1000), nullable=False),
sa.Column("content_type", sa.String(length=255), nullable=True),
sa.Column("size_bytes", sa.Integer(), nullable=False),
sa.Column("checksum_sha256", sa.String(length=64), nullable=False),
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["blob_id"], ["file_blobs.id"], ondelete="RESTRICT"),
sa.ForeignKeyConstraint(["created_by_user_id"], ["access_users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["file_asset_id"], ["file_assets.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("file_asset_id", "version_number", name="uq_file_versions_asset_number"),
)
for col in ["tenant_id", "file_asset_id", "blob_id", "checksum_sha256", "created_by_user_id"]:
op.create_index(op.f(f"ix_file_versions_{col}"), "file_versions", [col])
op.create_table(
"file_shares",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("file_asset_id", sa.String(length=36), nullable=False),
sa.Column("target_type", sa.String(length=20), nullable=False),
sa.Column("target_id", sa.String(length=36), nullable=False),
sa.Column("permission", sa.String(length=20), nullable=False, server_default="read"),
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["created_by_user_id"], ["access_users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["file_asset_id"], ["file_assets.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("file_asset_id", "target_type", "target_id", "revoked_at", name="uq_file_shares_active_target"),
)
for col in ["tenant_id", "file_asset_id", "target_type", "target_id", "created_by_user_id", "revoked_at"]:
op.create_index(op.f(f"ix_file_shares_{col}"), "file_shares", [col])
op.create_table(
"campaign_attachment_uses",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("campaign_id", sa.String(length=36), nullable=False),
sa.Column("campaign_version_id", sa.String(length=36), nullable=False),
sa.Column("campaign_job_id", sa.String(length=36), nullable=True),
sa.Column("entry_index", sa.Integer(), nullable=True),
sa.Column("entry_id", sa.String(length=255), nullable=True),
sa.Column("file_asset_id", sa.String(length=36), nullable=False),
sa.Column("file_version_id", sa.String(length=36), nullable=False),
sa.Column("file_blob_id", sa.String(length=36), nullable=False),
sa.Column("filename_used", sa.String(length=500), nullable=False),
sa.Column("checksum_sha256", sa.String(length=64), nullable=False),
sa.Column("size_bytes", sa.Integer(), nullable=False),
sa.Column("content_type", sa.String(length=255), nullable=True),
sa.Column("use_stage", sa.String(length=20), nullable=False, server_default="built"),
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["campaign_id"], ["campaigns.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["campaign_job_id"], ["campaign_jobs.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["campaign_version_id"], ["campaign_versions.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["file_asset_id"], ["file_assets.id"], ondelete="RESTRICT"),
sa.ForeignKeyConstraint(["file_blob_id"], ["file_blobs.id"], ondelete="RESTRICT"),
sa.ForeignKeyConstraint(["file_version_id"], ["file_versions.id"], ondelete="RESTRICT"),
sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("campaign_job_id", "file_version_id", "filename_used", "use_stage", name="uq_campaign_attachment_uses_job_file_stage"),
)
for col in ["tenant_id", "campaign_id", "campaign_version_id", "campaign_job_id", "entry_id", "file_asset_id", "file_version_id", "file_blob_id", "use_stage", "used_at"]:
op.create_index(op.f(f"ix_campaign_attachment_uses_{col}"), "campaign_attachment_uses", [col])
def downgrade() -> None:
op.drop_table("campaign_attachment_uses")
op.drop_table("file_shares")
op.drop_table("file_versions")
op.drop_table("file_assets")
op.drop_table("file_blobs")

View File

@@ -0,0 +1,111 @@
"""add core change sequence
Revision ID: 3f4a5b6c7d8e
Revises: 2e3f4a5b6c7d
Create Date: 2026-07-09 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "3f4a5b6c7d8e"
down_revision = "2e3f4a5b6c7d"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "core_change_sequence" not in inspector.get_table_names():
op.create_table(
"core_change_sequence",
sa.Column("id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), autoincrement=True, nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=True),
sa.Column("module_id", sa.String(length=100), nullable=False),
sa.Column("collection", sa.String(length=150), nullable=False),
sa.Column("resource_type", sa.String(length=100), nullable=False),
sa.Column("resource_id", sa.String(length=255), nullable=False),
sa.Column("operation", sa.String(length=30), nullable=False),
sa.Column("actor_type", sa.String(length=30), nullable=True),
sa.Column("actor_id", sa.String(length=255), nullable=True),
sa.Column("payload", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_change_sequence")),
)
inspector = sa.inspect(op.get_bind())
indexes = {item["name"] for item in inspector.get_indexes("core_change_sequence")}
for column in (
"tenant_id",
"module_id",
"collection",
"resource_type",
"resource_id",
"operation",
"actor_type",
"actor_id",
"created_at",
):
name = op.f(f"ix_core_change_sequence_{column}")
if name not in indexes:
op.create_index(name, "core_change_sequence", [column], unique=False)
for name, columns in (
("ix_core_change_sequence_tenant_module_id", ["tenant_id", "module_id", "id"]),
("ix_core_change_sequence_collection_id", ["collection", "id"]),
("ix_core_change_sequence_resource", ["module_id", "resource_type", "resource_id"]),
):
if name not in indexes:
op.create_index(name, "core_change_sequence", columns, unique=False)
inspector = sa.inspect(op.get_bind())
if "core_change_sequence_retention_floor" not in inspector.get_table_names():
op.create_table(
"core_change_sequence_retention_floor",
sa.Column("id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), autoincrement=True, nullable=False),
sa.Column("tenant_key", sa.String(length=36), nullable=False),
sa.Column("module_id", sa.String(length=100), nullable=False),
sa.Column("collection", sa.String(length=150), nullable=False),
sa.Column("min_valid_sequence", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_change_sequence_retention_floor")),
sa.UniqueConstraint("tenant_key", "module_id", "collection", name="uq_core_change_sequence_retention_scope"),
)
indexes = {item["name"] for item in inspector.get_indexes("core_change_sequence_retention_floor")}
if "ix_core_change_sequence_retention_scope" not in indexes:
op.create_index(
"ix_core_change_sequence_retention_scope",
"core_change_sequence_retention_floor",
["tenant_key", "module_id", "collection"],
unique=False,
)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "core_change_sequence_retention_floor" in inspector.get_table_names():
indexes = {item["name"] for item in inspector.get_indexes("core_change_sequence_retention_floor")}
if "ix_core_change_sequence_retention_scope" in indexes:
op.drop_index("ix_core_change_sequence_retention_scope", table_name="core_change_sequence_retention_floor")
op.drop_table("core_change_sequence_retention_floor")
if "core_change_sequence" not in inspector.get_table_names():
return
indexes = {item["name"] for item in inspector.get_indexes("core_change_sequence")}
for name in (
"ix_core_change_sequence_resource",
"ix_core_change_sequence_collection_id",
"ix_core_change_sequence_tenant_module_id",
op.f("ix_core_change_sequence_created_at"),
op.f("ix_core_change_sequence_actor_id"),
op.f("ix_core_change_sequence_actor_type"),
op.f("ix_core_change_sequence_operation"),
op.f("ix_core_change_sequence_resource_id"),
op.f("ix_core_change_sequence_resource_type"),
op.f("ix_core_change_sequence_collection"),
op.f("ix_core_change_sequence_module_id"),
op.f("ix_core_change_sequence_tenant_id"),
):
if name in indexes:
op.drop_index(name, table_name="core_change_sequence")
op.drop_table("core_change_sequence")

View File

@@ -0,0 +1,45 @@
"""file folders
Revision ID: 4e5f6a7b8c9d
Revises: 3d4e5f6a7b8c
Create Date: 2026-06-12 00:30:00.000000
"""
from __future__ import annotations
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "4e5f6a7b8c9d"
down_revision: Union[str, None] = "3d4e5f6a7b8c"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"file_folders",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("owner_type", sa.String(length=20), nullable=False),
sa.Column("owner_user_id", sa.String(length=36), nullable=True),
sa.Column("owner_group_id", sa.String(length=36), nullable=True),
sa.Column("path", sa.String(length=1000), nullable=False),
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("metadata", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["created_by_user_id"], ["access_users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["owner_group_id"], ["access_groups.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["owner_user_id"], ["access_users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
for col in ["tenant_id", "owner_type", "owner_user_id", "owner_group_id", "path", "created_by_user_id", "deleted_at"]:
op.create_index(op.f(f"ix_file_folders_{col}"), "file_folders", [col])
def downgrade() -> None:
op.drop_table("file_folders")

View File

@@ -0,0 +1,76 @@
"""rename tenancy scope table to core scopes
Revision ID: 4f2a9c8e7b6d
Revises: 3f4a5b6c7d8e
Create Date: 2026-07-10 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "4f2a9c8e7b6d"
down_revision = "3f4a5b6c7d8e"
branch_labels = None
depends_on = None
LEGACY_SCOPE_TABLE = "tenancy_tenants"
CORE_SCOPE_TABLE = "core_scopes"
LEGACY_SLUG_INDEX = "ix_tenancy_tenants_slug"
CORE_SLUG_INDEX = "ix_core_scopes_slug"
def _row_count(bind: sa.Connection, table_name: str) -> int:
quoted = bind.dialect.identifier_preparer.quote(table_name)
return int(bind.execute(sa.text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one())
def _scope_tables(bind: sa.Connection) -> set[str]:
return set(sa.inspect(bind).get_table_names())
def _drop_table_if_empty(bind: sa.Connection, table_name: str) -> bool:
if _row_count(bind, table_name) != 0:
return False
op.drop_table(table_name)
return True
def _ensure_slug_index(bind: sa.Connection, table_name: str, index_name: str, old_index_name: str) -> None:
indexes = {index["name"] for index in sa.inspect(bind).get_indexes(table_name)}
if old_index_name in indexes:
op.drop_index(old_index_name, table_name=table_name)
indexes.remove(old_index_name)
if index_name not in indexes:
op.create_index(op.f(index_name), table_name, ["slug"], unique=True)
def _rename_scope_table(old_name: str, new_name: str) -> None:
bind = op.get_bind()
tables = _scope_tables(bind)
if old_name not in tables:
if new_name in tables:
_ensure_slug_index(bind, new_name, CORE_SLUG_INDEX if new_name == CORE_SCOPE_TABLE else LEGACY_SLUG_INDEX, LEGACY_SLUG_INDEX if new_name == CORE_SCOPE_TABLE else CORE_SLUG_INDEX)
return
if new_name in tables:
if _drop_table_if_empty(bind, new_name):
tables.remove(new_name)
elif _drop_table_if_empty(bind, old_name):
_ensure_slug_index(bind, new_name, CORE_SLUG_INDEX if new_name == CORE_SCOPE_TABLE else LEGACY_SLUG_INDEX, LEGACY_SLUG_INDEX if new_name == CORE_SCOPE_TABLE else CORE_SLUG_INDEX)
return
else:
raise RuntimeError(f"Cannot reconcile non-empty {old_name} over non-empty {new_name}")
if old_name in tables and new_name not in tables:
op.rename_table(old_name, new_name)
_ensure_slug_index(bind, new_name, CORE_SLUG_INDEX if new_name == CORE_SCOPE_TABLE else LEGACY_SLUG_INDEX, LEGACY_SLUG_INDEX if new_name == CORE_SCOPE_TABLE else CORE_SLUG_INDEX)
def upgrade() -> None:
_rename_scope_table(LEGACY_SCOPE_TABLE, CORE_SCOPE_TABLE)
def downgrade() -> None:
_rename_scope_table(CORE_SCOPE_TABLE, LEGACY_SCOPE_TABLE)

View File

@@ -0,0 +1,55 @@
"""explicit temporary and permanent user locks
Revision ID: 5f6a7b8c9d0e
Revises: 4e5f6a7b8c9d
Create Date: 2026-06-13 18:00:00.000000
"""
from __future__ import annotations
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "5f6a7b8c9d0e"
down_revision: Union[str, None] = "4e5f6a7b8c9d"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table("campaign_versions") as batch_op:
batch_op.add_column(sa.Column("user_lock_state", sa.String(length=20), nullable=True))
batch_op.add_column(sa.Column("user_locked_at", sa.DateTime(timezone=True), nullable=True))
batch_op.add_column(sa.Column("user_locked_by_user_id", sa.String(length=36), nullable=True))
batch_op.create_foreign_key(
"fk_campaign_versions_user_locked_by_user_id_users",
"access_users",
["user_locked_by_user_id"],
["id"],
ondelete="SET NULL",
)
batch_op.create_index("ix_campaign_versions_user_lock_state", ["user_lock_state"])
batch_op.create_index("ix_campaign_versions_user_locked_by_user_id", ["user_locked_by_user_id"])
# Existing published snapshots were the former irreversible user lock.
op.execute(
"""
UPDATE campaign_versions
SET user_lock_state = 'permanent',
user_locked_at = published_at,
user_locked_by_user_id = NULL
WHERE published_at IS NOT NULL
AND user_lock_state IS NULL
"""
)
def downgrade() -> None:
with op.batch_alter_table("campaign_versions") as batch_op:
batch_op.drop_index("ix_campaign_versions_user_locked_by_user_id")
batch_op.drop_index("ix_campaign_versions_user_lock_state")
batch_op.drop_constraint("fk_campaign_versions_user_locked_by_user_id_users", type_="foreignkey")
batch_op.drop_column("user_locked_by_user_id")
batch_op.drop_column("user_locked_at")
batch_op.drop_column("user_lock_state")

View File

@@ -0,0 +1,73 @@
"""Prevent duplicate active file-folder paths.
Revision ID: 6a7b8c9d0e1f
Revises: 5f6a7b8c9d0e
"""
from __future__ import annotations
from collections import defaultdict
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "6a7b8c9d0e1f"
down_revision = "5f6a7b8c9d0e"
branch_labels = None
depends_on = None
USER_INDEX = "uq_file_folders_active_user_path"
GROUP_INDEX = "uq_file_folders_active_group_path"
def _deduplicate_active_folders() -> None:
bind = op.get_bind()
rows = bind.execute(sa.text("""
SELECT id, tenant_id, owner_type, owner_user_id, owner_group_id, path, created_at
FROM file_folders
WHERE deleted_at IS NULL
ORDER BY created_at ASC, id ASC
""")).mappings().all()
grouped: dict[tuple[object, ...], list[str]] = defaultdict(list)
for row in rows:
owner_id = row["owner_user_id"] if row["owner_type"] == "user" else row["owner_group_id"]
grouped[(row["tenant_id"], row["owner_type"], owner_id, row["path"])].append(row["id"])
duplicate_ids = [folder_id for ids in grouped.values() for folder_id in ids[1:]]
if duplicate_ids:
bind.execute(
sa.text("UPDATE file_folders SET deleted_at = CURRENT_TIMESTAMP WHERE id IN :ids").bindparams(
sa.bindparam("ids", expanding=True)
),
{"ids": duplicate_ids},
)
def upgrade() -> None:
_deduplicate_active_folders()
existing = {item["name"] for item in inspect(op.get_bind()).get_indexes("file_folders")}
if USER_INDEX not in existing:
op.create_index(
USER_INDEX,
"file_folders",
["tenant_id", "owner_user_id", "path"],
unique=True,
sqlite_where=sa.text("owner_type = 'user' AND deleted_at IS NULL"),
postgresql_where=sa.text("owner_type = 'user' AND deleted_at IS NULL"),
)
if GROUP_INDEX not in existing:
op.create_index(
GROUP_INDEX,
"file_folders",
["tenant_id", "owner_group_id", "path"],
unique=True,
sqlite_where=sa.text("owner_type = 'group' AND deleted_at IS NULL"),
postgresql_where=sa.text("owner_type = 'group' AND deleted_at IS NULL"),
)
def downgrade() -> None:
existing = {item["name"] for item in inspect(op.get_bind()).get_indexes("file_folders")}
if GROUP_INDEX in existing:
op.drop_index(GROUP_INDEX, table_name="file_folders")
if USER_INDEX in existing:
op.drop_index(USER_INDEX, table_name="file_folders")

View File

@@ -0,0 +1,64 @@
"""Safe delivery lifecycle and immutable execution snapshot.
Revision ID: 7b8c9d0e1f2a
Revises: 6a7b8c9d0e1f
Create Date: 2026-06-14 12:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "7b8c9d0e1f2a"
down_revision = "6a7b8c9d0e1f"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("campaign_versions") as batch_op:
batch_op.add_column(sa.Column("execution_snapshot", sa.JSON(), nullable=True))
batch_op.add_column(sa.Column("execution_snapshot_hash", sa.String(length=64), nullable=True))
batch_op.add_column(sa.Column("execution_snapshot_at", sa.DateTime(timezone=True), nullable=True))
batch_op.create_index("ix_campaign_versions_execution_snapshot_hash", ["execution_snapshot_hash"], unique=False)
with op.batch_alter_table("campaign_jobs") as batch_op:
batch_op.add_column(sa.Column("claimed_at", sa.DateTime(timezone=True), nullable=True))
batch_op.add_column(sa.Column("claim_token", sa.String(length=36), nullable=True))
batch_op.add_column(sa.Column("smtp_started_at", sa.DateTime(timezone=True), nullable=True))
batch_op.add_column(sa.Column("outcome_unknown_at", sa.DateTime(timezone=True), nullable=True))
batch_op.add_column(sa.Column("eml_sha256", sa.String(length=64), nullable=True))
batch_op.create_index("ix_campaign_jobs_claim_token", ["claim_token"], unique=False)
batch_op.create_index("ix_campaign_jobs_eml_sha256", ["eml_sha256"], unique=False)
with op.batch_alter_table("send_attempts") as batch_op:
batch_op.add_column(sa.Column("status", sa.String(length=50), nullable=False, server_default="started"))
batch_op.add_column(sa.Column("claim_token", sa.String(length=36), nullable=True))
batch_op.create_index("ix_send_attempts_status", ["status"], unique=False)
batch_op.create_index("ix_send_attempts_claim_token", ["claim_token"], unique=False)
# Existing successful rows remain readable through the legacy 'sent' value.
# No data rewrite is required; new deliveries use 'smtp_accepted'.
def downgrade() -> None:
with op.batch_alter_table("send_attempts") as batch_op:
batch_op.drop_index("ix_send_attempts_claim_token")
batch_op.drop_index("ix_send_attempts_status")
batch_op.drop_column("claim_token")
batch_op.drop_column("status")
with op.batch_alter_table("campaign_jobs") as batch_op:
batch_op.drop_index("ix_campaign_jobs_eml_sha256")
batch_op.drop_index("ix_campaign_jobs_claim_token")
batch_op.drop_column("eml_sha256")
batch_op.drop_column("outcome_unknown_at")
batch_op.drop_column("smtp_started_at")
batch_op.drop_column("claim_token")
batch_op.drop_column("claimed_at")
with op.batch_alter_table("campaign_versions") as batch_op:
batch_op.drop_index("ix_campaign_versions_execution_snapshot_hash")
batch_op.drop_column("execution_snapshot_at")
batch_op.drop_column("execution_snapshot_hash")
batch_op.drop_column("execution_snapshot")

View File

@@ -0,0 +1,323 @@
"""global accounts and operational tenant administration
Revision ID: 8c9d0e1f2a3b
Revises: 7b8c9d0e1f2a
Create Date: 2026-06-14 18:00:00.000000
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from alembic import op
import sqlalchemy as sa
revision = "8c9d0e1f2a3b"
down_revision = "7b8c9d0e1f2a"
branch_labels = None
depends_on = None
def _now() -> datetime:
return datetime.now(timezone.utc)
def _normalize_email(value: str) -> str:
return value.strip().casefold()
def _coerce_datetime(value: object) -> datetime | None:
if value is None or isinstance(value, datetime):
return value
if isinstance(value, str):
normalized = value.strip().replace("Z", "+00:00")
parsed = datetime.fromisoformat(normalized)
return parsed.replace(tzinfo=timezone.utc) if parsed.tzinfo is None else parsed
raise TypeError(f"Unsupported datetime value during account migration: {value!r}")
def _role_definitions() -> dict[str, dict[str, object]]:
# Kept in the migration so historic upgrades remain deterministic even if
# application role presets evolve later.
tenant_admin = [
"admin:api_keys:read", "admin:api_keys:write", "admin:groups:read", "admin:groups:write",
"admin:roles:read", "admin:roles:write", "admin:settings:read", "admin:settings:write",
"admin:users:read", "admin:users:write", "attachments:read", "attachments:write",
"audit:read", "campaign:build", "campaign:queue", "campaign:read", "campaign:send",
"campaign:send_test", "campaign:validate", "campaign:write", "reports:read", "reports:send",
]
return {
"owner": {"name": "Owner", "permissions": ["tenant:*"], "description": "Full tenant access, including administration and delivery."},
"admin": {"name": "Administrator", "permissions": tenant_admin, "description": "Tenant administration and full campaign operation without system access."},
"campaign_manager": {"name": "Campaign manager", "permissions": ["campaign:read", "campaign:write", "campaign:validate", "campaign:build", "attachments:read", "attachments:write", "reports:read"], "description": "Prepare, validate and build campaigns, but do not start real delivery."},
"sender": {"name": "Sender", "permissions": ["campaign:read", "campaign:queue", "campaign:send_test", "campaign:send", "attachments:read", "reports:read", "reports:send"], "description": "Review, queue and send prepared campaigns."},
"reviewer": {"name": "Reviewer", "permissions": ["campaign:read", "campaign:validate", "attachments:read", "reports:read"], "description": "Inspect campaigns, validate them and view delivery reports."},
"viewer": {"name": "Viewer", "permissions": ["campaign:read", "attachments:read", "reports:read"], "description": "Read campaigns, files and reports without changing them."},
"auditor": {"name": "Auditor", "permissions": ["campaign:read", "reports:read", "audit:read"], "description": "Read campaigns, reports and audit records."},
}
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
tables = set(inspector.get_table_names())
user_columns = {column["name"] for column in inspector.get_columns("access_users")}
# Base.metadata.create_all() from a newer application can create brand-new
# tables while leaving existing tables unaltered. Repair that known drift by
# removing only empty, unreferenced administration tables before applying the
# real migration. A non-empty table is never guessed at or discarded.
if "account_id" not in user_columns and "access_system_role_assignments" in tables:
count = bind.execute(sa.text("SELECT COUNT(*) FROM access_system_role_assignments")).scalar_one()
if count:
raise RuntimeError("Cannot reconcile non-empty create_all system_role_assignments table")
op.drop_table("access_system_role_assignments")
tables.remove("access_system_role_assignments")
if "account_id" not in user_columns and "access_accounts" in tables:
count = bind.execute(sa.text("SELECT COUNT(*) FROM access_accounts")).scalar_one()
if count:
raise RuntimeError("Cannot reconcile non-empty create_all accounts table")
op.drop_table("access_accounts")
op.create_table(
"access_accounts",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("email", sa.String(length=320), nullable=False),
sa.Column("normalized_email", sa.String(length=320), nullable=False),
sa.Column("display_name", sa.String(length=255), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("auth_provider", sa.String(length=50), nullable=False, server_default="local"),
sa.Column("password_hash", sa.String(length=500), nullable=True),
sa.Column("password_reset_required", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("normalized_email", name="uq_accounts_normalized_email"),
)
op.create_index(op.f("ix_access_accounts_normalized_email"), "access_accounts", ["normalized_email"])
with op.batch_alter_table("tenancy_tenants") as batch_op:
batch_op.add_column(sa.Column("description", sa.Text(), nullable=True))
batch_op.add_column(sa.Column("default_locale", sa.String(length=20), nullable=False, server_default="en"))
batch_op.add_column(sa.Column("settings", sa.JSON(), nullable=False, server_default="{}"))
with op.batch_alter_table("access_groups") as batch_op:
batch_op.add_column(sa.Column("description", sa.Text(), nullable=True))
batch_op.add_column(sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()))
with op.batch_alter_table("access_roles") as batch_op:
batch_op.add_column(sa.Column("description", sa.Text(), nullable=True))
batch_op.add_column(sa.Column("is_builtin", sa.Boolean(), nullable=False, server_default=sa.false()))
batch_op.add_column(sa.Column("is_assignable", sa.Boolean(), nullable=False, server_default=sa.true()))
with op.batch_alter_table("access_users") as batch_op:
batch_op.add_column(sa.Column("account_id", sa.String(length=36), nullable=True))
with op.batch_alter_table("access_auth_sessions") as batch_op:
batch_op.add_column(sa.Column("account_id", sa.String(length=36), nullable=True))
users = bind.execute(sa.text(
"SELECT id, tenant_id, email, display_name, is_active, is_tenant_admin, auth_provider, password_hash, last_login_at, created_at, updated_at FROM access_users ORDER BY created_at, id"
)).mappings().all()
accounts_by_email: dict[str, str] = {}
account_rows: dict[str, dict[str, object]] = {}
first_system_owner_account_id: str | None = None
for row in users:
normalized = _normalize_email(row["email"])
account_id = accounts_by_email.get(normalized)
if account_id is None:
account_id = str(uuid.uuid4())
accounts_by_email[normalized] = account_id
account_rows[account_id] = {
"id": account_id,
"email": row["email"],
"normalized_email": normalized,
"display_name": row["display_name"],
"is_active": bool(row["is_active"]),
"auth_provider": row["auth_provider"] or "local",
"password_hash": row["password_hash"],
"password_reset_required": False,
"last_login_at": _coerce_datetime(row["last_login_at"]),
"created_at": _coerce_datetime(row["created_at"]) or _now(),
"updated_at": _coerce_datetime(row["updated_at"]) or _now(),
}
else:
account = account_rows[account_id]
if not account["password_hash"] and row["password_hash"]:
account["password_hash"] = row["password_hash"]
elif account["password_hash"] and row["password_hash"] and account["password_hash"] != row["password_hash"]:
account["password_reset_required"] = True
if not account["display_name"] and row["display_name"]:
account["display_name"] = row["display_name"]
account["is_active"] = bool(account["is_active"] or row["is_active"])
if first_system_owner_account_id is None and row["is_active"] and row["is_tenant_admin"]:
first_system_owner_account_id = account_id
accounts_table = sa.table(
"access_accounts",
sa.column("id", sa.String), sa.column("email", sa.String), sa.column("normalized_email", sa.String),
sa.column("display_name", sa.String), sa.column("is_active", sa.Boolean), sa.column("auth_provider", sa.String),
sa.column("password_hash", sa.String), sa.column("password_reset_required", sa.Boolean),
sa.column("last_login_at", sa.DateTime(timezone=True)), sa.column("created_at", sa.DateTime(timezone=True)),
sa.column("updated_at", sa.DateTime(timezone=True)),
)
if account_rows:
bind.execute(accounts_table.insert(), list(account_rows.values()))
for row in users:
bind.execute(
sa.text("UPDATE access_users SET account_id = :account_id WHERE id = :user_id"),
{"account_id": accounts_by_email[_normalize_email(row["email"])], "user_id": row["id"]},
)
bind.execute(sa.text(
"UPDATE access_auth_sessions SET account_id = (SELECT access_users.account_id FROM access_users WHERE access_users.id = access_auth_sessions.user_id)"
))
with op.batch_alter_table("access_users") as batch_op:
batch_op.alter_column("account_id", existing_type=sa.String(length=36), nullable=False)
batch_op.create_foreign_key("fk_users_account_id_accounts", "access_accounts", ["account_id"], ["id"], ondelete="CASCADE")
batch_op.create_unique_constraint("uq_users_tenant_account", ["tenant_id", "account_id"])
op.create_index(op.f("ix_access_users_account_id"), "access_users", ["account_id"])
with op.batch_alter_table("access_auth_sessions") as batch_op:
batch_op.alter_column("account_id", existing_type=sa.String(length=36), nullable=False)
batch_op.create_foreign_key("fk_auth_sessions_account_id_accounts", "access_accounts", ["account_id"], ["id"], ondelete="CASCADE")
op.create_index(op.f("ix_access_auth_sessions_account_id"), "access_auth_sessions", ["account_id"])
op.create_table(
"access_system_role_assignments",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("account_id", sa.String(length=36), nullable=False),
sa.Column("role_id", sa.String(length=36), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["account_id"], ["access_accounts.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["role_id"], ["access_roles.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("account_id", "role_id", name="uq_system_role_assignments"),
)
op.create_index(op.f("ix_access_system_role_assignments_account_id"), "access_system_role_assignments", ["account_id"])
op.create_index(op.f("ix_access_system_role_assignments_role_id"), "access_system_role_assignments", ["role_id"])
roles_table = sa.table(
"access_roles",
sa.column("id", sa.String), sa.column("tenant_id", sa.String), sa.column("slug", sa.String),
sa.column("name", sa.String), sa.column("description", sa.Text), sa.column("permissions", sa.JSON),
sa.column("is_builtin", sa.Boolean), sa.column("is_assignable", sa.Boolean),
sa.column("created_at", sa.DateTime(timezone=True)), sa.column("updated_at", sa.DateTime(timezone=True)),
)
now = _now()
tenant_ids = [row[0] for row in bind.execute(sa.text("SELECT id FROM tenancy_tenants")).all()]
definitions = _role_definitions()
tenant_role_ids: dict[tuple[str, str], str] = {}
for tenant_id in tenant_ids:
for slug, definition in definitions.items():
existing = bind.execute(
sa.text("SELECT id FROM access_roles WHERE tenant_id = :tenant_id AND slug = :slug"),
{"tenant_id": tenant_id, "slug": slug},
).scalar_one_or_none()
if existing:
role_id = existing
bind.execute(
roles_table.update().where(roles_table.c.id == role_id).values(
name=definition["name"], description=definition["description"],
permissions=definition["permissions"], is_builtin=True, is_assignable=True,
updated_at=now,
)
)
else:
role_id = str(uuid.uuid4())
bind.execute(roles_table.insert().values(
id=role_id, tenant_id=tenant_id, slug=slug, name=definition["name"],
description=definition["description"], permissions=definition["permissions"],
is_builtin=True, is_assignable=True, created_at=now, updated_at=now,
))
tenant_role_ids[(tenant_id, slug)] = role_id
system_owner_role_id = str(uuid.uuid4())
system_auditor_role_id = str(uuid.uuid4())
bind.execute(roles_table.insert(), [
{
"id": system_owner_role_id, "tenant_id": None, "slug": "system_owner", "name": "System owner",
"description": "Full instance-wide administration. At least one active account must retain this role.",
"permissions": ["system:*"], "is_builtin": True, "is_assignable": True,
"created_at": now, "updated_at": now,
},
{
"id": system_auditor_role_id, "tenant_id": None, "slug": "system_auditor", "name": "System auditor",
"description": "Read tenant registry, system access and cross-tenant audit records.",
"permissions": ["system:tenants:read", "system:access:read", "system:audit:read"],
"is_builtin": True, "is_assignable": True, "created_at": now, "updated_at": now,
},
])
op.create_index(
"uq_roles_system_slug",
"access_roles",
["slug"],
unique=True,
sqlite_where=sa.text("tenant_id IS NULL"),
postgresql_where=sa.text("tenant_id IS NULL"),
)
# Preserve the old tenant-admin shortcut by assigning the canonical owner
# role. Authorization no longer reads users.is_tenant_admin after upgrade.
for row in users:
if not row["is_tenant_admin"]:
continue
owner_role_id = tenant_role_ids[(row["tenant_id"], "owner")]
exists = bind.execute(sa.text(
"SELECT 1 FROM access_user_role_assignments WHERE tenant_id = :tenant_id AND user_id = :user_id AND role_id = :role_id"
), {"tenant_id": row["tenant_id"], "user_id": row["id"], "role_id": owner_role_id}).first()
if not exists:
bind.execute(sa.text(
"INSERT INTO access_user_role_assignments (id, tenant_id, user_id, role_id, created_at, updated_at) VALUES (:id, :tenant_id, :user_id, :role_id, :created_at, :updated_at)"
), {"id": str(uuid.uuid4()), "tenant_id": row["tenant_id"], "user_id": row["id"], "role_id": owner_role_id, "created_at": now, "updated_at": now})
# Bootstrap rule for existing installations: the earliest active legacy
# tenant administrator becomes the initial system owner. This prevents an
# upgrade from producing an instance with no actor able to create tenants or
# delegate system access. It can be changed immediately through Admin.
if first_system_owner_account_id is None and account_rows:
first_system_owner_account_id = next((
account_id for account_id, account in account_rows.items() if account["is_active"]
), None)
if first_system_owner_account_id:
bind.execute(sa.text(
"INSERT INTO access_system_role_assignments (id, account_id, role_id, created_at, updated_at) VALUES (:id, :account_id, :role_id, :created_at, :updated_at)"
), {"id": str(uuid.uuid4()), "account_id": first_system_owner_account_id, "role_id": system_owner_role_id, "created_at": now, "updated_at": now})
def downgrade() -> None:
op.drop_index("uq_roles_system_slug", table_name="access_roles")
op.drop_index(op.f("ix_access_system_role_assignments_role_id"), table_name="access_system_role_assignments")
op.drop_index(op.f("ix_access_system_role_assignments_account_id"), table_name="access_system_role_assignments")
op.drop_table("access_system_role_assignments")
op.drop_index(op.f("ix_access_auth_sessions_account_id"), table_name="access_auth_sessions")
with op.batch_alter_table("access_auth_sessions") as batch_op:
batch_op.drop_constraint("fk_auth_sessions_account_id_accounts", type_="foreignkey")
batch_op.drop_column("account_id")
op.drop_index(op.f("ix_access_users_account_id"), table_name="access_users")
with op.batch_alter_table("access_users") as batch_op:
batch_op.drop_constraint("uq_users_tenant_account", type_="unique")
batch_op.drop_constraint("fk_users_account_id_accounts", type_="foreignkey")
batch_op.drop_column("account_id")
with op.batch_alter_table("access_roles") as batch_op:
batch_op.drop_column("is_assignable")
batch_op.drop_column("is_builtin")
batch_op.drop_column("description")
with op.batch_alter_table("access_groups") as batch_op:
batch_op.drop_column("is_active")
batch_op.drop_column("description")
with op.batch_alter_table("tenancy_tenants") as batch_op:
batch_op.drop_column("settings")
batch_op.drop_column("default_locale")
batch_op.drop_column("description")
op.drop_index(op.f("ix_access_accounts_normalized_email"), table_name="access_accounts")
op.drop_table("access_accounts")

View File

@@ -0,0 +1,170 @@
"""system governance settings and reusable tenant templates
Revision ID: 9d0e1f2a3b4c
Revises: 8c9d0e1f2a3b
Create Date: 2026-06-15 00:00:00.000000
"""
from __future__ import annotations
from datetime import datetime, timezone
import json
from alembic import op
import sqlalchemy as sa
revision = "9d0e1f2a3b4c"
down_revision = "8c9d0e1f2a3b"
branch_labels = None
depends_on = None
def _now() -> datetime:
return datetime.now(timezone.utc)
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
tables = set(inspector.get_table_names())
# Reconcile only the empty create_all shape for the newly introduced tables.
for table_name in ("admin_governance_template_assignments", "admin_governance_templates", "core_system_settings"):
if table_name in tables:
count = bind.execute(sa.text(f"SELECT COUNT(*) FROM {table_name}")).scalar_one()
if count:
raise RuntimeError(f"Cannot reconcile non-empty create_all table {table_name}")
op.drop_table(table_name)
op.create_table(
"core_system_settings",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("default_locale", sa.String(length=20), nullable=False, server_default="en"),
sa.Column("allow_tenant_custom_groups", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("allow_tenant_custom_roles", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("allow_tenant_api_keys", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("settings", sa.JSON(), nullable=False, server_default="{}"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
now = _now()
bind.execute(sa.text(
"""
INSERT INTO core_system_settings
(id, default_locale, allow_tenant_custom_groups, allow_tenant_custom_roles,
allow_tenant_api_keys, settings, created_at, updated_at)
VALUES
('global', 'en', :allow_tenant_custom_groups, :allow_tenant_custom_roles,
:allow_tenant_api_keys, '{}', :created_at, :updated_at)
"""
), {
"allow_tenant_custom_groups": True,
"allow_tenant_custom_roles": True,
"allow_tenant_api_keys": True,
"created_at": now,
"updated_at": now,
})
op.create_table(
"admin_governance_templates",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("kind", sa.String(length=20), nullable=False),
sa.Column("slug", sa.String(length=100), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("permissions", sa.JSON(), nullable=False, server_default="[]"),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("kind", "slug", name="uq_governance_templates_kind_slug"),
)
op.create_index(op.f("ix_admin_governance_templates_kind"), "admin_governance_templates", ["kind"])
op.create_table(
"admin_governance_template_assignments",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("template_id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("mode", sa.String(length=20), nullable=False, server_default="available"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["template_id"], ["admin_governance_templates.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("template_id", "tenant_id", name="uq_governance_template_tenant"),
)
op.create_index(op.f("ix_admin_governance_template_assignments_template_id"), "admin_governance_template_assignments", ["template_id"])
op.create_index(op.f("ix_admin_governance_template_assignments_tenant_id"), "admin_governance_template_assignments", ["tenant_id"])
with op.batch_alter_table("tenancy_tenants") as batch_op:
batch_op.add_column(sa.Column("allow_custom_groups", sa.Boolean(), nullable=True))
batch_op.add_column(sa.Column("allow_custom_roles", sa.Boolean(), nullable=True))
batch_op.add_column(sa.Column("allow_api_keys", sa.Boolean(), nullable=True))
with op.batch_alter_table("access_groups") as batch_op:
batch_op.add_column(sa.Column("system_template_id", sa.String(length=36), nullable=True))
batch_op.add_column(sa.Column("system_required", sa.Boolean(), nullable=False, server_default=sa.false()))
batch_op.create_foreign_key(
"fk_groups_system_template_id_governance_templates",
"admin_governance_templates", ["system_template_id"], ["id"], ondelete="SET NULL",
)
op.create_index(op.f("ix_access_groups_system_template_id"), "access_groups", ["system_template_id"])
with op.batch_alter_table("access_roles") as batch_op:
batch_op.add_column(sa.Column("system_template_id", sa.String(length=36), nullable=True))
batch_op.add_column(sa.Column("system_required", sa.Boolean(), nullable=False, server_default=sa.false()))
batch_op.create_foreign_key(
"fk_roles_system_template_id_governance_templates",
"admin_governance_templates", ["system_template_id"], ["id"], ondelete="SET NULL",
)
op.create_index(op.f("ix_access_roles_system_template_id"), "access_roles", ["system_template_id"])
# Existing system owners use system:* and need no data change. Extend the
# read-only built-in auditor role to the newly introduced read scopes.
auditor = bind.execute(sa.text(
"SELECT id, permissions FROM access_roles WHERE tenant_id IS NULL AND slug = 'system_auditor'"
)).mappings().first()
if auditor:
raw_permissions = auditor["permissions"] or []
permissions = json.loads(raw_permissions) if isinstance(raw_permissions, str) else list(raw_permissions)
for scope in ("system:settings:read", "system:governance:read"):
if scope not in permissions:
permissions.append(scope)
roles_table = sa.table(
"access_roles",
sa.column("id", sa.String),
sa.column("permissions", sa.JSON),
sa.column("updated_at", sa.DateTime(timezone=True)),
)
bind.execute(
roles_table.update().where(roles_table.c.id == auditor["id"]).values(
permissions=permissions, updated_at=now
)
)
def downgrade() -> None:
op.drop_index(op.f("ix_access_roles_system_template_id"), table_name="access_roles")
with op.batch_alter_table("access_roles") as batch_op:
batch_op.drop_constraint("fk_roles_system_template_id_governance_templates", type_="foreignkey")
batch_op.drop_column("system_required")
batch_op.drop_column("system_template_id")
op.drop_index(op.f("ix_access_groups_system_template_id"), table_name="access_groups")
with op.batch_alter_table("access_groups") as batch_op:
batch_op.drop_constraint("fk_groups_system_template_id_governance_templates", type_="foreignkey")
batch_op.drop_column("system_required")
batch_op.drop_column("system_template_id")
with op.batch_alter_table("tenancy_tenants") as batch_op:
batch_op.drop_column("allow_api_keys")
batch_op.drop_column("allow_custom_roles")
batch_op.drop_column("allow_custom_groups")
op.drop_index(op.f("ix_admin_governance_template_assignments_tenant_id"), table_name="admin_governance_template_assignments")
op.drop_index(op.f("ix_admin_governance_template_assignments_template_id"), table_name="admin_governance_template_assignments")
op.drop_table("admin_governance_template_assignments")
op.drop_index(op.f("ix_admin_governance_templates_kind"), table_name="admin_governance_templates")
op.drop_table("admin_governance_templates")
op.drop_table("core_system_settings")

View File

@@ -0,0 +1,216 @@
"""refine permission catalogue, campaign ACLs and system-role model
Revision ID: a0b1c2d3e4f5
Revises: 9d0e1f2a3b4c
Create Date: 2026-06-15 10:30:00.000000
"""
from __future__ import annotations
from datetime import datetime, timezone
import json
from uuid import uuid4
from alembic import op
import sqlalchemy as sa
revision = "a0b1c2d3e4f5"
down_revision = "9d0e1f2a3b4c"
branch_labels = None
depends_on = None
TENANT_ROLE_PERMISSIONS = {
"owner": ["tenant:*"],
"tenant_admin": [
"campaign:read", "files:read", "reports:read", "audit:read",
"admin:users:read", "admin:users:create", "admin:users:update", "admin:users:suspend",
"admin:groups:read", "admin:groups:write", "admin:groups:manage_members",
"admin:roles:read", "admin:roles:write", "admin:roles:assign",
"admin:api_keys:read", "admin:api_keys:create", "admin:api_keys:revoke",
"admin:settings:read", "admin:settings:write", "admin:policies:read", "admin:policies:write",
],
"admin": ["tenant:*"],
"access_admin": [
"admin:users:read", "admin:users:create", "admin:users:update", "admin:users:suspend",
"admin:groups:read", "admin:groups:manage_members", "admin:roles:read", "admin:roles:assign",
],
"campaign_manager": [
"campaign:read", "campaign:create", "campaign:update", "campaign:copy", "campaign:validate", "campaign:build",
"recipients:read", "recipients:write", "recipients:import", "files:read", "files:download", "files:upload", "files:organize", "reports:read",
],
"reviewer": ["campaign:read", "campaign:validate", "campaign:review", "recipients:read", "files:read", "reports:read"],
"sender": [
"campaign:read", "campaign:send_test", "campaign:queue", "campaign:control", "campaign:send", "campaign:retry", "campaign:reconcile",
"recipients:read", "files:read", "reports:read", "reports:send", "mail_servers:use", "mail_servers:test",
],
"file_manager": ["files:read", "files:download", "files:upload", "files:organize", "files:share", "files:delete"],
"viewer": ["campaign:read", "recipients:read", "files:read", "reports:read"],
"auditor": ["campaign:read", "recipients:read", "recipients:export", "reports:read", "reports:export", "audit:read"],
}
SYSTEM_ALL = [
"system:tenants:read", "system:tenants:create", "system:tenants:update", "system:tenants:suspend",
"system:accounts:read", "system:accounts:create", "system:accounts:update", "system:accounts:suspend",
"system:roles:read", "system:roles:write", "system:roles:assign",
"system:access:read", "system:access:assign",
"system:audit:read", "system:settings:read", "system:settings:write",
"system:governance:read", "system:governance:write",
]
SYSTEM_ROLE_PERMISSIONS = {
"system_owner": ["system:*"],
"system_admin": SYSTEM_ALL,
"system_auditor": [
"system:tenants:read", "system:accounts:read", "system:roles:read", "system:access:read",
"system:audit:read", "system:settings:read", "system:governance:read",
],
}
# Some names were canonical in the old catalogue but represented a wider bundle.
# Expand every existing role once during upgrade; runtime authorization then treats
# the new canonical names narrowly.
LEGACY_EXPANSIONS = {
"campaign:write": {
"campaign:create", "campaign:update", "campaign:copy", "campaign:archive", "campaign:delete", "campaign:share",
"recipients:read", "recipients:write", "recipients:import",
},
"campaign:queue": {"campaign:queue", "campaign:control", "campaign:retry"},
"campaign:send": {"campaign:send", "campaign:reconcile"},
"attachments:read": {"files:read", "files:download"},
"attachments:write": {"files:upload", "files:organize", "files:share", "files:delete"},
"reports:read": {"reports:read", "reports:export"},
"admin:users": {
"admin:users:read", "admin:users:create", "admin:users:update", "admin:users:suspend",
"admin:groups:read", "admin:groups:write", "admin:groups:manage_members",
"admin:roles:read", "admin:roles:write", "admin:roles:assign",
},
"admin:users:write": {"admin:users:create", "admin:users:update", "admin:users:suspend", "admin:roles:assign", "admin:groups:manage_members"},
"admin:groups:write": {"admin:groups:write", "admin:groups:manage_members", "admin:roles:assign"},
"admin:roles:write": {"admin:roles:write", "admin:roles:assign"},
"admin:api_keys:write": {"admin:api_keys:create", "admin:api_keys:revoke"},
"admin:settings": {"admin:settings:read", "admin:settings:write", "admin:api_keys:read", "admin:api_keys:create", "admin:api_keys:revoke"},
"system:tenants:write": {"system:tenants:create", "system:tenants:update", "system:tenants:suspend"},
"system:access:read": {"system:access:read", "system:accounts:read", "system:roles:read"},
"system:access:write": {"system:access:assign", "system:roles:assign", "system:accounts:create", "system:accounts:update", "system:accounts:suspend"},
}
def _json(value: list[str]) -> str:
return json.dumps(value, separators=(",", ":"))
def _decode(value) -> list[str]:
if value is None:
return []
if isinstance(value, list):
return [str(item) for item in value]
if isinstance(value, str):
try:
decoded = json.loads(value)
return [str(item) for item in decoded] if isinstance(decoded, list) else []
except json.JSONDecodeError:
return []
return []
def _expand_legacy(scopes: list[str]) -> list[str]:
expanded: set[str] = set()
for scope in scopes:
replacement = LEGACY_EXPANSIONS.get(scope)
if replacement:
expanded.update(replacement)
else:
expanded.add(scope)
return sorted(expanded)
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
tables = set(inspector.get_table_names())
if "campaigns" in tables:
columns = {col["name"] for col in inspector.get_columns("campaigns")}
if "owner_user_id" not in columns:
op.add_column("campaigns", sa.Column("owner_user_id", sa.String(length=36), nullable=True))
op.create_index("ix_campaigns_owner_user_id", "campaigns", ["owner_user_id"])
if "owner_group_id" not in columns:
op.add_column("campaigns", sa.Column("owner_group_id", sa.String(length=36), nullable=True))
op.create_index("ix_campaigns_owner_group_id", "campaigns", ["owner_group_id"])
bind.execute(sa.text("UPDATE campaigns SET owner_user_id = created_by_user_id WHERE owner_user_id IS NULL"))
if "campaign_shares" not in tables:
op.create_table(
"campaign_shares",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("campaign_id", sa.String(length=36), nullable=False),
sa.Column("target_type", sa.String(length=20), nullable=False),
sa.Column("target_id", sa.String(length=36), nullable=False),
sa.Column("permission", sa.String(length=20), nullable=False, server_default="read"),
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["campaign_id"], ["campaigns.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["created_by_user_id"], ["access_users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("campaign_id", "target_type", "target_id", name="uq_campaign_share_target"),
)
op.create_index("ix_campaign_shares_tenant_id", "campaign_shares", ["tenant_id"])
op.create_index("ix_campaign_shares_campaign_id", "campaign_shares", ["campaign_id"])
op.create_index("ix_campaign_shares_target_type", "campaign_shares", ["target_type"])
op.create_index("ix_campaign_shares_target_id", "campaign_shares", ["target_id"])
op.create_index("ix_campaign_shares_created_by_user_id", "campaign_shares", ["created_by_user_id"])
op.create_index("ix_campaign_shares_revoked_at", "campaign_shares", ["revoked_at"])
if "access_roles" not in tables:
return
rows = bind.execute(sa.text("SELECT id, permissions FROM access_roles")).mappings().all()
for row in rows:
bind.execute(
sa.text("UPDATE access_roles SET permissions = :permissions WHERE id = :id"),
{"id": row["id"], "permissions": _json(_expand_legacy(_decode(row["permissions"])))},
)
for slug, permissions in TENANT_ROLE_PERMISSIONS.items():
bind.execute(
sa.text("UPDATE access_roles SET permissions = :permissions WHERE tenant_id IS NOT NULL AND slug = :slug AND is_builtin = TRUE"),
{"slug": slug, "permissions": _json(permissions)},
)
now = datetime.now(timezone.utc)
for slug, permissions in SYSTEM_ROLE_PERMISSIONS.items():
existing = bind.execute(
sa.text("SELECT id FROM access_roles WHERE tenant_id IS NULL AND slug = :slug"), {"slug": slug}
).first()
is_protected = slug == "system_owner"
if existing:
bind.execute(
sa.text(
"UPDATE access_roles SET permissions = :permissions, is_builtin = :is_builtin, is_assignable = TRUE "
"WHERE tenant_id IS NULL AND slug = :slug"
),
{"slug": slug, "permissions": _json(permissions), "is_builtin": is_protected},
)
else:
names = {
"system_owner": ("System owner", "Protected full instance-wide administration."),
"system_admin": ("System administrator", "Manage the instance without granting the protected System owner role."),
"system_auditor": ("System auditor", "Read-only access to system administration and audit."),
}
name, description = names[slug]
bind.execute(
sa.text(
"INSERT INTO access_roles (id, tenant_id, slug, name, description, permissions, is_builtin, is_assignable, "
"system_template_id, system_required, created_at, updated_at) "
"VALUES (:id, NULL, :slug, :name, :description, :permissions, :is_builtin, TRUE, NULL, FALSE, :created_at, :updated_at)"
),
{
"id": str(uuid4()), "slug": slug, "name": name, "description": description,
"permissions": _json(permissions), "is_builtin": is_protected,
"created_at": now, "updated_at": now,
},
)
def downgrade() -> None:
# ACL/evidence-bearing columns are intentionally retained on downgrade.
pass

View File

@@ -0,0 +1,69 @@
"""separate system and tenant audit scopes
Revision ID: b1c2d3e4f5a6
Revises: a0b1c2d3e4f5
Create Date: 2026-06-15 13:30:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "b1c2d3e4f5a6"
down_revision = "a0b1c2d3e4f5"
branch_labels = None
depends_on = None
SYSTEM_ACTIONS = (
"tenant.created",
"tenant.updated",
"system_role.created",
"system_role.updated",
"system_role.deleted",
"system_account.created",
"system_account.updated",
"system_access.updated",
"system_memberships.updated",
"system_settings.updated",
"governance_template.created",
"governance_template.updated",
"governance_template.deleted",
)
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if "audit_log" not in inspector.get_table_names():
return
columns = {column["name"] for column in inspector.get_columns("audit_log")}
if "scope" not in columns:
op.add_column(
"audit_log",
sa.Column("scope", sa.String(length=20), nullable=False, server_default="tenant"),
)
indexes = {index["name"] for index in sa.inspect(bind).get_indexes("audit_log")}
if "ix_audit_log_scope" not in indexes:
op.create_index("ix_audit_log_scope", "audit_log", ["scope"])
placeholders = ", ".join(f":action_{index}" for index, _ in enumerate(SYSTEM_ACTIONS))
params = {f"action_{index}": action for index, action in enumerate(SYSTEM_ACTIONS)}
bind.execute(
sa.text(f"UPDATE audit_log SET scope = 'system' WHERE action IN ({placeholders})"),
params,
)
bind.execute(sa.text("UPDATE audit_log SET scope = 'tenant' WHERE scope IS NULL OR scope NOT IN ('tenant', 'system')"))
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if "audit_log" not in inspector.get_table_names():
return
indexes = {index["name"] for index in inspector.get_indexes("audit_log")}
if "ix_audit_log_scope" in indexes:
op.drop_index("ix_audit_log_scope", table_name="audit_log")
columns = {column["name"] for column in sa.inspect(bind).get_columns("audit_log")}
if "scope" in columns:
with op.batch_alter_table("audit_log") as batch_op:
batch_op.drop_column("scope")

View File

@@ -0,0 +1,346 @@
"""initial persistence models
Revision ID: b57c5b216bce
Revises:
Create Date: 2026-06-07 19:29:10.222504
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = 'b57c5b216bce'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('tenancy_tenants',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('slug', sa.String(length=100), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_tenants'))
)
op.create_index(op.f('ix_tenancy_tenants_slug'), 'tenancy_tenants', ['slug'], unique=True)
op.create_table('attachment_blobs',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('sha256', sa.String(length=64), nullable=False),
sa.Column('size_bytes', sa.Integer(), nullable=False),
sa.Column('mime_type', sa.String(length=255), nullable=True),
sa.Column('storage_bucket', sa.String(length=255), nullable=False),
sa.Column('storage_key', sa.String(length=1000), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['tenant_id'], ['tenancy_tenants.id'], name=op.f('fk_attachment_blobs_tenant_id_tenants'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_attachment_blobs')),
sa.UniqueConstraint('tenant_id', 'sha256', name='uq_attachment_blobs_tenant_sha256')
)
op.create_index(op.f('ix_attachment_blobs_sha256'), 'attachment_blobs', ['sha256'], unique=False)
op.create_index(op.f('ix_attachment_blobs_tenant_id'), 'attachment_blobs', ['tenant_id'], unique=False)
op.create_table('access_groups',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('slug', sa.String(length=100), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['tenant_id'], ['tenancy_tenants.id'], name=op.f('fk_groups_tenant_id_tenants'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_groups')),
sa.UniqueConstraint('tenant_id', 'slug', name='uq_groups_tenant_slug')
)
op.create_index(op.f('ix_access_groups_tenant_id'), 'access_groups', ['tenant_id'], unique=False)
op.create_table('access_roles',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=True),
sa.Column('slug', sa.String(length=100), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('permissions', 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(['tenant_id'], ['tenancy_tenants.id'], name=op.f('fk_roles_tenant_id_tenants'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_roles')),
sa.UniqueConstraint('tenant_id', 'slug', name='uq_roles_tenant_slug')
)
op.create_index(op.f('ix_access_roles_tenant_id'), 'access_roles', ['tenant_id'], unique=False)
op.create_table('access_users',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('email', sa.String(length=320), nullable=False),
sa.Column('display_name', sa.String(length=255), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('is_tenant_admin', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['tenant_id'], ['tenancy_tenants.id'], name=op.f('fk_users_tenant_id_tenants'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_users')),
sa.UniqueConstraint('tenant_id', 'email', name='uq_users_tenant_email')
)
op.create_index(op.f('ix_access_users_email'), 'access_users', ['email'], unique=False)
op.create_index(op.f('ix_access_users_tenant_id'), 'access_users', ['tenant_id'], unique=False)
op.create_table('access_api_keys',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('user_id', sa.String(length=36), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('prefix', sa.String(length=16), nullable=False),
sa.Column('key_hash', sa.String(length=128), nullable=False),
sa.Column('scopes', sa.JSON(), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['tenant_id'], ['tenancy_tenants.id'], name=op.f('fk_api_keys_tenant_id_tenants'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['access_users.id'], name=op.f('fk_api_keys_user_id_users'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_api_keys'))
)
op.create_index(op.f('ix_access_api_keys_prefix'), 'access_api_keys', ['prefix'], unique=False)
op.create_index(op.f('ix_access_api_keys_tenant_id'), 'access_api_keys', ['tenant_id'], unique=False)
op.create_index(op.f('ix_access_api_keys_user_id'), 'access_api_keys', ['user_id'], unique=False)
op.create_table('campaigns',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('created_by_user_id', sa.String(length=36), nullable=True),
sa.Column('external_id', sa.String(length=255), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('status', sa.String(length=50), nullable=False),
sa.Column('current_version_id', sa.String(length=36), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['created_by_user_id'], ['access_users.id'], name=op.f('fk_campaigns_created_by_user_id_users'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['tenant_id'], ['tenancy_tenants.id'], name=op.f('fk_campaigns_tenant_id_tenants'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_campaigns')),
sa.UniqueConstraint('tenant_id', 'external_id', name='uq_campaigns_tenant_external_id')
)
op.create_index(op.f('ix_campaigns_created_by_user_id'), 'campaigns', ['created_by_user_id'], unique=False)
op.create_index(op.f('ix_campaigns_external_id'), 'campaigns', ['external_id'], unique=False)
op.create_index(op.f('ix_campaigns_status'), 'campaigns', ['status'], unique=False)
op.create_index(op.f('ix_campaigns_tenant_id'), 'campaigns', ['tenant_id'], unique=False)
op.create_table('attachment_instances',
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=True),
sa.Column('campaign_id', sa.String(length=36), nullable=True),
sa.Column('blob_id', sa.String(length=36), nullable=False),
sa.Column('logical_name', sa.String(length=500), nullable=True),
sa.Column('filename', sa.String(length=500), nullable=False),
sa.Column('tags', sa.JSON(), nullable=False),
sa.Column('metadata', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['blob_id'], ['attachment_blobs.id'], name=op.f('fk_attachment_instances_blob_id_attachment_blobs'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['campaign_id'], ['campaigns.id'], name=op.f('fk_attachment_instances_campaign_id_campaigns'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['owner_user_id'], ['access_users.id'], name=op.f('fk_attachment_instances_owner_user_id_users'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['tenant_id'], ['tenancy_tenants.id'], name=op.f('fk_attachment_instances_tenant_id_tenants'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_attachment_instances'))
)
op.create_index(op.f('ix_attachment_instances_blob_id'), 'attachment_instances', ['blob_id'], unique=False)
op.create_index(op.f('ix_attachment_instances_campaign_id'), 'attachment_instances', ['campaign_id'], unique=False)
op.create_index(op.f('ix_attachment_instances_owner_user_id'), 'attachment_instances', ['owner_user_id'], unique=False)
op.create_index(op.f('ix_attachment_instances_tenant_id'), 'attachment_instances', ['tenant_id'], unique=False)
op.create_table('audit_log',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=True),
sa.Column('user_id', sa.String(length=36), nullable=True),
sa.Column('api_key_id', sa.String(length=36), nullable=True),
sa.Column('action', sa.String(length=100), nullable=False),
sa.Column('object_type', sa.String(length=100), nullable=True),
sa.Column('object_id', sa.String(length=100), nullable=True),
sa.Column('details', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['api_key_id'], ['access_api_keys.id'], name=op.f('fk_audit_log_api_key_id_api_keys'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['tenant_id'], ['tenancy_tenants.id'], name=op.f('fk_audit_log_tenant_id_tenants'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['access_users.id'], name=op.f('fk_audit_log_user_id_users'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_audit_log'))
)
op.create_index(op.f('ix_audit_log_action'), 'audit_log', ['action'], unique=False)
op.create_index(op.f('ix_audit_log_api_key_id'), 'audit_log', ['api_key_id'], unique=False)
op.create_index(op.f('ix_audit_log_object_id'), 'audit_log', ['object_id'], unique=False)
op.create_index(op.f('ix_audit_log_object_type'), 'audit_log', ['object_type'], unique=False)
op.create_index(op.f('ix_audit_log_tenant_id'), 'audit_log', ['tenant_id'], unique=False)
op.create_index(op.f('ix_audit_log_user_id'), 'audit_log', ['user_id'], unique=False)
op.create_table('campaign_versions',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('campaign_id', sa.String(length=36), nullable=False),
sa.Column('version_number', sa.Integer(), nullable=False),
sa.Column('raw_json', sa.JSON(), nullable=False),
sa.Column('schema_version', sa.String(length=50), nullable=False),
sa.Column('source_filename', sa.String(length=500), nullable=True),
sa.Column('validation_summary', sa.JSON(), nullable=True),
sa.Column('build_summary', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['campaign_id'], ['campaigns.id'], name=op.f('fk_campaign_versions_campaign_id_campaigns'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_campaign_versions')),
sa.UniqueConstraint('campaign_id', 'version_number', name='uq_campaign_versions_campaign_number')
)
op.create_index(op.f('ix_campaign_versions_campaign_id'), 'campaign_versions', ['campaign_id'], unique=False)
op.create_table('campaign_jobs',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('campaign_id', sa.String(length=36), nullable=False),
sa.Column('campaign_version_id', sa.String(length=36), nullable=False),
sa.Column('entry_index', sa.Integer(), nullable=False),
sa.Column('entry_id', sa.String(length=255), nullable=True),
sa.Column('recipient_email', sa.String(length=320), nullable=True),
sa.Column('subject', sa.String(length=998), nullable=True),
sa.Column('message_id_header', sa.String(length=255), nullable=True),
sa.Column('eml_storage_key', sa.String(length=1000), nullable=True),
sa.Column('eml_local_path', sa.String(length=1000), nullable=True),
sa.Column('eml_size_bytes', sa.Integer(), nullable=True),
sa.Column('build_status', sa.String(length=50), nullable=False),
sa.Column('validation_status', sa.String(length=50), nullable=False),
sa.Column('queue_status', sa.String(length=50), nullable=False),
sa.Column('send_status', sa.String(length=50), nullable=False),
sa.Column('imap_status', sa.String(length=50), nullable=False),
sa.Column('attempt_count', sa.Integer(), nullable=False),
sa.Column('last_error', sa.Text(), nullable=True),
sa.Column('queued_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('sent_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('resolved_recipients', sa.JSON(), nullable=True),
sa.Column('resolved_attachments', sa.JSON(), nullable=False),
sa.Column('issues_snapshot', 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(['campaign_id'], ['campaigns.id'], name=op.f('fk_campaign_jobs_campaign_id_campaigns'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['campaign_version_id'], ['campaign_versions.id'], name=op.f('fk_campaign_jobs_campaign_version_id_campaign_versions'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['tenant_id'], ['tenancy_tenants.id'], name=op.f('fk_campaign_jobs_tenant_id_tenants'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_campaign_jobs')),
sa.UniqueConstraint('campaign_version_id', 'entry_index', name='uq_campaign_jobs_version_entry')
)
op.create_index(op.f('ix_campaign_jobs_build_status'), 'campaign_jobs', ['build_status'], unique=False)
op.create_index(op.f('ix_campaign_jobs_campaign_id'), 'campaign_jobs', ['campaign_id'], unique=False)
op.create_index(op.f('ix_campaign_jobs_campaign_version_id'), 'campaign_jobs', ['campaign_version_id'], unique=False)
op.create_index(op.f('ix_campaign_jobs_entry_id'), 'campaign_jobs', ['entry_id'], unique=False)
op.create_index(op.f('ix_campaign_jobs_imap_status'), 'campaign_jobs', ['imap_status'], unique=False)
op.create_index(op.f('ix_campaign_jobs_queue_status'), 'campaign_jobs', ['queue_status'], unique=False)
op.create_index(op.f('ix_campaign_jobs_recipient_email'), 'campaign_jobs', ['recipient_email'], unique=False)
op.create_index(op.f('ix_campaign_jobs_send_status'), 'campaign_jobs', ['send_status'], unique=False)
op.create_index(op.f('ix_campaign_jobs_tenant_id'), 'campaign_jobs', ['tenant_id'], unique=False)
op.create_index(op.f('ix_campaign_jobs_validation_status'), 'campaign_jobs', ['validation_status'], unique=False)
op.create_table('campaign_issues',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('campaign_id', sa.String(length=36), nullable=False),
sa.Column('campaign_version_id', sa.String(length=36), nullable=True),
sa.Column('job_id', sa.String(length=36), nullable=True),
sa.Column('severity', sa.String(length=20), nullable=False),
sa.Column('code', sa.String(length=100), nullable=False),
sa.Column('message', sa.Text(), nullable=False),
sa.Column('source', sa.String(length=255), nullable=True),
sa.Column('behavior', sa.String(length=50), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['campaign_id'], ['campaigns.id'], name=op.f('fk_campaign_issues_campaign_id_campaigns'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['campaign_version_id'], ['campaign_versions.id'], name=op.f('fk_campaign_issues_campaign_version_id_campaign_versions'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['job_id'], ['campaign_jobs.id'], name=op.f('fk_campaign_issues_job_id_campaign_jobs'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['tenant_id'], ['tenancy_tenants.id'], name=op.f('fk_campaign_issues_tenant_id_tenants'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_campaign_issues'))
)
op.create_index(op.f('ix_campaign_issues_campaign_id'), 'campaign_issues', ['campaign_id'], unique=False)
op.create_index(op.f('ix_campaign_issues_campaign_version_id'), 'campaign_issues', ['campaign_version_id'], unique=False)
op.create_index(op.f('ix_campaign_issues_code'), 'campaign_issues', ['code'], unique=False)
op.create_index(op.f('ix_campaign_issues_job_id'), 'campaign_issues', ['job_id'], unique=False)
op.create_index(op.f('ix_campaign_issues_severity'), 'campaign_issues', ['severity'], unique=False)
op.create_index(op.f('ix_campaign_issues_tenant_id'), 'campaign_issues', ['tenant_id'], unique=False)
op.create_table('imap_append_attempts',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('job_id', sa.String(length=36), nullable=False),
sa.Column('attempt_number', sa.Integer(), nullable=False),
sa.Column('folder', sa.String(length=500), nullable=True),
sa.Column('status', sa.String(length=50), nullable=False),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['job_id'], ['campaign_jobs.id'], name=op.f('fk_imap_append_attempts_job_id_campaign_jobs'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_imap_append_attempts'))
)
op.create_index(op.f('ix_imap_append_attempts_job_id'), 'imap_append_attempts', ['job_id'], unique=False)
op.create_table('send_attempts',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('job_id', sa.String(length=36), nullable=False),
sa.Column('attempt_number', sa.Integer(), nullable=False),
sa.Column('smtp_status_code', sa.Integer(), nullable=True),
sa.Column('smtp_response', sa.Text(), nullable=True),
sa.Column('error_type', sa.String(length=255), nullable=True),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('started_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['job_id'], ['campaign_jobs.id'], name=op.f('fk_send_attempts_job_id_campaign_jobs'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_send_attempts'))
)
op.create_index(op.f('ix_send_attempts_job_id'), 'send_attempts', ['job_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_send_attempts_job_id'), table_name='send_attempts')
op.drop_table('send_attempts')
op.drop_index(op.f('ix_imap_append_attempts_job_id'), table_name='imap_append_attempts')
op.drop_table('imap_append_attempts')
op.drop_index(op.f('ix_campaign_issues_tenant_id'), table_name='campaign_issues')
op.drop_index(op.f('ix_campaign_issues_severity'), table_name='campaign_issues')
op.drop_index(op.f('ix_campaign_issues_job_id'), table_name='campaign_issues')
op.drop_index(op.f('ix_campaign_issues_code'), table_name='campaign_issues')
op.drop_index(op.f('ix_campaign_issues_campaign_version_id'), table_name='campaign_issues')
op.drop_index(op.f('ix_campaign_issues_campaign_id'), table_name='campaign_issues')
op.drop_table('campaign_issues')
op.drop_index(op.f('ix_campaign_jobs_validation_status'), table_name='campaign_jobs')
op.drop_index(op.f('ix_campaign_jobs_tenant_id'), table_name='campaign_jobs')
op.drop_index(op.f('ix_campaign_jobs_send_status'), table_name='campaign_jobs')
op.drop_index(op.f('ix_campaign_jobs_recipient_email'), table_name='campaign_jobs')
op.drop_index(op.f('ix_campaign_jobs_queue_status'), table_name='campaign_jobs')
op.drop_index(op.f('ix_campaign_jobs_imap_status'), table_name='campaign_jobs')
op.drop_index(op.f('ix_campaign_jobs_entry_id'), table_name='campaign_jobs')
op.drop_index(op.f('ix_campaign_jobs_campaign_version_id'), table_name='campaign_jobs')
op.drop_index(op.f('ix_campaign_jobs_campaign_id'), table_name='campaign_jobs')
op.drop_index(op.f('ix_campaign_jobs_build_status'), table_name='campaign_jobs')
op.drop_table('campaign_jobs')
op.drop_index(op.f('ix_campaign_versions_campaign_id'), table_name='campaign_versions')
op.drop_table('campaign_versions')
op.drop_index(op.f('ix_audit_log_user_id'), table_name='audit_log')
op.drop_index(op.f('ix_audit_log_tenant_id'), table_name='audit_log')
op.drop_index(op.f('ix_audit_log_object_type'), table_name='audit_log')
op.drop_index(op.f('ix_audit_log_object_id'), table_name='audit_log')
op.drop_index(op.f('ix_audit_log_api_key_id'), table_name='audit_log')
op.drop_index(op.f('ix_audit_log_action'), table_name='audit_log')
op.drop_table('audit_log')
op.drop_index(op.f('ix_attachment_instances_tenant_id'), table_name='attachment_instances')
op.drop_index(op.f('ix_attachment_instances_owner_user_id'), table_name='attachment_instances')
op.drop_index(op.f('ix_attachment_instances_campaign_id'), table_name='attachment_instances')
op.drop_index(op.f('ix_attachment_instances_blob_id'), table_name='attachment_instances')
op.drop_table('attachment_instances')
op.drop_index(op.f('ix_campaigns_tenant_id'), table_name='campaigns')
op.drop_index(op.f('ix_campaigns_status'), table_name='campaigns')
op.drop_index(op.f('ix_campaigns_external_id'), table_name='campaigns')
op.drop_index(op.f('ix_campaigns_created_by_user_id'), table_name='campaigns')
op.drop_table('campaigns')
op.drop_index(op.f('ix_access_api_keys_user_id'), table_name='access_api_keys')
op.drop_index(op.f('ix_access_api_keys_tenant_id'), table_name='access_api_keys')
op.drop_index(op.f('ix_access_api_keys_prefix'), table_name='access_api_keys')
op.drop_table('access_api_keys')
op.drop_index(op.f('ix_access_users_tenant_id'), table_name='access_users')
op.drop_index(op.f('ix_access_users_email'), table_name='access_users')
op.drop_table('access_users')
op.drop_index(op.f('ix_access_roles_tenant_id'), table_name='access_roles')
op.drop_table('access_roles')
op.drop_index(op.f('ix_access_groups_tenant_id'), table_name='access_groups')
op.drop_table('access_groups')
op.drop_index(op.f('ix_attachment_blobs_tenant_id'), table_name='attachment_blobs')
op.drop_index(op.f('ix_attachment_blobs_sha256'), table_name='attachment_blobs')
op.drop_table('attachment_blobs')
op.drop_index(op.f('ix_tenancy_tenants_slug'), table_name='tenancy_tenants')
op.drop_table('tenancy_tenants')
# ### end Alembic commands ###

View File

@@ -0,0 +1,42 @@
"""add composite indexes for paginated audit queries
Revision ID: c2d3e4f5a6b7
Revises: b1c2d3e4f5a6
Create Date: 2026-06-15 17:30:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "c2d3e4f5a6b7"
down_revision = "b1c2d3e4f5a6"
branch_labels = None
depends_on = None
INDEXES: tuple[tuple[str, list[str]], ...] = (
("ix_audit_log_scope_created_at", ["scope", "created_at"]),
("ix_audit_log_tenant_scope_created_at", ["tenant_id", "scope", "created_at"]),
)
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if "audit_log" not in inspector.get_table_names():
return
existing = {index["name"] for index in inspector.get_indexes("audit_log")}
for name, columns in INDEXES:
if name not in existing:
op.create_index(name, "audit_log", columns)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if "audit_log" not in inspector.get_table_names():
return
existing = {index["name"] for index in inspector.get_indexes("audit_log")}
for name, _columns in reversed(INDEXES):
if name in existing:
op.drop_index(name, table_name="audit_log")

View File

@@ -0,0 +1,73 @@
"""add encrypted mail profiles and session csrf hashes
Revision ID: d3e4f5a6b7c8
Revises: c2d3e4f5a6b7
Create Date: 2026-06-16 11:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "d3e4f5a6b7c8"
down_revision = "c2d3e4f5a6b7"
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
tables = set(inspector.get_table_names())
if "access_auth_sessions" in tables:
columns = {column["name"] for column in inspector.get_columns("access_auth_sessions")}
if "csrf_token_hash" not in columns:
op.add_column("access_auth_sessions", sa.Column("csrf_token_hash", sa.String(length=128), nullable=True))
if "mail_server_profiles" not in tables:
op.create_table(
"mail_server_profiles",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("slug", sa.String(length=100), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("smtp_config", sa.JSON(), nullable=False),
sa.Column("smtp_password_encrypted", sa.Text(), nullable=True),
sa.Column("imap_config", sa.JSON(), nullable=True),
sa.Column("imap_password_encrypted", sa.Text(), nullable=True),
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
sa.Column("updated_by_user_id", sa.String(length=36), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["created_by_user_id"], ["access_users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["updated_by_user_id"], ["access_users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tenant_id", "slug", name="uq_mail_server_profiles_tenant_slug"),
)
op.create_index(op.f("ix_mail_server_profiles_tenant_id"), "mail_server_profiles", ["tenant_id"])
op.create_index(op.f("ix_mail_server_profiles_is_active"), "mail_server_profiles", ["is_active"])
op.create_index(op.f("ix_mail_server_profiles_created_by_user_id"), "mail_server_profiles", ["created_by_user_id"])
op.create_index(op.f("ix_mail_server_profiles_updated_by_user_id"), "mail_server_profiles", ["updated_by_user_id"])
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if "mail_server_profiles" in inspector.get_table_names():
for name in (
op.f("ix_mail_server_profiles_updated_by_user_id"),
op.f("ix_mail_server_profiles_created_by_user_id"),
op.f("ix_mail_server_profiles_is_active"),
op.f("ix_mail_server_profiles_tenant_id"),
):
try:
op.drop_index(name, table_name="mail_server_profiles")
except Exception:
pass
op.drop_table("mail_server_profiles")
if "access_auth_sessions" in inspector.get_table_names():
columns = {column["name"] for column in inspector.get_columns("access_auth_sessions")}
if "csrf_token_hash" in columns:
op.drop_column("access_auth_sessions", "csrf_token_hash")

View File

@@ -0,0 +1,88 @@
"""add mail profile scope and policy hierarchy
Revision ID: e4f5a6b7c8d9
Revises: d3e4f5a6b7c8
Create Date: 2026-06-16 13:30:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "e4f5a6b7c8d9"
down_revision = "d3e4f5a6b7c8"
branch_labels = None
depends_on = None
_POLICY_DEFAULT = sa.text("'{}'")
def _columns(inspector: sa.Inspector, table_name: str) -> set[str]:
return {column["name"] for column in inspector.get_columns(table_name)}
def _indexes(inspector: sa.Inspector, table_name: str) -> set[str]:
return {index["name"] for index in inspector.get_indexes(table_name)}
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
tables = set(inspector.get_table_names())
for table_name in ("access_users", "access_groups", "campaigns"):
if table_name not in tables:
continue
columns = _columns(inspector, table_name)
if "mail_profile_policy" not in columns:
op.add_column(
table_name,
sa.Column("mail_profile_policy", sa.JSON(), nullable=False, server_default=_POLICY_DEFAULT),
)
if "mail_server_profiles" not in tables:
return
columns = _columns(inspector, "mail_server_profiles")
with op.batch_alter_table("mail_server_profiles") as batch:
if "scope_type" not in columns:
batch.add_column(sa.Column("scope_type", sa.String(length=20), nullable=False, server_default="tenant"))
if "scope_id" not in columns:
batch.add_column(sa.Column("scope_id", sa.String(length=36), nullable=True))
batch.alter_column("tenant_id", existing_type=sa.String(length=36), nullable=True)
op.execute("UPDATE mail_server_profiles SET scope_type = 'tenant' WHERE scope_type IS NULL OR scope_type = ''")
op.execute("UPDATE mail_server_profiles SET scope_id = tenant_id WHERE scope_id IS NULL AND tenant_id IS NOT NULL")
inspector = sa.inspect(bind)
indexes = _indexes(inspector, "mail_server_profiles")
if "ix_mail_server_profiles_scope_type" not in indexes:
op.create_index(op.f("ix_mail_server_profiles_scope_type"), "mail_server_profiles", ["scope_type"])
if "ix_mail_server_profiles_scope_id" not in indexes:
op.create_index(op.f("ix_mail_server_profiles_scope_id"), "mail_server_profiles", ["scope_id"])
if "ix_mail_server_profiles_scope" not in indexes:
op.create_index("ix_mail_server_profiles_scope", "mail_server_profiles", ["scope_type", "scope_id"])
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
tables = set(inspector.get_table_names())
if "mail_server_profiles" in tables:
indexes = _indexes(inspector, "mail_server_profiles")
for name in ("ix_mail_server_profiles_scope", op.f("ix_mail_server_profiles_scope_id"), op.f("ix_mail_server_profiles_scope_type")):
if name in indexes:
op.drop_index(name, table_name="mail_server_profiles")
columns = _columns(inspector, "mail_server_profiles")
with op.batch_alter_table("mail_server_profiles") as batch:
if "scope_id" in columns:
batch.drop_column("scope_id")
if "scope_type" in columns:
batch.drop_column("scope_type")
batch.alter_column("tenant_id", existing_type=sa.String(length=36), nullable=False)
for table_name in ("campaigns", "access_groups", "access_users"):
if table_name in tables and "mail_profile_policy" in _columns(inspector, table_name):
op.drop_column(table_name, "mail_profile_policy")

View File

@@ -0,0 +1,41 @@
"""add generic settings hierarchy columns
Revision ID: f5a6b7c8d9e0
Revises: e4f5a6b7c8d9
Create Date: 2026-06-16 15:15:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "f5a6b7c8d9e0"
down_revision = "e4f5a6b7c8d9"
branch_labels = None
depends_on = None
_SETTINGS_DEFAULT = sa.text("'{}'")
def _columns(inspector: sa.Inspector, table_name: str) -> set[str]:
return {column["name"] for column in inspector.get_columns(table_name)}
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
tables = set(inspector.get_table_names())
for table_name in ("access_users", "access_groups", "campaigns"):
if table_name not in tables:
continue
if "settings" not in _columns(inspector, table_name):
op.add_column(table_name, sa.Column("settings", sa.JSON(), nullable=False, server_default=_SETTINGS_DEFAULT))
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
tables = set(inspector.get_table_names())
for table_name in ("campaigns", "access_groups", "access_users"):
if table_name in tables and "settings" in _columns(inspector, table_name):
op.drop_column(table_name, "settings")