Files
DadaoTask/backend/alembic/versions/0003_tickets.py
T
dadao 30caaaf1d9 feat(ticket): 工单协同后端——升级不搬数据、受理到验收、站内通知
- item.kind='ticket' + ticket_ext 1:1,迁移 0003 同时重写 item 状态约束为任务∪工单并集
- 工单状态机与动作权限:受理/开工/提交限办理人,验收限需求人(办理人不能自验)
- 升级时原任务转 blocked,验收通过后自动回流 todo,事件流两侧都留痕
- item_link(父子/相关)与 notification(只发当事人),三张表均开 RLS
- 抽出 serializers.py 复用任务序列化,修复工单响应缺 tags 导致前端白屏
- 评论与 PATCH 放开给工单;conftest 清库前终止残留事务,避免 TRUNCATE 死等锁
- tests/test_tickets.py 11 个用例,全量 57 passed
2026-10-28 13:20:07 +08:00

182 lines
7.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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};",
)