Files
saas_backend/alembic/versions/d4a7c2f8b51e_master_data.py
T
2026-08-31 20:39:41 -04:00

119 lines
5.1 KiB
Python

"""Reference lists — the things dropdowns are made of.
## The assumption, stated because it is a product decision
**One generic pair of tables rather than a table per list.** Countries,
currencies, document types, leave types, cost centres — each could be its own
table with its own columns, and each would then need its own migration, its own
CRUD, its own screen and its own permission. A platform meant to host modules
would grow one per module.
The cost of the generic shape is that a list cannot carry list-specific columns.
Where one genuinely needs them — a currency's decimal places, a country's dialling
code — that list graduates to its own table, and this stops being where it lives.
`metadata` exists so the common cases do not have to.
## The split that makes it multi-tenant
A list is either **the platform's** or **a workspace's**, and the difference
matters in both directions:
- Platform lists (`tenant_id IS NULL`) are visible to every workspace and
editable by none of them. ISO currency codes are not a customer's to change,
and a workspace that renamed one would break every other workspace if these
were shared and writable.
- A workspace's own lists are invisible to everyone else. "Cost centre" means
something different at every customer.
A workspace **can** add items to a platform list without being able to edit the
platform's own — an item carries its own `tenant_id`, so "the standard list plus
ours" is the ordinary case rather than a special one.
Revision ID: d4a7c2f8b51e
Revises: c8f1b4e7a03d
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import JSONB, UUID
revision: str = "d4a7c2f8b51e"
down_revision: Union[str, Sequence[str], None] = "c8f1b4e7a03d"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"lookup_lists",
sa.Column("id", UUID(as_uuid=True), primary_key=True,
server_default=sa.text("gen_random_uuid()")),
sa.Column("tenant_id", UUID(as_uuid=True),
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True),
sa.Column("code", sa.String(60), nullable=False),
sa.Column("name", sa.String(150), nullable=False),
sa.Column("description", sa.String(500), nullable=True),
sa.Column("allows_custom_items", sa.Boolean(), nullable=False,
server_default=sa.true()),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now()),
)
op.execute(
"CREATE UNIQUE INDEX uq_lookup_lists_code "
"ON lookup_lists (tenant_id, code) NULLS NOT DISTINCT"
)
op.create_table(
"lookup_items",
sa.Column("id", UUID(as_uuid=True), primary_key=True,
server_default=sa.text("gen_random_uuid()")),
sa.Column("tenant_id", UUID(as_uuid=True),
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True),
sa.Column("list_id", UUID(as_uuid=True),
sa.ForeignKey("lookup_lists.id", ondelete="CASCADE"),
nullable=False),
sa.Column("code", sa.String(60), nullable=False),
sa.Column("label", sa.String(200), nullable=False),
sa.Column("metadata_json", JSONB(), nullable=True),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now()),
)
op.execute(
"CREATE UNIQUE INDEX uq_lookup_items_code "
"ON lookup_items (list_id, tenant_id, code) NULLS NOT DISTINCT"
)
op.create_index("ix_lookup_items_list", "lookup_items", ["list_id", "sort_order"])
for table in ("lookup_lists", "lookup_items"):
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
op.execute(
f"""
CREATE POLICY tenant_isolation ON {table}
USING (
current_setting('app.bypass_rls', true) = 'on'
OR tenant_id IS NULL
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
)
WITH CHECK (
current_setting('app.bypass_rls', true) = 'on'
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
)
"""
)
def downgrade() -> None:
for table in ("lookup_items", "lookup_lists"):
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table}")
op.drop_index("ix_lookup_items_list", table_name="lookup_items")
op.execute("DROP INDEX IF EXISTS uq_lookup_items_code")
op.drop_table("lookup_items")
op.execute("DROP INDEX IF EXISTS uq_lookup_lists_code")
op.drop_table("lookup_lists")