"""services phase s1: categories, recurrence, sort order, applicability flags Revision ID: 20260422_services_phase_s1 Revises: 20260420_services_catalogue_firm_toggle Create Date: 2026-04-22 20:00:00.000000 """ from __future__ import annotations from alembic import op import sqlalchemy as sa revision = "20260422_services_phase_s1" down_revision = "20260420_services_catalogue_firm_toggle" branch_labels = None depends_on = None def _inspector(): return sa.inspect(op.get_bind()) def _has_table(name: str) -> bool: return name in _inspector().get_table_names() def _has_column(table_name: str, column_name: str) -> bool: try: cols = _inspector().get_columns(table_name) except Exception: return False return any(col.get("name") == column_name for col in cols) def _has_index(table_name: str, index_name: str) -> bool: try: indexes = _inspector().get_indexes(table_name) except Exception: return False return any(ix.get("name") == index_name for ix in indexes) def _create_index_if_missing(table_name: str, index_name: str, columns, unique: bool = False) -> None: if not _has_index(table_name, index_name): op.create_index(index_name, table_name, columns, unique=unique) def _normalize_code(name: str) -> str: text = (name or "").strip().upper() out = [] last_sep = False for ch in text: if ch.isalnum(): out.append(ch) last_sep = False else: if not last_sep: out.append("_") last_sep = True code = "".join(out).strip("_") return code or "UNCATEGORIZED" def upgrade(): conn = op.get_bind() if not _has_table("service_categories"): op.create_table( "service_categories", sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False), sa.Column("code", sa.String(length=50), nullable=True), sa.Column("name", sa.String(length=100), 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()), ) if _has_table("service_categories"): if not _has_column("service_categories", "code"): op.add_column("service_categories", sa.Column("code", sa.String(length=50), nullable=True)) if not _has_column("service_categories", "name"): op.add_column("service_categories", sa.Column("name", sa.String(length=100), nullable=True)) if not _has_column("service_categories", "sort_order"): op.add_column("service_categories", sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0")) if not _has_column("service_categories", "is_active"): op.add_column("service_categories", sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true())) conn.execute(sa.text( "UPDATE service_categories SET name = COALESCE(name, 'Category ' || id) WHERE name IS NULL" )) rows = conn.execute(sa.text( "SELECT id, name FROM service_categories WHERE (code IS NULL OR TRIM(code) = '')" )).fetchall() used_codes = set() existing_code_rows = conn.execute(sa.text( "SELECT code FROM service_categories WHERE code IS NOT NULL AND TRIM(code) <> ''" )).fetchall() for row in existing_code_rows: used_codes.add(row[0]) for row in rows: category_id = row[0] name = row[1] or f"Category {category_id}" base_code = _normalize_code(name) code = base_code n = 2 while code in used_codes: code = f"{base_code}_{n}" n += 1 used_codes.add(code) conn.execute( sa.text("UPDATE service_categories SET code = :code, name = COALESCE(name, :name) WHERE id = :id"), {"code": code, "name": name, "id": category_id}, ) conn.execute(sa.text("UPDATE service_categories SET sort_order = 0 WHERE sort_order IS NULL")) conn.execute(sa.text("UPDATE service_categories SET is_active = TRUE WHERE is_active IS NULL")) _create_index_if_missing("service_categories", "ix_service_categories_code", ["code"]) _create_index_if_missing("service_categories", "ix_service_categories_name", ["name"]) _create_index_if_missing("service_categories", "ix_service_categories_sort_order", ["sort_order"]) if _has_table("service_catalogues"): additions = [ ("category_id", sa.Integer(), True, None), ("recurrence_type", sa.String(length=50), True, None), ("sort_order", sa.Integer(), False, "0"), ("applicable_individual", sa.Boolean(), False, sa.false()), ("applicable_proprietorship", sa.Boolean(), False, sa.false()), ("applicable_partnership", sa.Boolean(), False, sa.false()), ("applicable_llp", sa.Boolean(), False, sa.false()), ("applicable_company", sa.Boolean(), False, sa.false()), ("applicable_trust", sa.Boolean(), False, sa.false()), ("applicable_society", sa.Boolean(), False, sa.false()), ] for name, typ, nullable, default in additions: if not _has_column("service_catalogues", name): kwargs = {"nullable": nullable} if default is not None: kwargs["server_default"] = default op.add_column("service_catalogues", sa.Column(name, typ, **kwargs)) conn.execute(sa.text("UPDATE service_catalogues SET sort_order = 0 WHERE sort_order IS NULL")) for flag in [ "applicable_individual", "applicable_proprietorship", "applicable_partnership", "applicable_llp", "applicable_company", "applicable_trust", "applicable_society", ]: if _has_column("service_catalogues", flag): conn.execute(sa.text(f"UPDATE service_catalogues SET {flag} = FALSE WHERE {flag} IS NULL")) _create_index_if_missing("service_catalogues", "ix_service_catalogues_category_id", ["category_id"]) _create_index_if_missing("service_catalogues", "ix_service_catalogues_recurrence_type", ["recurrence_type"]) _create_index_if_missing("service_catalogues", "ix_service_catalogues_sort_order", ["sort_order"]) if _has_column("service_catalogues", "category"): rows = conn.execute(sa.text( """ SELECT DISTINCT TRIM(category) AS category FROM service_catalogues WHERE category IS NOT NULL AND TRIM(category) <> '' ORDER BY TRIM(category) """ )).fetchall() next_sort = conn.execute(sa.text("SELECT COALESCE(MAX(sort_order), 0) FROM service_categories")).scalar() or 0 for row in rows: category_name = row[0] existing = conn.execute( sa.text("SELECT id FROM service_categories WHERE name = :name ORDER BY id LIMIT 1"), {"name": category_name}, ).fetchone() if not existing: base_code = _normalize_code(category_name) code = base_code n = 2 while conn.execute( sa.text("SELECT 1 FROM service_categories WHERE code = :code"), {"code": code}, ).fetchone(): code = f"{base_code}_{n}" n += 1 next_sort += 1 conn.execute( sa.text( """ INSERT INTO service_categories (code, name, sort_order, is_active) VALUES (:code, :name, :sort_order, TRUE) """ ), {"code": code, "name": category_name, "sort_order": next_sort}, ) if _has_column("service_catalogues", "category_id"): rows = conn.execute(sa.text( """ SELECT id, category FROM service_catalogues WHERE category_id IS NULL AND category IS NOT NULL AND TRIM(category) <> '' """ )).fetchall() for row in rows: catalogue_id = row[0] category_name = row[1].strip() match = conn.execute( sa.text("SELECT id FROM service_categories WHERE name = :name ORDER BY id LIMIT 1"), {"name": category_name}, ).fetchone() if match: conn.execute( sa.text("UPDATE service_catalogues SET category_id = :category_id WHERE id = :catalogue_id"), {"category_id": match[0], "catalogue_id": catalogue_id}, ) def downgrade(): pass