182 lines
7.8 KiB
Python
182 lines
7.8 KiB
Python
"""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
|
||
|
||
from app.roles import APP_ROLE
|
||
|
||
revision: str = "0003"
|
||
down_revision: str | None = "0002"
|
||
branch_labels: str | Sequence[str] | None = None
|
||
depends_on: str | Sequence[str] | None = None
|
||
|
||
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};",
|
||
)
|