Files
DadaoTask/backend/app/models_ticket.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

146 lines
6.3 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。
三条设计约束(对应 PRODUCT-PLAN 的 D1/D2):
1. **工单不是新表,是 `item` 的一个 kind**。标题、正文、截止、标签、评论、事件流
全部复用,M1 写的每一处代码对工单依然成立;`ticket_ext` 只补"谁请谁、验没验收"。
2. **升级不是搬家**。原任务留在原地并进入 `blocked`(它确实被卡住了),工单是**新建**的一条
item,通过 `TicketExt.parent_item_id` 指回去。工单验收通过时把原任务从 blocked 拉回 todo,
这就是"闭环回流"——任务那条时间线完整保留,历史不被搬来搬去。
3. **通知只记录"发生了什么",不做已读以外的状态机**。通知看错、漏看是常态,
真正的状态永远以 item 为准,通知只是提醒,不能变成第二份真相。
"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import (
CheckConstraint,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import UUID as PgUUID
from sqlalchemy.orm import Mapped, mapped_column
from .models import Base, Timestamps, new_uuid
# 工单状态机(与任务共用 item.status 这一列,但取值集合不同)
TICKET_STATUSES: tuple[str, ...] = (
"open",
"accepted",
"in_progress",
"pending_verify",
"done",
"canceled",
)
LINK_TYPES: tuple[str, ...] = ("parent", "related")
class TicketExt(Base, Timestamps):
"""工单的附加信息,与 item 一比一。"""
__tablename__ = "ticket_ext"
__table_args__ = (
Index("ix_ticket_ws_assignee", "workspace_id", "assignee_id"),
Index("ix_ticket_ws_requester", "workspace_id", "requester_id"),
CheckConstraint("reject_count >= 0", name="ck_ticket_reject_count"),
)
# item_id 既是主键又是外键:一条 item 最多一条 ticket_ext,天然 1:1
item_id: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="CASCADE"), primary_key=True
)
workspace_id: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False
)
# 谁提的这件事(下单的人)
requester_id: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="RESTRICT"), nullable=False
)
# 谁在办(冗余一份,免得每次查工单都要回 item 表;两处必须一致,改指派时同时写)
assignee_id: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="RESTRICT"), nullable=False
)
# 从哪条任务升级来的;独立创建的工单为空
parent_item_id: Mapped[uuid.UUID | None] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="SET NULL")
)
accepted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
submitted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
verified_by: Mapped[uuid.UUID | None] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="SET NULL")
)
# 被驳回次数:不是为了考核,是为了让提需求的人知道"这事返工过几次"
reject_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# 办完的结论:写什么由办理人决定,M3 从这里长知识草稿
resolution_md: Mapped[str] = mapped_column(Text, nullable=False, default="")
class ItemLink(Base, Timestamps):
"""事项之间的关系。M2 只做两种:父子(子任务)与相关。
`from_item_id → to_item_id` 是有方向的:parent 表示 from 是 to 的子项。
"""
__tablename__ = "item_link"
__table_args__ = (
UniqueConstraint("from_item_id", "to_item_id", "link_type", name="uq_item_link"),
CheckConstraint("from_item_id <> to_item_id", name="ck_item_link_not_self"),
CheckConstraint("link_type IN ('parent','related')", name="ck_item_link_type"),
Index("ix_item_link_ws_from", "workspace_id", "from_item_id"),
Index("ix_item_link_ws_to", "workspace_id", "to_item_id"),
)
id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid)
workspace_id: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False
)
from_item_id: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="CASCADE"), nullable=False
)
to_item_id: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="CASCADE"), nullable=False
)
link_type: Mapped[str] = mapped_column(String(16), nullable=False, default="related")
class Notification(Base, Timestamps):
"""站内通知。收件人维度,不是工作区里的公共消息流。"""
__tablename__ = "notification"
__table_args__ = (
Index("ix_notification_ws_user_read", "workspace_id", "user_id", "read_at"),
Index("ix_notification_ws_created", "workspace_id", "created_at"),
)
id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid)
workspace_id: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False
)
user_id: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="CASCADE"), nullable=False
)
kind: Mapped[str] = mapped_column(String(32), nullable=False)
item_id: Mapped[uuid.UUID | None] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="CASCADE")
)
title: Mapped[str] = mapped_column(String(120), nullable=False)
body: Mapped[str] = mapped_column(String(300), nullable=False, default="")
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
TICKET_TENANT_TABLES: tuple[str, ...] = ("ticket_ext", "item_link", "notification")