"""the navigation tree: categories and subcategories
Two tables, both org-scoped, both ordered by a fractional rank so a reorder is
one UPDATE rather than a renumbering of the list.
`subcategories.category_id` is nullable and carries no NOT NULL default on
purpose: null is what "ungrouped" means, so there is no row to seed and nothing
to backfill. An organisation that never makes a category has an empty tree, which
is the correct empty state rather than a special case.
Revision ID: 0003
Revises: 0002
Create Date: 2026-01-03
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "0003"
down_revision = "0002"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"categories",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("org_id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=60), nullable=False),
sa.Column("rank", sa.Float(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(),
nullable=False),
sa.ForeignKeyConstraint(["org_id"], ["organizations.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("org_id", "name", name="uq_category_name_per_org"),
)
op.create_index("ix_categories_org_id", "categories", ["org_id"])
op.create_table(
"subcategories",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("org_id", sa.Integer(), nullable=False),
sa.Column("category_id", sa.Integer(), nullable=True),
sa.Column("name", sa.String(length=60), nullable=False),
sa.Column("slug", sa.String(length=60), nullable=False),
sa.Column("nav_rank", sa.Float(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(),
nullable=False),
sa.ForeignKeyConstraint(["org_id"], ["organizations.id"], ondelete="CASCADE"),
# SET NULL, not CASCADE: deleting a heading must not delete what was
# filed under it. app.core.categories.delete_category clears the column
# explicitly as well, because SQLite enforces no foreign key unless
# asked - so relying on this alone would behave one way in the test
# database and another in Postgres.
sa.ForeignKeyConstraint(["category_id"], ["categories.id"],
ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("org_id", "slug", name="uq_subcategory_slug_per_org"),
)
op.create_index("ix_subcategories_org_id", "subcategories", ["org_id"])
op.create_index("ix_subcategories_category_id", "subcategories", ["category_id"])
def downgrade() -> None:
op.drop_index("ix_subcategories_category_id", table_name="subcategories")
op.drop_index("ix_subcategories_org_id", table_name="subcategories")
op.drop_table("subcategories")
op.drop_index("ix_categories_org_id", table_name="categories")
op.drop_table("categories")