"""Documents Generator tables (Phase L1): letterheads, print_templates, document_assets. Revision ID: 0143 Revises: 0142 Create Date: 2026-08-29 Dual-path convergence (Gate B): on plugin-first installs the report_generator plugin migration 0003 has already created these tables — skip instead of failing. Both paths converge to the identical schema (see app/plugins/builtins/report_generator/migrations/0003_documents_generator.sql). """ import sqlalchemy as sa from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import UUID as PGUUID from alembic import op revision = "0143" down_revision = "0142" branch_labels = None depends_on = None def _table_exists(conn, table_name: str) -> bool: row = conn.execute( sa.text("SELECT to_regclass(:tname) IS NOT NULL"), {"tname": f"public.{table_name}"}, ).scalar() return bool(row) def _rls(table: str) -> None: op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY") op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY") op.execute(f"DROP POLICY IF EXISTS {table}_tenant_isolation ON {table}") op.execute( f"CREATE POLICY {table}_tenant_isolation ON {table} AS PERMISSIVE " f"FOR ALL TO crm_api " f"USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid) " f"WITH CHECK (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid)" ) def upgrade() -> None: conn = op.get_bind() if _table_exists(conn, "letterheads"): return op.create_table( "letterheads", sa.Column("id", PGUUID(as_uuid=True), primary_key=True), sa.Column("name", sa.String(255), nullable=False), sa.Column("description", sa.Text(), nullable=False, server_default=""), sa.Column("config", JSONB(), nullable=False, server_default=sa.text("'{}'::jsonb")), sa.Column("is_default", sa.Boolean(), nullable=False, server_default=sa.false()), sa.Column("tenant_id", PGUUID(as_uuid=True), nullable=False), sa.Column("owner_id", PGUUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), sa.Column("deleted_at", sa.DateTime(timezone=True)), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), sa.Column("created_by", PGUUID(as_uuid=True), nullable=False), ) op.create_index("ix_letterheads_tenant", "letterheads", ["tenant_id"]) op.create_index("ix_letterheads_name", "letterheads", ["name"]) op.create_table( "print_templates", sa.Column("id", PGUUID(as_uuid=True), primary_key=True), sa.Column("name", sa.String(255), nullable=False), sa.Column("description", sa.Text(), nullable=False, server_default=""), sa.Column("letterhead_id", PGUUID(as_uuid=True), sa.ForeignKey("letterheads.id", ondelete="SET NULL"), nullable=True), sa.Column("entity_type", sa.String(100), nullable=False, server_default="contact"), sa.Column("blocks", JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")), sa.Column("output_format", sa.String(20), nullable=False, server_default="pdf"), sa.Column("tenant_id", PGUUID(as_uuid=True), nullable=False), sa.Column("owner_id", PGUUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), sa.Column("deleted_at", sa.DateTime(timezone=True)), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), sa.Column("created_by", PGUUID(as_uuid=True), nullable=False), ) op.create_index("ix_print_templates_tenant", "print_templates", ["tenant_id"]) op.create_index("ix_print_templates_name", "print_templates", ["name"]) op.create_table( "document_assets", sa.Column("id", PGUUID(as_uuid=True), primary_key=True), sa.Column("letterhead_id", PGUUID(as_uuid=True), sa.ForeignKey("letterheads.id", ondelete="CASCADE"), nullable=True), sa.Column("filename", sa.String(255), nullable=False), sa.Column("mime_type", sa.String(100), nullable=False), sa.Column("size_bytes", sa.Integer(), nullable=False, server_default="0"), sa.Column("storage_path", sa.String(1024), nullable=False), sa.Column("tenant_id", PGUUID(as_uuid=True), nullable=False), sa.Column("owner_id", PGUUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), sa.Column("deleted_at", sa.DateTime(timezone=True)), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), sa.Column("created_by", PGUUID(as_uuid=True), nullable=False), ) op.create_index("ix_document_assets_tenant", "document_assets", ["tenant_id"]) op.create_index("ix_document_assets_letterhead", "document_assets", ["letterhead_id"]) for table in ("letterheads", "print_templates", "document_assets"): _rls(table) def downgrade() -> None: conn = op.get_bind() if not _table_exists(conn, "letterheads"): return for table in ("document_assets", "print_templates", "letterheads"): op.execute(f"DROP POLICY IF EXISTS {table}_tenant_isolation ON {table}") op.drop_table(table)