- 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
734 lines
26 KiB
Python
734 lines
26 KiB
Python
"""任务(item)接口。
|
||
|
||
写操作的并发控制统一走**乐观锁**:请求必须带 `version`,落库时用
|
||
`UPDATE ... WHERE id = ? AND version = ?` 并检查 rowcount。
|
||
不用"先查再改"——两个请求同时读到 version=3 时,两边都会认为自己是对的,
|
||
后写的静默覆盖先写的,而且日志里看不出异常。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import uuid
|
||
from datetime import datetime, timezone
|
||
from typing import Annotated, Any
|
||
|
||
from fastapi import APIRouter, Query
|
||
from pydantic import BaseModel, Field
|
||
from sqlalchemy import Select, func, or_, select, update
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from ..config import get_settings
|
||
from ..deps import MemberDep, SessionDep
|
||
from ..domain.task import (
|
||
ALL_STATUSES,
|
||
apply_status,
|
||
due_window,
|
||
ensure_transition,
|
||
view_statuses,
|
||
)
|
||
from ..errors import AppError, ErrorCode
|
||
from ..models_task import Activity, Comment, Item, ItemTag, Tag
|
||
from ..models_ticket import TicketExt
|
||
from ..schemas import Pagination, encode_cursor, ok
|
||
from ..serializers import item_out, tag_out, tags_of
|
||
|
||
router = APIRouter(prefix="/workspaces/{ws_id}/items", tags=["任务"])
|
||
tag_router = APIRouter(prefix="/workspaces/{ws_id}/tags", tags=["标签"])
|
||
|
||
MAX_TITLE = 200
|
||
MAX_BODY = 100_000
|
||
|
||
|
||
# ── 入参 ────────────────────────────────────────────────────────────────
|
||
class CreateItemIn(BaseModel):
|
||
title: str = Field(min_length=1, max_length=MAX_TITLE)
|
||
body_md: str = Field(default="", max_length=MAX_BODY)
|
||
status: str = Field(default="inbox")
|
||
priority: int = Field(default=0, ge=0, le=3)
|
||
assignee_id: uuid.UUID | None = None
|
||
due_at: datetime | None = None
|
||
tag_names: list[str] = Field(default_factory=list, max_length=10)
|
||
|
||
|
||
class UpdateItemIn(BaseModel):
|
||
"""只传要改的字段。注意:`None` 表示"不动",想把 due_at 清空请传 `clear_due_at`。"""
|
||
|
||
version: int = Field(ge=1)
|
||
title: str | None = Field(default=None, min_length=1, max_length=MAX_TITLE)
|
||
body_md: str | None = Field(default=None, max_length=MAX_BODY)
|
||
priority: int | None = Field(default=None, ge=0, le=3)
|
||
assignee_id: uuid.UUID | None = None
|
||
clear_assignee: bool = False
|
||
due_at: datetime | None = None
|
||
clear_due_at: bool = False
|
||
tag_names: list[str] | None = Field(default=None, max_length=10)
|
||
|
||
|
||
class StatusIn(BaseModel):
|
||
version: int = Field(ge=1)
|
||
status: str
|
||
|
||
|
||
class CommentIn(BaseModel):
|
||
body_md: str = Field(min_length=1, max_length=20_000)
|
||
|
||
|
||
class TagIn(BaseModel):
|
||
name: str = Field(min_length=1, max_length=32)
|
||
color: str = Field(default="", max_length=16)
|
||
|
||
|
||
# ── 序列化(与工单接口共用一份,两份拼装迟早会不一致)─────────────────────
|
||
_tag_out = tag_out
|
||
_item_out = item_out
|
||
|
||
|
||
# ── 内部工具 ────────────────────────────────────────────────────────────
|
||
async def _load_item(
|
||
session: AsyncSession,
|
||
ws_id: uuid.UUID,
|
||
item_id: uuid.UUID,
|
||
kinds: tuple[str, ...] = ("task",),
|
||
) -> Item:
|
||
item = await session.get(Item, item_id)
|
||
if item is None or item.workspace_id != ws_id or item.kind not in kinds:
|
||
# 跨租户 / 不存在 / 不是任务,对外一律"不存在",不泄漏别人有没有这条数据
|
||
raise AppError(ErrorCode.ITEM_NOT_FOUND, "任务不存在")
|
||
return item
|
||
|
||
|
||
async def _tags_of(
|
||
session: AsyncSession, ws_id: uuid.UUID, item_ids: list[uuid.UUID]
|
||
) -> dict[uuid.UUID, list[Tag]]:
|
||
return await tags_of(session, ws_id, item_ids)
|
||
|
||
|
||
def _normalize_tag_names(names: list[str]) -> list[str]:
|
||
result: list[str] = []
|
||
seen: set[str] = set()
|
||
for raw in names:
|
||
name = raw.strip()
|
||
if not name or name.lower() in seen:
|
||
continue
|
||
seen.add(name.lower())
|
||
result.append(name)
|
||
return result
|
||
|
||
|
||
async def _set_tags(session: AsyncSession, ws_id: uuid.UUID, item: Item, names: list[str]) -> None:
|
||
"""整组替换:客户端传什么就是什么,不做增量(增量语义在弱网下容易和用户预期错位)。"""
|
||
names = _normalize_tag_names(names)
|
||
existing = {
|
||
tag.name.lower(): tag
|
||
for tag in (await session.execute(select(Tag).where(Tag.workspace_id == ws_id))).scalars()
|
||
}
|
||
keep: list[Tag] = []
|
||
for name in names:
|
||
tag = existing.get(name.lower())
|
||
if tag is None:
|
||
tag = Tag(workspace_id=ws_id, name=name)
|
||
session.add(tag)
|
||
await session.flush()
|
||
existing[name.lower()] = tag
|
||
keep.append(tag)
|
||
|
||
await session.execute(
|
||
ItemTag.__table__.delete().where(ItemTag.workspace_id == ws_id, ItemTag.item_id == item.id)
|
||
)
|
||
for tag in keep:
|
||
session.add(ItemTag(workspace_id=ws_id, item_id=item.id, tag_id=tag.id))
|
||
await session.flush()
|
||
|
||
|
||
async def _log(
|
||
session: AsyncSession, item: Item, actor_id: uuid.UUID, action: str, payload: dict | None = None
|
||
) -> None:
|
||
session.add(
|
||
Activity(
|
||
workspace_id=item.workspace_id,
|
||
item_id=item.id,
|
||
actor_id=actor_id,
|
||
action=action,
|
||
payload=payload or {},
|
||
)
|
||
)
|
||
|
||
|
||
async def _apply_update(
|
||
session: AsyncSession,
|
||
item: Item,
|
||
*,
|
||
version: int,
|
||
values: dict[str, Any],
|
||
actor_id: uuid.UUID,
|
||
action: str,
|
||
payload: dict | None = None,
|
||
) -> Item:
|
||
"""乐观锁写库:rowcount=0 说明别人先改了,别默默覆盖。"""
|
||
values = {**values, "version": version + 1, "updated_at": datetime.now(timezone.utc)}
|
||
result = await session.execute(
|
||
update(Item)
|
||
.where(Item.id == item.id, Item.workspace_id == item.workspace_id, Item.version == version)
|
||
.values(**values)
|
||
)
|
||
if result.rowcount == 0:
|
||
await session.refresh(item)
|
||
raise AppError(
|
||
ErrorCode.VERSION_CONFLICT,
|
||
"这条任务已被别人修改,请刷新后重试",
|
||
details={"current_version": item.version},
|
||
)
|
||
await session.refresh(item)
|
||
await _log(session, item, actor_id, action, payload)
|
||
return item
|
||
|
||
|
||
# ── 列表 ────────────────────────────────────────────────────────────────
|
||
def _apply_filters(
|
||
stmt: Select,
|
||
*,
|
||
ws_id: uuid.UUID,
|
||
me: uuid.UUID,
|
||
view: str | None,
|
||
status: list[str] | None,
|
||
assignee: str | None,
|
||
q: str | None,
|
||
tag: str | None,
|
||
include_deleted: bool,
|
||
now: datetime,
|
||
tz: str,
|
||
) -> Select:
|
||
stmt = stmt.where(Item.workspace_id == ws_id, Item.kind == "task")
|
||
|
||
if view == "deleted":
|
||
stmt = stmt.where(Item.deleted_at.is_not(None))
|
||
elif not include_deleted:
|
||
stmt = stmt.where(Item.deleted_at.is_(None))
|
||
|
||
if status:
|
||
invalid = [s for s in status if s not in ALL_STATUSES]
|
||
if invalid:
|
||
raise AppError(ErrorCode.BAD_REQUEST, f"未知状态:{','.join(invalid)}")
|
||
stmt = stmt.where(Item.status.in_(status))
|
||
elif view and view != "deleted":
|
||
stmt = stmt.where(Item.status.in_(view_statuses(view)))
|
||
|
||
due_from, due_to = due_window(view, now=now, tz=tz)
|
||
if due_from is not None:
|
||
stmt = stmt.where(Item.due_at.is_not(None), Item.due_at >= due_from, Item.due_at < due_to)
|
||
|
||
if assignee:
|
||
if assignee == "me":
|
||
stmt = stmt.where(Item.assignee_id == me)
|
||
else:
|
||
try:
|
||
stmt = stmt.where(Item.assignee_id == uuid.UUID(assignee))
|
||
except ValueError as exc:
|
||
raise AppError(ErrorCode.BAD_REQUEST, "assignee 需要是 me 或用户 id") from exc
|
||
|
||
if q and q.strip():
|
||
needle = f"%{q.strip()}%"
|
||
stmt = stmt.where(or_(Item.title.ilike(needle), Item.body_md.ilike(needle)))
|
||
|
||
if tag and tag.strip():
|
||
stmt = stmt.where(
|
||
Item.id.in_(
|
||
select(ItemTag.item_id)
|
||
.join(Tag, Tag.id == ItemTag.tag_id)
|
||
.where(ItemTag.workspace_id == ws_id, func.lower(Tag.name) == tag.strip().lower())
|
||
)
|
||
)
|
||
return stmt
|
||
|
||
|
||
@router.get("", summary="任务列表(视图 / 状态 / 负责人 / 关键字 / 标签)")
|
||
async def list_items(
|
||
ctx: MemberDep,
|
||
session: SessionDep,
|
||
view: Annotated[
|
||
str | None, Query(description="inbox|active|today|week|done|canceled|all|deleted")
|
||
] = None,
|
||
status: Annotated[list[str] | None, Query(description="精确状态,可重复传")] = None,
|
||
assignee: Annotated[str | None, Query(description="me 或用户 uuid")] = None,
|
||
q: Annotated[str | None, Query(max_length=100, description="标题/正文关键字")] = None,
|
||
tag: Annotated[str | None, Query(max_length=32)] = None,
|
||
sort: Annotated[str, Query(description="updated|due|created")] = "updated",
|
||
include_deleted: bool = False,
|
||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||
cursor: Annotated[str | None, Query()] = None,
|
||
) -> dict:
|
||
if sort not in ("updated", "due", "created"):
|
||
raise AppError(ErrorCode.BAD_REQUEST, "sort 只能是 updated / due / created")
|
||
|
||
settings = get_settings()
|
||
now = datetime.now(timezone.utc)
|
||
page = Pagination(limit=limit, cursor=cursor)
|
||
limit, parsed_cursor = page.parsed()
|
||
|
||
base = _apply_filters(
|
||
select(Item),
|
||
ws_id=ctx.ws_id,
|
||
me=ctx.user.id,
|
||
view=view,
|
||
status=status,
|
||
assignee=assignee,
|
||
q=q,
|
||
tag=tag,
|
||
include_deleted=include_deleted,
|
||
now=now,
|
||
tz=settings.default_timezone,
|
||
)
|
||
|
||
if sort == "due":
|
||
# 截止时间排序涉及 NULLS LAST,keyset 分页会把 NULL 行切碎。
|
||
# 逾期/今日清单本来就是短列表,这里不分页(上限 100 条),语义更清楚。
|
||
rows = (
|
||
(
|
||
await session.execute(
|
||
base.order_by(Item.due_at.asc().nulls_last(), Item.sort_order, Item.id).limit(
|
||
limit
|
||
)
|
||
)
|
||
)
|
||
.scalars()
|
||
.all()
|
||
)
|
||
tags = await _tags_of(session, ctx.ws_id, [r.id for r in rows])
|
||
return ok(
|
||
{
|
||
"items": [_item_out(r, tags.get(r.id)) for r in rows],
|
||
"next_cursor": None,
|
||
"has_more": False,
|
||
}
|
||
)
|
||
|
||
if sort == "created":
|
||
order = (Item.created_at.desc(), Item.id.desc())
|
||
sort_col, sort_attr = Item.created_at, "created_at"
|
||
else:
|
||
order = (Item.updated_at.desc(), Item.id.desc())
|
||
sort_col, sort_attr = Item.updated_at, "updated_at"
|
||
|
||
stmt = base.order_by(*order).limit(limit + 1)
|
||
if parsed_cursor is not None:
|
||
stmt = stmt.where(
|
||
or_(
|
||
sort_col < parsed_cursor.created_at,
|
||
(sort_col == parsed_cursor.created_at) & (Item.id < parsed_cursor.id),
|
||
)
|
||
)
|
||
|
||
rows = (await session.execute(stmt)).scalars().all()
|
||
has_more = len(rows) > limit
|
||
rows = list(rows[:limit])
|
||
next_cursor = (
|
||
encode_cursor(getattr(rows[-1], sort_attr), rows[-1].id) if has_more and rows else None
|
||
)
|
||
tags = await _tags_of(session, ctx.ws_id, [r.id for r in rows])
|
||
return ok(
|
||
{
|
||
"items": [_item_out(r, tags.get(r.id)) for r in rows],
|
||
"next_cursor": next_cursor,
|
||
"has_more": has_more,
|
||
}
|
||
)
|
||
|
||
|
||
@router.get("/board", summary="看板:按状态分列的最近任务")
|
||
async def board(
|
||
ctx: MemberDep,
|
||
session: SessionDep,
|
||
per_column: Annotated[int, Query(ge=1, le=100)] = 30,
|
||
) -> dict:
|
||
"""一屏拖动需要的数据,一次拿全。
|
||
|
||
每列按"截止时间早的在前、没有截止时间的靠后"排,其次按更新时间。
|
||
只取 `per_column` 条:看板是"手上正在做的",不是全量列表。
|
||
"""
|
||
columns: dict[str, list[dict[str, Any]]] = {}
|
||
statuses = ("todo", "doing", "blocked", "done")
|
||
rows = (
|
||
(
|
||
await session.execute(
|
||
select(Item)
|
||
.where(
|
||
Item.workspace_id == ctx.ws_id,
|
||
Item.status.in_(statuses),
|
||
Item.deleted_at.is_(None),
|
||
)
|
||
.order_by(
|
||
Item.due_at.asc().nulls_last(),
|
||
Item.updated_at.desc(),
|
||
Item.id.desc(),
|
||
)
|
||
.limit(per_column * len(statuses))
|
||
)
|
||
)
|
||
.scalars()
|
||
.all()
|
||
)
|
||
|
||
by_id = {row.id: row for row in rows}
|
||
tag_map = await _tags_of(session, ctx.ws_id, list(by_id))
|
||
for status in statuses:
|
||
columns[status] = [
|
||
_item_out(row, tag_map.get(row.id)) for row in rows if row.status == status
|
||
][:per_column]
|
||
return ok(
|
||
{
|
||
"columns": columns,
|
||
"order": list(statuses),
|
||
"labels": {
|
||
"todo": "待办",
|
||
"doing": "进行中",
|
||
"blocked": "被阻塞",
|
||
"done": "已完成",
|
||
},
|
||
}
|
||
)
|
||
|
||
|
||
@router.get("/{item_id}", summary="事项详情(任务或工单,含最近 50 条事件)")
|
||
async def get_item(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
|
||
item = await _load_item(session, ctx.ws_id, item_id, ("task", "ticket"))
|
||
tags = await _tags_of(session, ctx.ws_id, [item.id])
|
||
activities = (
|
||
(
|
||
await session.execute(
|
||
select(Activity)
|
||
.where(Activity.workspace_id == ctx.ws_id, Activity.item_id == item.id)
|
||
.order_by(Activity.created_at.desc(), Activity.id.desc())
|
||
.limit(50)
|
||
)
|
||
)
|
||
.scalars()
|
||
.all()
|
||
)
|
||
data = _item_out(item, tags.get(item.id))
|
||
data["activities"] = [
|
||
{
|
||
"id": str(a.id),
|
||
"action": a.action,
|
||
"actor_id": str(a.actor_id) if a.actor_id else None,
|
||
"payload": a.payload,
|
||
"created_at": a.created_at,
|
||
}
|
||
for a in activities
|
||
]
|
||
if item.kind == "ticket":
|
||
ext = await session.get(TicketExt, item.id)
|
||
if ext is not None:
|
||
data["ticket"] = {
|
||
"requester_id": str(ext.requester_id),
|
||
"assignee_id": str(ext.assignee_id),
|
||
"parent_item_id": str(ext.parent_item_id) if ext.parent_item_id else None,
|
||
"accepted_at": ext.accepted_at,
|
||
"submitted_at": ext.submitted_at,
|
||
"verified_at": ext.verified_at,
|
||
"verified_by": str(ext.verified_by) if ext.verified_by else None,
|
||
"reject_count": ext.reject_count,
|
||
"resolution_md": ext.resolution_md,
|
||
}
|
||
return ok(data)
|
||
|
||
|
||
# ── 写操作 ──────────────────────────────────────────────────────────────
|
||
@router.post("", summary="新建任务")
|
||
async def create_item(body: CreateItemIn, ctx: MemberDep, session: SessionDep) -> dict:
|
||
if body.status not in ALL_STATUSES:
|
||
raise AppError(ErrorCode.BAD_REQUEST, f"未知状态:{body.status}")
|
||
|
||
fields: dict[str, Any] = {"status": body.status, "completed_at": None}
|
||
apply_status(fields, body.status, now=datetime.now(timezone.utc))
|
||
|
||
item = Item(
|
||
id=uuid.uuid4(),
|
||
workspace_id=ctx.ws_id,
|
||
kind="task",
|
||
title=body.title.strip(),
|
||
body_md=body.body_md,
|
||
priority=body.priority,
|
||
creator_id=ctx.user.id,
|
||
assignee_id=body.assignee_id,
|
||
due_at=body.due_at,
|
||
status=fields["status"],
|
||
completed_at=fields["completed_at"],
|
||
)
|
||
session.add(item)
|
||
await session.flush()
|
||
if body.tag_names:
|
||
await _set_tags(session, ctx.ws_id, item, body.tag_names)
|
||
await _log(session, item, ctx.user.id, "created", {"status": item.status})
|
||
|
||
tags = await _tags_of(session, ctx.ws_id, [item.id])
|
||
return ok(_item_out(item, tags.get(item.id)), message="已创建")
|
||
|
||
|
||
class BatchStatusItemIn(BaseModel):
|
||
id: uuid.UUID
|
||
version: int
|
||
|
||
|
||
class BatchStatusIn(BaseModel):
|
||
status: str
|
||
items: list[BatchStatusItemIn] = Field(min_length=1, max_length=50)
|
||
|
||
|
||
@router.post("/batch-status", summary="批量改状态(逐条返回成败)")
|
||
async def batch_status(body: BatchStatusIn, ctx: MemberDep, session: SessionDep) -> dict:
|
||
"""看板多选拖拽用。
|
||
|
||
不做"全成功或全回滚":拖 10 条里有 1 条被同事改过,其余 9 条应当照样生效,
|
||
失败的那条单独告诉前端原因。全回滚会让用户白干一次,而且更难解释。
|
||
"""
|
||
if body.status not in ALL_STATUSES:
|
||
raise AppError(ErrorCode.BAD_REQUEST, f"未知状态:{body.status}")
|
||
|
||
now = datetime.now(timezone.utc)
|
||
updated: list[dict[str, Any]] = []
|
||
failed: list[dict[str, Any]] = []
|
||
|
||
for entry in body.items:
|
||
try:
|
||
item = await _load_item(session, ctx.ws_id, entry.id)
|
||
except AppError as exc:
|
||
failed.append({"id": str(entry.id), "code": int(exc.code), "message": exc.message})
|
||
continue
|
||
|
||
if item.status == body.status:
|
||
updated.append({"id": str(item.id), "version": item.version, "unchanged": True})
|
||
continue
|
||
|
||
try:
|
||
ensure_transition(item.status, body.status)
|
||
except AppError as exc:
|
||
failed.append(
|
||
{
|
||
"id": str(entry.id),
|
||
"code": int(exc.code),
|
||
"message": exc.message,
|
||
"allowed": (exc.details or {}).get("allowed"),
|
||
}
|
||
)
|
||
continue
|
||
|
||
previous = item.status
|
||
fields: dict[str, Any] = {"status": body.status, "completed_at": None}
|
||
apply_status(fields, body.status, now=now)
|
||
try:
|
||
item = await _apply_update(
|
||
session,
|
||
item,
|
||
version=entry.version,
|
||
values={"status": fields["status"], "completed_at": fields["completed_at"]},
|
||
actor_id=ctx.user.id,
|
||
action="status_changed",
|
||
payload={"from": previous, "to": body.status, "batch": True},
|
||
)
|
||
except AppError as exc:
|
||
failed.append(
|
||
{
|
||
"id": str(entry.id),
|
||
"code": int(exc.code),
|
||
"message": exc.message,
|
||
"current_version": (exc.details or {}).get("current_version"),
|
||
}
|
||
)
|
||
continue
|
||
updated.append({"id": str(item.id), "version": item.version, "unchanged": False})
|
||
|
||
return ok(
|
||
{"updated": updated, "failed": failed},
|
||
message=f"已更新 {len(updated)} 条,{len(failed)} 条未生效",
|
||
)
|
||
|
||
|
||
@router.patch("/{item_id}", summary="修改事项字段(乐观锁)")
|
||
async def update_item(
|
||
item_id: uuid.UUID, body: UpdateItemIn, ctx: MemberDep, session: SessionDep
|
||
) -> dict:
|
||
# 工单也能改标题/优先级/截止/标签;状态必须走 /ticket/* 的动作接口
|
||
item = await _load_item(session, ctx.ws_id, item_id, ("task", "ticket"))
|
||
|
||
values: dict[str, Any] = {}
|
||
changes: dict[str, Any] = {}
|
||
if body.title is not None and body.title.strip() != item.title:
|
||
values["title"] = body.title.strip()
|
||
changes["title"] = values["title"]
|
||
if body.body_md is not None and body.body_md != item.body_md:
|
||
values["body_md"] = body.body_md
|
||
changes["body_md"] = "changed"
|
||
if body.priority is not None and body.priority != item.priority:
|
||
values["priority"] = body.priority
|
||
changes["priority"] = body.priority
|
||
if body.clear_assignee:
|
||
values["assignee_id"] = None
|
||
changes["assignee_id"] = None
|
||
elif body.assignee_id is not None and body.assignee_id != item.assignee_id:
|
||
values["assignee_id"] = body.assignee_id
|
||
changes["assignee_id"] = str(body.assignee_id)
|
||
if body.clear_due_at:
|
||
values["due_at"] = None
|
||
changes["due_at"] = None
|
||
elif body.due_at is not None and body.due_at != item.due_at:
|
||
values["due_at"] = body.due_at
|
||
changes["due_at"] = body.due_at.isoformat()
|
||
|
||
item = await _apply_update(
|
||
session,
|
||
item,
|
||
version=body.version,
|
||
values=values,
|
||
actor_id=ctx.user.id,
|
||
action="updated",
|
||
payload={"changes": changes} if changes else None,
|
||
)
|
||
if body.tag_names is not None:
|
||
await _set_tags(session, ctx.ws_id, item, body.tag_names)
|
||
await _log(
|
||
session,
|
||
item,
|
||
ctx.user.id,
|
||
"tags_changed",
|
||
{"tags": _normalize_tag_names(body.tag_names)},
|
||
)
|
||
item.version += 1
|
||
|
||
tags = await _tags_of(session, ctx.ws_id, [item.id])
|
||
return ok(_item_out(item, tags.get(item.id)), message="已保存")
|
||
|
||
|
||
@router.post("/{item_id}/status", summary="改状态(状态机 + 乐观锁)")
|
||
async def change_status(
|
||
item_id: uuid.UUID, body: StatusIn, ctx: MemberDep, session: SessionDep
|
||
) -> dict:
|
||
item = await _load_item(session, ctx.ws_id, item_id)
|
||
if body.status not in ALL_STATUSES:
|
||
raise AppError(ErrorCode.BAD_REQUEST, f"未知状态:{body.status}")
|
||
if body.status == item.status:
|
||
tags = await _tags_of(session, ctx.ws_id, [item.id])
|
||
return ok(_item_out(item, tags.get(item.id)), message="状态未变化")
|
||
|
||
ensure_transition(item.status, body.status)
|
||
previous = item.status
|
||
fields: dict[str, Any] = {"status": body.status, "completed_at": None}
|
||
apply_status(fields, body.status, now=datetime.now(timezone.utc))
|
||
item = await _apply_update(
|
||
session,
|
||
item,
|
||
version=body.version,
|
||
values={"status": fields["status"], "completed_at": fields["completed_at"]},
|
||
actor_id=ctx.user.id,
|
||
action="status_changed",
|
||
payload={"from": previous, "to": body.status},
|
||
)
|
||
tags = await _tags_of(session, ctx.ws_id, [item.id])
|
||
return ok(_item_out(item, tags.get(item.id)), message="状态已更新")
|
||
|
||
|
||
@router.delete("/{item_id}", summary="删除任务(软删;带 version 才防并发)")
|
||
async def delete_item(
|
||
item_id: uuid.UUID,
|
||
ctx: MemberDep,
|
||
session: SessionDep,
|
||
version: Annotated[int, Query(ge=1)],
|
||
) -> dict:
|
||
item = await _load_item(session, ctx.ws_id, item_id)
|
||
item = await _apply_update(
|
||
session,
|
||
item,
|
||
version=version,
|
||
values={"deleted_at": datetime.now(timezone.utc)},
|
||
actor_id=ctx.user.id,
|
||
action="deleted",
|
||
)
|
||
return ok({"id": str(item.id), "deleted_at": item.deleted_at}, message="已删除")
|
||
|
||
|
||
# ── 评论 ────────────────────────────────────────────────────────────────
|
||
@router.post("/{item_id}/comments", summary="加评论")
|
||
async def add_comment(
|
||
item_id: uuid.UUID, body: CommentIn, ctx: MemberDep, session: SessionDep
|
||
) -> dict:
|
||
item = await _load_item(session, ctx.ws_id, item_id, ("task", "ticket"))
|
||
comment = Comment(
|
||
workspace_id=ctx.ws_id,
|
||
item_id=item.id,
|
||
author_id=ctx.user.id,
|
||
body_md=body.body_md,
|
||
)
|
||
session.add(comment)
|
||
await session.flush()
|
||
await _log(session, item, ctx.user.id, "commented", {"comment_id": str(comment.id)})
|
||
return ok(
|
||
{
|
||
"id": str(comment.id),
|
||
"item_id": str(item.id),
|
||
"author_id": str(comment.author_id),
|
||
"body_md": comment.body_md,
|
||
"created_at": comment.created_at,
|
||
},
|
||
message="已评论",
|
||
)
|
||
|
||
|
||
@router.get("/{item_id}/comments", summary="评论列表")
|
||
async def list_comments(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
|
||
item = await _load_item(session, ctx.ws_id, item_id, ("task", "ticket"))
|
||
rows = (
|
||
(
|
||
await session.execute(
|
||
select(Comment)
|
||
.where(
|
||
Comment.workspace_id == ctx.ws_id,
|
||
Comment.item_id == item.id,
|
||
Comment.deleted_at.is_(None),
|
||
)
|
||
.order_by(Comment.created_at.asc(), Comment.id.asc())
|
||
.limit(200)
|
||
)
|
||
)
|
||
.scalars()
|
||
.all()
|
||
)
|
||
return ok(
|
||
{
|
||
"items": [
|
||
{
|
||
"id": str(c.id),
|
||
"author_id": str(c.author_id),
|
||
"body_md": c.body_md,
|
||
"created_at": c.created_at,
|
||
}
|
||
for c in rows
|
||
]
|
||
}
|
||
)
|
||
|
||
|
||
# ── 标签 ────────────────────────────────────────────────────────────────
|
||
@tag_router.get("", summary="标签列表")
|
||
async def list_tags(ctx: MemberDep, session: SessionDep) -> dict:
|
||
rows = (
|
||
(await session.execute(select(Tag).where(Tag.workspace_id == ctx.ws_id).order_by(Tag.name)))
|
||
.scalars()
|
||
.all()
|
||
)
|
||
return ok({"items": [_tag_out(t) for t in rows]})
|
||
|
||
|
||
@tag_router.post("", summary="建标签(同名返回已有)")
|
||
async def create_tag(body: TagIn, ctx: MemberDep, session: SessionDep) -> dict:
|
||
name = body.name.strip()
|
||
exists = (
|
||
await session.execute(
|
||
select(Tag).where(Tag.workspace_id == ctx.ws_id, func.lower(Tag.name) == name.lower())
|
||
)
|
||
).scalar_one_or_none()
|
||
if exists is not None:
|
||
return ok(_tag_out(exists), message="已存在")
|
||
tag = Tag(workspace_id=ctx.ws_id, name=name, color=body.color)
|
||
session.add(tag)
|
||
await session.flush()
|
||
return ok(_tag_out(tag), message="已创建")
|