"""M2 工单域:ticket_ext / item_link / notification + 扩宽 item.status 取值。 三个需要注意的点: 1. **item 的 status CHECK 必须重写**。0002 里只允许任务的六个状态, 工单的 `open/accepted/in_progress/pending_verify` 会直接被约束拦下。 这里改成任务 ∪ 工单的并集,而不是按 kind 做条件约束——PostgreSQL 的 CHECK 不能引用其它行, 而"任务不该有工单状态"这件事由状态机(`app/domain/*.py`)保证,不靠数据库。 2. **新表同样开 RLS**。通知是"谁的"、工单附加信息是"哪个租户的",漏一张就等于开了一个洞。 3. **downgrade 会删掉所有工单数据**(kind='ticket' 的 item 及其扩展)。这是有意的: 旧约束容纳不下工单状态,不回删就无法回到 0002。已在函数注释里写明。 """ from __future__ import annotations from collections.abc import Sequence import sqlalchemy as sa from alembic import op from sqlalchemy.dialects import postgresql revision: str = "0003" down_revision: str | None = "0002" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None APP_ROLE = "dadao_app" TENANT_TABLES_0003: tuple[str, ...] = ("ticket_ext", "item_link", "notification") TASK_STATUSES = ("inbox", "todo", "doing", "done", "blocked", "canceled") TICKET_STATUSES = ("open", "accepted", "in_progress", "pending_verify", "done", "canceled") def _ws_fk() -> sa.Column: return sa.Column( "workspace_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False, ) def _user_fk(name: str, *, ondelete: str, nullable: bool = False) -> sa.Column: return sa.Column( name, postgresql.UUID(as_uuid=True), sa.ForeignKey("app_user.id", ondelete=ondelete), nullable=nullable, ) def _item_fk(name: str, *, ondelete: str, nullable: bool = False) -> sa.Column: return sa.Column( name, postgresql.UUID(as_uuid=True), sa.ForeignKey("item.id", ondelete=ondelete), nullable=nullable, ) def upgrade() -> None: # 1) 放宽 item.status:任务 ∪ 工单 op.execute("ALTER TABLE item DROP CONSTRAINT IF EXISTS ck_item_status") statuses = ",".join(f"'{s}'" for s in (*TASK_STATUSES, *TICKET_STATUSES)) op.execute( f"ALTER TABLE item ADD CONSTRAINT ck_item_status CHECK (status IN ({statuses}))" ) # 2) 工单扩展:item_id 既是主键也是外键,天然 1:1 op.create_table( "ticket_ext", sa.Column( "item_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("item.id", ondelete="CASCADE"), primary_key=True, ), _ws_fk(), _user_fk("requester_id", ondelete="RESTRICT"), _user_fk("assignee_id", ondelete="RESTRICT"), _item_fk("parent_item_id", ondelete="SET NULL", nullable=True), sa.Column("accepted_at", sa.DateTime(timezone=True)), sa.Column("submitted_at", sa.DateTime(timezone=True)), sa.Column("verified_at", sa.DateTime(timezone=True)), _user_fk("verified_by", ondelete="SET NULL", nullable=True), sa.Column("reject_count", sa.Integer, nullable=False, server_default="0"), sa.Column("resolution_md", sa.Text, nullable=False, server_default=""), 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("version", sa.Integer, nullable=False, server_default="1"), sa.CheckConstraint("reject_count >= 0", name="ck_ticket_reject_count"), ) op.create_index("ix_ticket_ws_assignee", "ticket_ext", ["workspace_id", "assignee_id"]) op.create_index("ix_ticket_ws_requester", "ticket_ext", ["workspace_id", "requester_id"]) # 3) 事项关系(子任务 / 相关) op.create_table( "item_link", sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), _ws_fk(), _item_fk("from_item_id", ondelete="CASCADE"), _item_fk("to_item_id", ondelete="CASCADE"), sa.Column("link_type", sa.String(16), nullable=False, server_default="related"), 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("version", sa.Integer, nullable=False, server_default="1"), sa.UniqueConstraint("from_item_id", "to_item_id", "link_type", name="uq_item_link"), sa.CheckConstraint("from_item_id <> to_item_id", name="ck_item_link_not_self"), sa.CheckConstraint("link_type IN ('parent','related')", name="ck_item_link_type"), ) op.create_index("ix_item_link_ws_from", "item_link", ["workspace_id", "from_item_id"]) op.create_index("ix_item_link_ws_to", "item_link", ["workspace_id", "to_item_id"]) # 4) 站内通知 op.create_table( "notification", sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), _ws_fk(), _user_fk("user_id", ondelete="CASCADE"), sa.Column("kind", sa.String(32), nullable=False), _item_fk("item_id", ondelete="CASCADE", nullable=True), sa.Column("title", sa.String(120), nullable=False), sa.Column("body", sa.String(300), nullable=False, server_default=""), sa.Column("read_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("version", sa.Integer, nullable=False, server_default="1"), ) op.create_index("ix_notification_ws_user_read", "notification", ["workspace_id", "user_id", "read_at"]) op.create_index("ix_notification_ws_created", "notification", ["workspace_id", "created_at"]) for statement in _POLICY_STATEMENTS: op.execute(statement) for statement in _GRANT_STATEMENTS: op.execute(statement) def downgrade() -> None: # 旧约束容纳不下工单状态,回退必须先清掉工单数据(含它们的 ticket_ext)。 # 这是破坏性操作,只在回滚迁移时发生;生产环境回滚前请先导出 kanban/ticket 数据。 op.execute("DELETE FROM item WHERE kind = 'ticket'") for table in TENANT_TABLES_0003: op.execute(f"DROP POLICY IF EXISTS {table}_isolation ON {table}") op.drop_index("ix_notification_ws_created", table_name="notification") op.drop_index("ix_notification_ws_user_read", table_name="notification") op.drop_table("notification") op.drop_index("ix_item_link_ws_to", table_name="item_link") op.drop_index("ix_item_link_ws_from", table_name="item_link") op.drop_table("item_link") op.drop_index("ix_ticket_ws_requester", table_name="ticket_ext") op.drop_index("ix_ticket_ws_assignee", table_name="ticket_ext") op.drop_table("ticket_ext") statuses = ",".join(f"'{s}'" for s in TASK_STATUSES) op.execute("ALTER TABLE item DROP CONSTRAINT IF EXISTS ck_item_status") op.execute( f"ALTER TABLE item ADD CONSTRAINT ck_item_status CHECK (status IN ({statuses}))" ) _POLICY_STATEMENTS: tuple[str, ...] = tuple( statement for table in TENANT_TABLES_0003 for statement in ( f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY;", f""" CREATE POLICY {table}_isolation ON {table} USING (workspace_id = app_ws_id()) WITH CHECK (workspace_id = app_ws_id()); """, ) ) _GRANT_STATEMENTS: tuple[str, ...] = ( f"GRANT SELECT, INSERT, UPDATE, DELETE ON {', '.join(TENANT_TABLES_0003)} TO {APP_ROLE};", )