initial commit after split

This commit is contained in:
2026-06-24 01:43:10 +02:00
parent b1d6c0150f
commit 30c11a6dcf
173 changed files with 25380 additions and 0 deletions

61
alembic/env.py Normal file
View File

@@ -0,0 +1,61 @@
from __future__ import annotations
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from govoplan_core.access.db import models as access_models # noqa: F401 - populate access metadata
from govoplan_core.core.migrations import migration_metadata_plan
from govoplan_core.db.base import Base
from govoplan_core.db import models # noqa: F401 - populate core metadata
from govoplan_core.server.default_config import get_server_config
from govoplan_core.server.registry import build_platform_registry
from govoplan_core.settings import settings
config = context.config
database_url = config.attributes.get("database_url") or settings.database_url
config.set_main_option("sqlalchemy.url", database_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
def _target_metadata():
server_config = get_server_config()
registry = build_platform_registry(
server_config.enabled_modules,
manifest_factories=server_config.manifest_factories,
)
plan = migration_metadata_plan(registry, extra_metadata=(Base.metadata,))
return tuple(dict.fromkeys(plan.metadata))
target_metadata = _target_metadata()
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"})
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

24
alembic/script.py.mako Normal file
View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "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"),
"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("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(
"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"], ["tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["group_id"], ["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_user_group_memberships_tenant_id"), "user_group_memberships", ["tenant_id"])
op.create_index(op.f("ix_user_group_memberships_user_id"), "user_group_memberships", ["user_id"])
op.create_index(op.f("ix_user_group_memberships_group_id"), "user_group_memberships", ["group_id"])
op.create_table(
"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"], ["tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["role_id"], ["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_user_role_assignments_tenant_id"), "user_role_assignments", ["tenant_id"])
op.create_index(op.f("ix_user_role_assignments_user_id"), "user_role_assignments", ["user_id"])
op.create_index(op.f("ix_user_role_assignments_role_id"), "user_role_assignments", ["role_id"])
op.create_table(
"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"], ["tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["group_id"], ["groups.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["role_id"], ["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_group_role_assignments_tenant_id"), "group_role_assignments", ["tenant_id"])
op.create_index(op.f("ix_group_role_assignments_group_id"), "group_role_assignments", ["group_id"])
op.create_index(op.f("ix_group_role_assignments_role_id"), "group_role_assignments", ["role_id"])
op.create_table(
"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"], ["tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("token_hash"),
)
op.create_index(op.f("ix_auth_sessions_tenant_id"), "auth_sessions", ["tenant_id"])
op.create_index(op.f("ix_auth_sessions_user_id"), "auth_sessions", ["user_id"])
op.create_index(op.f("ix_auth_sessions_token_hash"), "auth_sessions", ["token_hash"])
op.create_index(op.f("ix_auth_sessions_expires_at"), "auth_sessions", ["expires_at"])
op.create_index(op.f("ix_auth_sessions_revoked_at"), "auth_sessions", ["revoked_at"])
def downgrade() -> None:
op.drop_index(op.f("ix_auth_sessions_revoked_at"), table_name="auth_sessions")
op.drop_index(op.f("ix_auth_sessions_expires_at"), table_name="auth_sessions")
op.drop_index(op.f("ix_auth_sessions_token_hash"), table_name="auth_sessions")
op.drop_index(op.f("ix_auth_sessions_user_id"), table_name="auth_sessions")
op.drop_index(op.f("ix_auth_sessions_tenant_id"), table_name="auth_sessions")
op.drop_table("auth_sessions")
op.drop_index(op.f("ix_group_role_assignments_role_id"), table_name="group_role_assignments")
op.drop_index(op.f("ix_group_role_assignments_group_id"), table_name="group_role_assignments")
op.drop_index(op.f("ix_group_role_assignments_tenant_id"), table_name="group_role_assignments")
op.drop_table("group_role_assignments")
op.drop_index(op.f("ix_user_role_assignments_role_id"), table_name="user_role_assignments")
op.drop_index(op.f("ix_user_role_assignments_user_id"), table_name="user_role_assignments")
op.drop_index(op.f("ix_user_role_assignments_tenant_id"), table_name="user_role_assignments")
op.drop_table("user_role_assignments")
op.drop_index(op.f("ix_user_group_memberships_group_id"), table_name="user_group_memberships")
op.drop_index(op.f("ix_user_group_memberships_user_id"), table_name="user_group_memberships")
op.drop_index(op.f("ix_user_group_memberships_tenant_id"), table_name="user_group_memberships")
op.drop_table("user_group_memberships")
with op.batch_alter_table("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,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"], ["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"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["owner_group_id"], ["groups.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["owner_user_id"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["tenant_id"], ["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"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["file_asset_id"], ["file_assets.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["tenant_id"], ["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"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["file_asset_id"], ["file_assets.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["tenant_id"], ["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"], ["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,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"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["owner_group_id"], ["groups.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["owner_user_id"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["tenant_id"], ["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,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",
"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("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 "system_role_assignments" in tables:
count = bind.execute(sa.text("SELECT COUNT(*) FROM system_role_assignments")).scalar_one()
if count:
raise RuntimeError("Cannot reconcile non-empty create_all system_role_assignments table")
op.drop_table("system_role_assignments")
tables.remove("system_role_assignments")
if "account_id" not in user_columns and "accounts" in tables:
count = bind.execute(sa.text("SELECT COUNT(*) FROM accounts")).scalar_one()
if count:
raise RuntimeError("Cannot reconcile non-empty create_all accounts table")
op.drop_table("accounts")
op.create_table(
"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_accounts_normalized_email"), "accounts", ["normalized_email"])
with op.batch_alter_table("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("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("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("users") as batch_op:
batch_op.add_column(sa.Column("account_id", sa.String(length=36), nullable=True))
with op.batch_alter_table("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 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(
"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 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 auth_sessions SET account_id = (SELECT users.account_id FROM users WHERE users.id = auth_sessions.user_id)"
))
with op.batch_alter_table("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", "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_users_account_id"), "users", ["account_id"])
with op.batch_alter_table("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", "accounts", ["account_id"], ["id"], ondelete="CASCADE")
op.create_index(op.f("ix_auth_sessions_account_id"), "auth_sessions", ["account_id"])
op.create_table(
"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"], ["accounts.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["role_id"], ["roles.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("account_id", "role_id", name="uq_system_role_assignments"),
)
op.create_index(op.f("ix_system_role_assignments_account_id"), "system_role_assignments", ["account_id"])
op.create_index(op.f("ix_system_role_assignments_role_id"), "system_role_assignments", ["role_id"])
roles_table = sa.table(
"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 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 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",
"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 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 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 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="roles")
op.drop_index(op.f("ix_system_role_assignments_role_id"), table_name="system_role_assignments")
op.drop_index(op.f("ix_system_role_assignments_account_id"), table_name="system_role_assignments")
op.drop_table("system_role_assignments")
op.drop_index(op.f("ix_auth_sessions_account_id"), table_name="auth_sessions")
with op.batch_alter_table("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_users_account_id"), table_name="users")
with op.batch_alter_table("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("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("groups") as batch_op:
batch_op.drop_column("is_active")
batch_op.drop_column("description")
with op.batch_alter_table("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_accounts_normalized_email"), table_name="accounts")
op.drop_table("accounts")

View File

@@ -0,0 +1,163 @@
"""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 ("governance_template_assignments", "governance_templates", "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(
"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 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', 1, 1, 1, '{}', :created_at, :updated_at)
"""
), {"created_at": now, "updated_at": now})
op.create_table(
"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_governance_templates_kind"), "governance_templates", ["kind"])
op.create_table(
"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"], ["governance_templates.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("template_id", "tenant_id", name="uq_governance_template_tenant"),
)
op.create_index(op.f("ix_governance_template_assignments_template_id"), "governance_template_assignments", ["template_id"])
op.create_index(op.f("ix_governance_template_assignments_tenant_id"), "governance_template_assignments", ["tenant_id"])
with op.batch_alter_table("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("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",
"governance_templates", ["system_template_id"], ["id"], ondelete="SET NULL",
)
op.create_index(op.f("ix_groups_system_template_id"), "groups", ["system_template_id"])
with op.batch_alter_table("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",
"governance_templates", ["system_template_id"], ["id"], ondelete="SET NULL",
)
op.create_index(op.f("ix_roles_system_template_id"), "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 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(
"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_roles_system_template_id"), table_name="roles")
with op.batch_alter_table("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_groups_system_template_id"), table_name="groups")
with op.batch_alter_table("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("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_governance_template_assignments_tenant_id"), table_name="governance_template_assignments")
op.drop_index(op.f("ix_governance_template_assignments_template_id"), table_name="governance_template_assignments")
op.drop_table("governance_template_assignments")
op.drop_index(op.f("ix_governance_templates_kind"), table_name="governance_templates")
op.drop_table("governance_templates")
op.drop_table("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"], ["tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["campaign_id"], ["campaigns.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["created_by_user_id"], ["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 "roles" not in tables:
return
rows = bind.execute(sa.text("SELECT id, permissions FROM roles")).mappings().all()
for row in rows:
bind.execute(
sa.text("UPDATE 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 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 roles WHERE tenant_id IS NULL AND slug = :slug"), {"slug": slug}
).first()
is_protected = slug == "system_owner"
if existing:
bind.execute(
sa.text(
"UPDATE 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 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('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_tenants_slug'), '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'], ['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('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'], ['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_groups_tenant_id'), 'groups', ['tenant_id'], unique=False)
op.create_table('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'], ['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_roles_tenant_id'), 'roles', ['tenant_id'], unique=False)
op.create_table('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'], ['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_users_email'), 'users', ['email'], unique=False)
op.create_index(op.f('ix_users_tenant_id'), 'users', ['tenant_id'], unique=False)
op.create_table('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'], ['tenants.id'], name=op.f('fk_api_keys_tenant_id_tenants'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['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_api_keys_prefix'), 'api_keys', ['prefix'], unique=False)
op.create_index(op.f('ix_api_keys_tenant_id'), 'api_keys', ['tenant_id'], unique=False)
op.create_index(op.f('ix_api_keys_user_id'), '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'], ['users.id'], name=op.f('fk_campaigns_created_by_user_id_users'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['tenant_id'], ['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'], ['users.id'], name=op.f('fk_attachment_instances_owner_user_id_users'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['tenant_id'], ['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'], ['api_keys.id'], name=op.f('fk_audit_log_api_key_id_api_keys'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name=op.f('fk_audit_log_tenant_id_tenants'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['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'], ['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'], ['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_api_keys_user_id'), table_name='api_keys')
op.drop_index(op.f('ix_api_keys_tenant_id'), table_name='api_keys')
op.drop_index(op.f('ix_api_keys_prefix'), table_name='api_keys')
op.drop_table('api_keys')
op.drop_index(op.f('ix_users_tenant_id'), table_name='users')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
op.drop_index(op.f('ix_roles_tenant_id'), table_name='roles')
op.drop_table('roles')
op.drop_index(op.f('ix_groups_tenant_id'), table_name='groups')
op.drop_table('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_tenants_slug'), table_name='tenants')
op.drop_table('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 "auth_sessions" in tables:
columns = {column["name"] for column in inspector.get_columns("auth_sessions")}
if "csrf_token_hash" not in columns:
op.add_column("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"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["updated_by_user_id"], ["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 "auth_sessions" in inspector.get_table_names():
columns = {column["name"] for column in inspector.get_columns("auth_sessions")}
if "csrf_token_hash" in columns:
op.drop_column("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 ("users", "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", "groups", "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 ("users", "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", "groups", "users"):
if table_name in tables and "settings" in _columns(inspector, table_name):
op.drop_column(table_name, "settings")