initial commit after split
This commit is contained in:
61
alembic/env.py
Normal file
61
alembic/env.py
Normal 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
24
alembic/script.py.mako
Normal 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"}
|
||||
58
alembic/versions/1f8d4c2a0b7e_editable_campaign_versions.py
Normal file
58
alembic/versions/1f8d4c2a0b7e_editable_campaign_versions.py
Normal 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")
|
||||
130
alembic/versions/2c3d4e5f6a7b_auth_sessions_and_rbac.py
Normal file
130
alembic/versions/2c3d4e5f6a7b_auth_sessions_and_rbac.py
Normal 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")
|
||||
152
alembic/versions/3d4e5f6a7b8c_file_storage_backend.py
Normal file
152
alembic/versions/3d4e5f6a7b8c_file_storage_backend.py
Normal 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")
|
||||
45
alembic/versions/4e5f6a7b8c9d_file_folders.py
Normal file
45
alembic/versions/4e5f6a7b8c9d_file_folders.py
Normal 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")
|
||||
55
alembic/versions/5f6a7b8c9d0e_campaign_version_user_locks.py
Normal file
55
alembic/versions/5f6a7b8c9d0e_campaign_version_user_locks.py
Normal 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")
|
||||
73
alembic/versions/6a7b8c9d0e1f_file_folder_uniqueness.py
Normal file
73
alembic/versions/6a7b8c9d0e1f_file_folder_uniqueness.py
Normal 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")
|
||||
64
alembic/versions/7b8c9d0e1f2a_safe_delivery_execution.py
Normal file
64
alembic/versions/7b8c9d0e1f2a_safe_delivery_execution.py
Normal 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")
|
||||
323
alembic/versions/8c9d0e1f2a3b_accounts_tenant_admin_rbac.py
Normal file
323
alembic/versions/8c9d0e1f2a3b_accounts_tenant_admin_rbac.py
Normal 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")
|
||||
163
alembic/versions/9d0e1f2a3b4c_system_governance_templates.py
Normal file
163
alembic/versions/9d0e1f2a3b4c_system_governance_templates.py
Normal 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")
|
||||
216
alembic/versions/a0b1c2d3e4f5_permission_catalogue_refinement.py
Normal file
216
alembic/versions/a0b1c2d3e4f5_permission_catalogue_refinement.py
Normal 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
|
||||
@@ -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")
|
||||
346
alembic/versions/b57c5b216bce_initial_persistence_models.py
Normal file
346
alembic/versions/b57c5b216bce_initial_persistence_models.py
Normal 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 ###
|
||||
42
alembic/versions/c2d3e4f5a6b7_audit_pagination_indexes.py
Normal file
42
alembic/versions/c2d3e4f5a6b7_audit_pagination_indexes.py
Normal 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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
41
alembic/versions/f5a6b7c8d9e0_hierarchical_settings.py
Normal file
41
alembic/versions/f5a6b7c8d9e0_hierarchical_settings.py
Normal 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")
|
||||
Reference in New Issue
Block a user