- item/tag/item_tag/comment/activity 五表迁移,RLS 全覆盖,pg_trgm 子串索引
- 状态机 domain/task.py:非法流转回 5002 并给 allowed,done 写 completed_at
- /workspaces/{ws}/items 列表/详情/新建/改/状态/软删/评论/标签
- 乐观锁 UPDATE...WHERE version,冲突 4009 + current_version
- Web 任务页:视图 tab、快速新建、勾选完成、开工/阻塞、详情事件流、游标分页
- 测试 43 全绿,ruff 干净,Web 构建通过
- 文档:2.Design/API.md 新增,ROADMAP 按已验证口径重写
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
"""任务域模型(M1):item / tag / item_tag / comment / activity。
|
||||
|
||||
约定与 M0 一致:uuid 主键、通用列、`workspace_id NOT NULL`。
|
||||
|
||||
两个刻意的取舍:
|
||||
- **M1 只有一个负责人 `assignee_id`**,不做 `item_assignee` 多对多。M1 是"自己的待办",
|
||||
多人协作在 M2 工单里才真正需要;提前建多对多表和对应 UI 是过度设计,等真要用再迁移。
|
||||
- **`kind` 列现在就加上**(默认 `task`)。M2 的工单、M3 的知识都是同一条 item 流,
|
||||
现在加一列只是默认值,将来加 `ticket_ext` / `knowledge_doc` 时不用改主表。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.dialects.postgresql import UUID as PgUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .models import Base, Timestamps, new_uuid
|
||||
|
||||
ITEM_STATUSES: tuple[str, ...] = ("inbox", "todo", "doing", "done", "blocked", "canceled")
|
||||
|
||||
|
||||
class Item(Base, Timestamps):
|
||||
"""任务/工单/知识的统一载体。M1 只用 `kind='task'`。"""
|
||||
|
||||
__tablename__ = "item"
|
||||
__table_args__ = (
|
||||
Index("ix_item_ws_status_due", "workspace_id", "status", "due_at"),
|
||||
Index("ix_item_ws_assignee", "workspace_id", "assignee_id", "status"),
|
||||
Index("ix_item_ws_updated", "workspace_id", "updated_at"),
|
||||
Index("ix_item_ws_sort", "workspace_id", "status", "sort_order"),
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
kind: Mapped[str] = mapped_column(String(16), nullable=False, default="task")
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
body_md: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="inbox")
|
||||
priority: Mapped[int] = mapped_column(nullable=False, default=0)
|
||||
creator_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
assignee_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="SET NULL")
|
||||
)
|
||||
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
sort_order: Mapped[int] = mapped_column(nullable=False, default=0)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class Tag(Base, Timestamps):
|
||||
__tablename__ = "tag"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("workspace_id", "name", name="uq_tag_ws_name"),
|
||||
Index("ix_tag_ws", "workspace_id", "name"),
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
color: Mapped[str] = mapped_column(String(16), nullable=False, default="")
|
||||
|
||||
|
||||
class ItemTag(Base, Timestamps):
|
||||
__tablename__ = "item_tag"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("item_id", "tag_id", name="uq_item_tag"),
|
||||
Index("ix_item_tag_ws_tag", "workspace_id", "tag_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
|
||||
)
|
||||
item_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
tag_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("tag.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
|
||||
|
||||
class Comment(Base, Timestamps):
|
||||
__tablename__ = "comment"
|
||||
__table_args__ = (Index("ix_comment_ws_item_created", "workspace_id", "item_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
|
||||
)
|
||||
item_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
author_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
body_md: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class Activity(Base):
|
||||
"""事件流:item 的每一次实质变更都落一条。
|
||||
|
||||
这是"这条任务怎么走到今天的"唯一真相源,M4 的 LLM 沉淀、M3 的知识都从这里取料。
|
||||
只增不改,所以没有 version / updated_at。
|
||||
"""
|
||||
|
||||
__tablename__ = "activity"
|
||||
__table_args__ = (
|
||||
Index("ix_activity_ws_item_created", "workspace_id", "item_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
|
||||
)
|
||||
item_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
actor_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="SET NULL")
|
||||
)
|
||||
action: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
payload: Mapped[dict | None] = mapped_column(JSONB)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
|
||||
TASK_TENANT_TABLES: tuple[str, ...] = ("item", "tag", "item_tag", "comment", "activity")
|
||||
Reference in New Issue
Block a user