119 lines
5.1 KiB
Python
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")
|