- 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
764 lines
26 KiB
Python
764 lines
26 KiB
Python
"""工单域接口(M2):升级、指派、办理、验收、回流、通知、事项关系。
|
||
|
||
一段流程长什么样:
|
||
|
||
任务卡住了
|
||
-> POST /items/{task_id}/promote {assignee_id} 任务变 blocked,生成一条工单(open)
|
||
-> 被指派人 POST /items/{ticket_id}/ticket/accept accepted
|
||
-> POST /items/{ticket_id}/ticket/start in_progress
|
||
-> POST /items/{ticket_id}/ticket/submit pending_verify(提需求的人收到通知)
|
||
-> POST /items/{ticket_id}/ticket/verify {approve} done,原任务自动 blocked -> todo
|
||
|
||
驳回走同一个入口:verify {approve: false, note: "..."} -> 回到 in_progress,reject_count + 1。
|
||
每一步都写 activity;通知只是提醒,状态永远以 item 为准。
|
||
"""
|
||
|
||
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, and_, or_, select, update
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from ..audit import record as audit_record
|
||
from ..deps import MemberDep, SessionDep, TenantCtx
|
||
from ..domain.ticket import (
|
||
CANCELABLE,
|
||
NOW_FIELD,
|
||
ensure_can_act,
|
||
returned_task_status,
|
||
target_status,
|
||
)
|
||
from ..errors import AppError, ErrorCode
|
||
from ..models import Member
|
||
from ..models_task import Activity, Item
|
||
from ..models_ticket import ItemLink, Notification, TicketExt
|
||
from ..schemas import Pagination, encode_cursor, ok
|
||
from ..serializers import item_out, tags_of
|
||
|
||
router = APIRouter(prefix="/workspaces/{ws_id}", tags=["工单"])
|
||
notify_router = APIRouter(prefix="/workspaces/{ws_id}/notifications", tags=["通知"])
|
||
|
||
MAX_REJECT_NOTE = 500
|
||
|
||
|
||
# -- 入参 ---------------------------------------------------------------
|
||
class PromoteIn(BaseModel):
|
||
assignee_id: uuid.UUID
|
||
title: str | None = Field(default=None, max_length=200)
|
||
body_md: str | None = Field(default=None, max_length=20000)
|
||
due_at: datetime | None = None
|
||
priority: int | None = Field(default=None, ge=0, le=3)
|
||
version: int | None = Field(default=None, description="原任务的版本,给了就做乐观锁")
|
||
|
||
|
||
class ReassignIn(BaseModel):
|
||
assignee_id: uuid.UUID
|
||
|
||
|
||
class VerifyIn(BaseModel):
|
||
approve: bool
|
||
note: str = Field(default="", max_length=MAX_REJECT_NOTE)
|
||
resolution_md: str = Field(
|
||
default="", max_length=20000, description="验收通过时写的结论,M3 用它长知识"
|
||
)
|
||
|
||
|
||
class ActionIn(BaseModel):
|
||
note: str = Field(default="", max_length=MAX_REJECT_NOTE)
|
||
version: int | None = None
|
||
|
||
|
||
class LinkIn(BaseModel):
|
||
to_item_id: uuid.UUID
|
||
link_type: str = "related"
|
||
|
||
|
||
# -- 内部工具 -----------------------------------------------------------
|
||
async def _load_item(
|
||
session: AsyncSession, ws_id: uuid.UUID, item_id: uuid.UUID, kinds: tuple[str, ...]
|
||
) -> 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, "事项不存在")
|
||
if item.deleted_at is not None:
|
||
raise AppError(ErrorCode.ITEM_NOT_FOUND, "事项不存在")
|
||
return item
|
||
|
||
|
||
async def _load_ext(session: AsyncSession, item_id: uuid.UUID) -> TicketExt:
|
||
ext = await session.get(TicketExt, item_id)
|
||
if ext is None:
|
||
raise AppError(ErrorCode.TICKET_NOT_FOUND, "这不是一条工单")
|
||
return ext
|
||
|
||
|
||
async def _active_member_id(session: AsyncSession, ws_id: uuid.UUID, user_id: uuid.UUID) -> None:
|
||
exists = (
|
||
await session.execute(
|
||
select(Member.id).where(
|
||
Member.workspace_id == ws_id,
|
||
Member.user_id == user_id,
|
||
Member.status == "active",
|
||
)
|
||
)
|
||
).first()
|
||
if exists is None:
|
||
raise AppError(ErrorCode.BAD_REQUEST, "被指派的人不在这个工作区里")
|
||
|
||
|
||
async def _log(
|
||
session: AsyncSession,
|
||
item: Item,
|
||
actor_id: uuid.UUID | None,
|
||
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 _notify(
|
||
session: AsyncSession,
|
||
*,
|
||
ws_id: uuid.UUID,
|
||
user_id: uuid.UUID,
|
||
kind: str,
|
||
item_id: uuid.UUID | None,
|
||
title: str,
|
||
body: str = "",
|
||
) -> None:
|
||
session.add(
|
||
Notification(
|
||
workspace_id=ws_id,
|
||
user_id=user_id,
|
||
kind=kind,
|
||
item_id=item_id,
|
||
title=title[:120],
|
||
body=body[:300],
|
||
)
|
||
)
|
||
|
||
|
||
def _ticket_ext_out(ext: TicketExt) -> dict[str, Any]:
|
||
return {
|
||
"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,
|
||
}
|
||
|
||
|
||
async def _item_out(session: AsyncSession, ws_id: uuid.UUID, item: Item) -> dict[str, Any]:
|
||
"""单条序列化。标签必须一起带上——前端把 `item.tags` 当数组用,
|
||
少这个字段整页会白屏(M2 首版就是这么炸的)。"""
|
||
grouped = await tags_of(session, ws_id, [item.id])
|
||
return item_out(item, grouped.get(item.id))
|
||
|
||
|
||
async def _items_out(
|
||
session: AsyncSession, ws_id: uuid.UUID, items: list[Item]
|
||
) -> list[dict[str, Any]]:
|
||
grouped = await tags_of(session, ws_id, [i.id for i in items])
|
||
return [item_out(i, grouped.get(i.id)) for i in items]
|
||
|
||
|
||
async def _ticket_out(
|
||
session: AsyncSession, ws_id: uuid.UUID, ticket: Item, ext: TicketExt
|
||
) -> dict:
|
||
return ok({**await _item_out(session, ws_id, ticket), "ticket": _ticket_ext_out(ext)})
|
||
|
||
|
||
async def _patch_item(
|
||
session: AsyncSession,
|
||
item: Item,
|
||
values: dict[str, Any],
|
||
*,
|
||
expect_version: int | None,
|
||
) -> None:
|
||
"""带版本的 UPDATE:给了 version 就必须匹配,否则 409 让客户端刷新。"""
|
||
where = [Item.id == item.id, Item.workspace_id == item.workspace_id]
|
||
if expect_version is not None:
|
||
where.append(Item.version == expect_version)
|
||
result = await session.execute(
|
||
update(Item)
|
||
.where(*where)
|
||
.values(**values, version=Item.version + 1, updated_at=datetime.now(timezone.utc))
|
||
)
|
||
if result.rowcount == 0:
|
||
await session.refresh(item)
|
||
raise AppError(
|
||
ErrorCode.VERSION_CONFLICT,
|
||
"这条工单刚被改过,请刷新后重试",
|
||
details={"current_version": item.version},
|
||
)
|
||
await session.refresh(item)
|
||
|
||
|
||
# -- 升级 ---------------------------------------------------------------
|
||
@router.post("/items/{item_id}/promote", summary="把任务升级成工单")
|
||
async def promote(item_id: uuid.UUID, body: PromoteIn, ctx: MemberDep, session: SessionDep) -> dict:
|
||
task = await _load_item(session, ctx.ws_id, item_id, ("task",))
|
||
if task.status in ("done", "canceled"):
|
||
raise AppError(
|
||
ErrorCode.ITEM_STATUS_INVALID,
|
||
f"「{task.status}」的任务不能再升级成工单",
|
||
details={"status": task.status},
|
||
)
|
||
|
||
existing = (
|
||
await session.execute(
|
||
select(TicketExt.item_id).where(
|
||
TicketExt.workspace_id == ctx.ws_id, TicketExt.parent_item_id == task.id
|
||
)
|
||
)
|
||
).first()
|
||
if existing is not None:
|
||
raise AppError(ErrorCode.TICKET_ALREADY_EXISTS, "这条任务已经升级过工单了")
|
||
|
||
await _active_member_id(session, ctx.ws_id, body.assignee_id)
|
||
|
||
ticket = Item(
|
||
workspace_id=ctx.ws_id,
|
||
kind="ticket",
|
||
title=(body.title or task.title)[:200],
|
||
body_md=body.body_md if body.body_md is not None else task.body_md,
|
||
status="open",
|
||
priority=body.priority if body.priority is not None else task.priority,
|
||
creator_id=ctx.user.id,
|
||
assignee_id=body.assignee_id,
|
||
due_at=body.due_at or task.due_at,
|
||
)
|
||
session.add(ticket)
|
||
await session.flush()
|
||
|
||
ext = TicketExt(
|
||
item_id=ticket.id,
|
||
workspace_id=ctx.ws_id,
|
||
requester_id=ctx.user.id,
|
||
assignee_id=body.assignee_id,
|
||
parent_item_id=task.id,
|
||
)
|
||
session.add(ext)
|
||
await session.flush()
|
||
|
||
# 原任务确实卡住了,进 blocked;不能静默把用户的待办改成别的状态
|
||
await _patch_item(session, task, {"status": "blocked"}, expect_version=body.version)
|
||
await _log(session, task, ctx.user.id, "promoted_to_ticket", {"ticket_id": str(ticket.id)})
|
||
await _log(session, ticket, ctx.user.id, "ticket_created", {"parent_item_id": str(task.id)})
|
||
await _notify(
|
||
session,
|
||
ws_id=ctx.ws_id,
|
||
user_id=body.assignee_id,
|
||
kind="ticket_assigned",
|
||
item_id=ticket.id,
|
||
title=f"你被指派了一条工单:{ticket.title}",
|
||
body=f"{ctx.user.nickname or '同事'} 请你处理,完成后提交验收",
|
||
)
|
||
await audit_record(
|
||
session,
|
||
workspace_id=ctx.ws_id,
|
||
actor_user_id=ctx.user.id,
|
||
action="ticket.promote",
|
||
target_type="item",
|
||
target_id=str(ticket.id),
|
||
payload={"parent_item_id": str(task.id)},
|
||
)
|
||
await session.flush()
|
||
|
||
task_out, ticket_out = await _items_out(session, ctx.ws_id, [task, ticket])
|
||
return ok({"task": task_out, "ticket": {**ticket_out, "ticket": _ticket_ext_out(ext)}})
|
||
|
||
|
||
# -- 办理/验收 ----------------------------------------------------------
|
||
async def _advance(
|
||
ctx: TenantCtx,
|
||
session: AsyncSession,
|
||
item_id: uuid.UUID,
|
||
*,
|
||
action: str,
|
||
note: str = "",
|
||
approve: bool | None = None,
|
||
resolution_md: str = "",
|
||
version: int | None = None,
|
||
) -> dict:
|
||
ticket = await _load_item(session, ctx.ws_id, item_id, ("ticket",))
|
||
ext = await _load_ext(session, ticket.id)
|
||
|
||
target = target_status(action, ticket.status)
|
||
ensure_can_act(
|
||
action=action,
|
||
actor_id=ctx.user.id,
|
||
role=ctx.role,
|
||
assignee_id=ext.assignee_id,
|
||
requester_id=ext.requester_id,
|
||
)
|
||
|
||
now = datetime.now(timezone.utc)
|
||
previous = ticket.status
|
||
values: dict[str, Any] = {"status": target}
|
||
if target == "done":
|
||
values["completed_at"] = now
|
||
ext.verified_at = now
|
||
ext.verified_by = ctx.user.id
|
||
if resolution_md:
|
||
ext.resolution_md = resolution_md
|
||
if target == "in_progress" and action == "reject":
|
||
ext.reject_count = ext.reject_count + 1
|
||
|
||
stamp_field = NOW_FIELD.get(target)
|
||
if stamp_field and action != "reject":
|
||
setattr(ext, stamp_field, now)
|
||
|
||
await _patch_item(session, ticket, values, expect_version=version)
|
||
|
||
payload: dict[str, Any] = {"action": action, "from": previous, "to": target}
|
||
if note:
|
||
payload["note"] = note
|
||
await _log(session, ticket, ctx.user.id, f"ticket_{action}", payload)
|
||
|
||
# 通知对家:验收找提需求的人,其余找办理人
|
||
if action == "submit":
|
||
await _notify(
|
||
session,
|
||
ws_id=ctx.ws_id,
|
||
user_id=ext.requester_id,
|
||
kind="ticket_submitted",
|
||
item_id=ticket.id,
|
||
title=f"工单待你验收:{ticket.title}",
|
||
body=note or "办理人已提交,请确认结果",
|
||
)
|
||
elif action == "approve":
|
||
await _notify(
|
||
session,
|
||
ws_id=ctx.ws_id,
|
||
user_id=ext.assignee_id,
|
||
kind="ticket_approved",
|
||
item_id=ticket.id,
|
||
title=f"工单已通过验收:{ticket.title}",
|
||
body=note or "谢谢,这事结了",
|
||
)
|
||
elif action == "reject":
|
||
await _notify(
|
||
session,
|
||
ws_id=ctx.ws_id,
|
||
user_id=ext.assignee_id,
|
||
kind="ticket_rejected",
|
||
item_id=ticket.id,
|
||
title=f"工单被退回:{ticket.title}",
|
||
body=note or "请按反馈再改一版",
|
||
)
|
||
|
||
# 验收通过 -> 原任务闭环回流
|
||
returned = returned_task_status(target)
|
||
if returned and ext.parent_item_id:
|
||
parent = await session.get(Item, ext.parent_item_id)
|
||
if parent is not None and parent.workspace_id == ctx.ws_id and parent.status == "blocked":
|
||
parent.status = returned
|
||
parent.version += 1
|
||
parent.updated_at = now
|
||
await _log(
|
||
session,
|
||
parent,
|
||
ctx.user.id,
|
||
"ticket_done",
|
||
{"ticket_id": str(ticket.id), "status": returned},
|
||
)
|
||
await _notify(
|
||
session,
|
||
ws_id=ctx.ws_id,
|
||
user_id=parent.creator_id,
|
||
kind="task_unblocked",
|
||
item_id=parent.id,
|
||
title=f"任务可以继续了:{parent.title}",
|
||
body="它对应的工单已验收通过",
|
||
)
|
||
|
||
await session.flush()
|
||
return await _ticket_out(session, ctx.ws_id, ticket, ext)
|
||
|
||
|
||
@router.post("/items/{item_id}/ticket/accept", summary="接受工单")
|
||
async def accept(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
|
||
return await _advance(ctx, session, item_id, action="accept")
|
||
|
||
|
||
@router.post("/items/{item_id}/ticket/start", summary="开始办理")
|
||
async def start(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
|
||
return await _advance(ctx, session, item_id, action="start")
|
||
|
||
|
||
@router.post("/items/{item_id}/ticket/submit", summary="提交验收")
|
||
async def submit(item_id: uuid.UUID, body: ActionIn, ctx: MemberDep, session: SessionDep) -> dict:
|
||
return await _advance(
|
||
ctx, session, item_id, action="submit", note=body.note, version=body.version
|
||
)
|
||
|
||
|
||
@router.post("/items/{item_id}/ticket/verify", summary="验收:通过或驳回")
|
||
async def verify(item_id: uuid.UUID, body: VerifyIn, ctx: MemberDep, session: SessionDep) -> dict:
|
||
return await _advance(
|
||
ctx,
|
||
session,
|
||
item_id,
|
||
action="approve" if body.approve else "reject",
|
||
note=body.note,
|
||
approve=body.approve,
|
||
resolution_md=body.resolution_md,
|
||
)
|
||
|
||
|
||
@router.post("/items/{item_id}/ticket/cancel", summary="取消工单")
|
||
async def cancel(item_id: uuid.UUID, body: ActionIn, ctx: MemberDep, session: SessionDep) -> dict:
|
||
return await _advance(
|
||
ctx, session, item_id, action="cancel", note=body.note, version=body.version
|
||
)
|
||
|
||
|
||
@router.post("/items/{item_id}/ticket/reassign", summary="改派给同事")
|
||
async def reassign(
|
||
item_id: uuid.UUID, body: ReassignIn, ctx: MemberDep, session: SessionDep
|
||
) -> dict:
|
||
ticket = await _load_item(session, ctx.ws_id, item_id, ("ticket",))
|
||
ext = await _load_ext(session, ticket.id)
|
||
|
||
ensure_can_act(
|
||
action="reassign",
|
||
actor_id=ctx.user.id,
|
||
role=ctx.role,
|
||
assignee_id=ext.assignee_id,
|
||
requester_id=ext.requester_id,
|
||
)
|
||
if ticket.status not in CANCELABLE:
|
||
raise AppError(
|
||
ErrorCode.TICKET_STATUS_INVALID,
|
||
f"「{ticket.status}」的工单不能改派",
|
||
details={"allowed_from": list(CANCELABLE)},
|
||
)
|
||
if body.assignee_id == ext.assignee_id:
|
||
return await _ticket_out(session, ctx.ws_id, ticket, ext)
|
||
|
||
await _active_member_id(session, ctx.ws_id, body.assignee_id)
|
||
|
||
await _patch_item(
|
||
session,
|
||
ticket,
|
||
{"status": "open", "assignee_id": body.assignee_id, "completed_at": None},
|
||
expect_version=None,
|
||
)
|
||
ext.assignee_id = body.assignee_id
|
||
ext.accepted_at = None
|
||
ext.submitted_at = None
|
||
await _log(session, ticket, ctx.user.id, "ticket_reassigned", {"to": str(body.assignee_id)})
|
||
await _notify(
|
||
session,
|
||
ws_id=ctx.ws_id,
|
||
user_id=body.assignee_id,
|
||
kind="ticket_assigned",
|
||
item_id=ticket.id,
|
||
title=f"转给你一条工单:{ticket.title}",
|
||
body="请先接收,再开始办理",
|
||
)
|
||
await session.flush()
|
||
return await _ticket_out(session, ctx.ws_id, ticket, ext)
|
||
|
||
|
||
# -- 工单列表 -----------------------------------------------------------
|
||
@router.get("/tickets", summary="工单列表:我指派的 / 我发起的")
|
||
async def list_tickets(
|
||
ctx: MemberDep,
|
||
session: SessionDep,
|
||
role: Annotated[str, Query(description="assigned|requested|all")] = "assigned",
|
||
status: Annotated[list[str] | None, Query(description="按状态过滤,可重复")] = None,
|
||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||
cursor: Annotated[str | None, Query()] = None,
|
||
) -> dict:
|
||
page = Pagination(limit=limit, cursor=cursor)
|
||
limit, parsed_cursor = page.parsed()
|
||
|
||
stmt: Select = (
|
||
select(Item, TicketExt)
|
||
.join(TicketExt, TicketExt.item_id == Item.id)
|
||
.where(Item.workspace_id == ctx.ws_id, Item.kind == "ticket", Item.deleted_at.is_(None))
|
||
)
|
||
if role == "assigned":
|
||
stmt = stmt.where(TicketExt.assignee_id == ctx.user.id)
|
||
elif role == "requested":
|
||
stmt = stmt.where(TicketExt.requester_id == ctx.user.id)
|
||
elif role != "all":
|
||
raise AppError(ErrorCode.BAD_REQUEST, "role 只能是 assigned / requested / all")
|
||
|
||
if status:
|
||
stmt = stmt.where(Item.status.in_(status))
|
||
|
||
stmt = stmt.order_by(Item.updated_at.desc(), Item.id.desc()).limit(limit + 1)
|
||
if parsed_cursor is not None:
|
||
stmt = stmt.where(
|
||
or_(
|
||
Item.updated_at < parsed_cursor.created_at,
|
||
and_(Item.updated_at == parsed_cursor.created_at, Item.id < parsed_cursor.id),
|
||
)
|
||
)
|
||
|
||
rows = (await session.execute(stmt)).all()
|
||
has_more = len(rows) > limit
|
||
rows = rows[:limit]
|
||
next_cursor = (
|
||
encode_cursor(rows[-1][0].updated_at, rows[-1][0].id) if has_more and rows else None
|
||
)
|
||
tickets = await _items_out(session, ctx.ws_id, [item for item, _ in rows])
|
||
return ok(
|
||
{
|
||
"tickets": [
|
||
{**out, "ticket": _ticket_ext_out(ext)}
|
||
for out, (_, ext) in zip(tickets, rows, strict=True)
|
||
],
|
||
"next_cursor": next_cursor,
|
||
"has_more": has_more,
|
||
}
|
||
)
|
||
|
||
|
||
# -- 事项关系(子任务 / 相关)------------------------------------------
|
||
@router.get("/items/{item_id}/links", summary="这条事项的子项与相关项")
|
||
async def list_links(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
|
||
await _load_item(session, ctx.ws_id, item_id, ("task", "ticket"))
|
||
|
||
# parent 边的方向:from 是 to 的子项。所以"我的子项"= 指向我、且类型为 parent 的 from。
|
||
children = (
|
||
await session.execute(
|
||
select(ItemLink, Item)
|
||
.join(Item, Item.id == ItemLink.from_item_id)
|
||
.where(
|
||
ItemLink.workspace_id == ctx.ws_id,
|
||
ItemLink.to_item_id == item_id,
|
||
ItemLink.link_type == "parent",
|
||
)
|
||
)
|
||
).all()
|
||
parents = (
|
||
await session.execute(
|
||
select(ItemLink, Item)
|
||
.join(Item, Item.id == ItemLink.to_item_id)
|
||
.where(
|
||
ItemLink.workspace_id == ctx.ws_id,
|
||
ItemLink.from_item_id == item_id,
|
||
ItemLink.link_type == "parent",
|
||
)
|
||
)
|
||
).all()
|
||
related = (
|
||
await session.execute(
|
||
select(ItemLink, Item)
|
||
.join(
|
||
Item,
|
||
or_(
|
||
and_(ItemLink.from_item_id == item_id, Item.id == ItemLink.to_item_id),
|
||
and_(ItemLink.to_item_id == item_id, Item.id == ItemLink.from_item_id),
|
||
),
|
||
)
|
||
.where(
|
||
ItemLink.workspace_id == ctx.ws_id,
|
||
ItemLink.link_type == "related",
|
||
or_(ItemLink.from_item_id == item_id, ItemLink.to_item_id == item_id),
|
||
)
|
||
)
|
||
).all()
|
||
|
||
async def _pack(rows: list[Any]) -> list[dict[str, Any]]:
|
||
packed = await _items_out(session, ctx.ws_id, [item for _, item in rows])
|
||
return [
|
||
{"link_id": str(link.id), "link_type": link.link_type, "item": out}
|
||
for (link, _), out in zip(rows, packed, strict=True)
|
||
]
|
||
|
||
return ok(
|
||
{
|
||
"children": await _pack(children),
|
||
"parents": await _pack(parents),
|
||
"related": await _pack(related),
|
||
}
|
||
)
|
||
|
||
|
||
@router.post("/items/{item_id}/links", summary="把另一条事项挂成子项/相关项")
|
||
async def create_link(
|
||
item_id: uuid.UUID, body: LinkIn, ctx: MemberDep, session: SessionDep
|
||
) -> dict:
|
||
if body.link_type not in ("parent", "related"):
|
||
raise AppError(ErrorCode.BAD_REQUEST, "link_type 只能是 parent / related")
|
||
if body.to_item_id == item_id:
|
||
raise AppError(ErrorCode.LINK_CONFLICT, "不能把自己挂到自己下面")
|
||
|
||
await _load_item(session, ctx.ws_id, body.to_item_id, ("task", "ticket"))
|
||
await _load_item(session, ctx.ws_id, item_id, ("task", "ticket"))
|
||
|
||
existing = (
|
||
await session.execute(
|
||
select(ItemLink).where(
|
||
ItemLink.from_item_id == body.to_item_id,
|
||
ItemLink.to_item_id == item_id,
|
||
ItemLink.link_type == body.link_type,
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if existing is not None:
|
||
raise AppError(ErrorCode.LINK_CONFLICT, "这条关系已经存在")
|
||
|
||
link = ItemLink(
|
||
workspace_id=ctx.ws_id,
|
||
from_item_id=body.to_item_id,
|
||
to_item_id=item_id,
|
||
link_type=body.link_type,
|
||
)
|
||
session.add(link)
|
||
await session.flush()
|
||
return ok({"id": str(link.id), "link_type": link.link_type})
|
||
|
||
|
||
@router.delete("/items/{item_id}/links/{link_id}", summary="解除关系")
|
||
async def delete_link(
|
||
item_id: uuid.UUID, link_id: uuid.UUID, ctx: MemberDep, session: SessionDep
|
||
) -> dict:
|
||
link = await session.get(ItemLink, link_id)
|
||
if (
|
||
link is None
|
||
or link.workspace_id != ctx.ws_id
|
||
or item_id
|
||
not in (
|
||
link.from_item_id,
|
||
link.to_item_id,
|
||
)
|
||
):
|
||
raise AppError(ErrorCode.LINK_NOT_FOUND, "关系不存在")
|
||
await session.delete(link)
|
||
return ok({"deleted": True})
|
||
|
||
|
||
# -- 通知 ---------------------------------------------------------------
|
||
def _notification_out(n: Notification, item_title: str | None = None) -> dict[str, Any]:
|
||
return {
|
||
"id": str(n.id),
|
||
"kind": n.kind,
|
||
"item_id": str(n.item_id) if n.item_id else None,
|
||
"item_title": item_title,
|
||
"title": n.title,
|
||
"body": n.body,
|
||
"read_at": n.read_at,
|
||
"created_at": n.created_at,
|
||
}
|
||
|
||
|
||
@notify_router.get("", summary="我的通知(默认只看未读)")
|
||
async def list_notifications(
|
||
ctx: MemberDep,
|
||
session: SessionDep,
|
||
unread_only: bool = True,
|
||
limit: Annotated[int, Query(ge=1, le=100)] = 30,
|
||
cursor: Annotated[str | None, Query()] = None,
|
||
) -> dict:
|
||
page = Pagination(limit=limit, cursor=cursor)
|
||
limit, parsed_cursor = page.parsed()
|
||
|
||
stmt = select(Notification).where(
|
||
Notification.workspace_id == ctx.ws_id, Notification.user_id == ctx.user.id
|
||
)
|
||
if unread_only:
|
||
stmt = stmt.where(Notification.read_at.is_(None))
|
||
stmt = stmt.order_by(Notification.created_at.desc(), Notification.id.desc()).limit(limit + 1)
|
||
if parsed_cursor is not None:
|
||
stmt = stmt.where(
|
||
or_(
|
||
Notification.created_at < parsed_cursor.created_at,
|
||
and_(
|
||
Notification.created_at == parsed_cursor.created_at,
|
||
Notification.id < parsed_cursor.id,
|
||
),
|
||
)
|
||
)
|
||
|
||
rows = (await session.execute(stmt)).scalars().all()
|
||
has_more = len(rows) > limit
|
||
rows = list(rows[:limit])
|
||
|
||
titles: dict[uuid.UUID, str] = {}
|
||
item_ids = [n.item_id for n in rows if n.item_id]
|
||
if item_ids:
|
||
for item_id, title in (
|
||
await session.execute(select(Item.id, Item.title).where(Item.id.in_(item_ids)))
|
||
).all():
|
||
titles[item_id] = title
|
||
|
||
next_cursor = encode_cursor(rows[-1].created_at, rows[-1].id) if has_more and rows else None
|
||
return ok(
|
||
{
|
||
"notifications": [
|
||
_notification_out(n, titles.get(n.item_id) if n.item_id else None) for n in rows
|
||
],
|
||
"next_cursor": next_cursor,
|
||
"has_more": has_more,
|
||
}
|
||
)
|
||
|
||
|
||
@notify_router.get("/unread-count", summary="未读数(导航角标用)")
|
||
async def unread_count(ctx: MemberDep, session: SessionDep) -> dict:
|
||
from sqlalchemy import func as sa_func
|
||
|
||
total = (
|
||
await session.execute(
|
||
select(sa_func.count(Notification.id)).where(
|
||
Notification.workspace_id == ctx.ws_id,
|
||
Notification.user_id == ctx.user.id,
|
||
Notification.read_at.is_(None),
|
||
)
|
||
)
|
||
).scalar_one()
|
||
return ok({"unread": int(total)})
|
||
|
||
|
||
@notify_router.post("/{notification_id}/read", summary="标记已读")
|
||
async def mark_read(notification_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
|
||
n = await session.get(Notification, notification_id)
|
||
if n is None or n.workspace_id != ctx.ws_id or n.user_id != ctx.user.id:
|
||
raise AppError(ErrorCode.NOTIFICATION_NOT_FOUND, "通知不存在")
|
||
if n.read_at is None:
|
||
n.read_at = datetime.now(timezone.utc)
|
||
return ok({"read_at": n.read_at})
|
||
|
||
|
||
@notify_router.post("/read-all", summary="全部标记已读")
|
||
async def mark_all_read(ctx: MemberDep, session: SessionDep) -> dict:
|
||
from sqlalchemy import update as sa_update
|
||
|
||
result = await session.execute(
|
||
sa_update(Notification)
|
||
.where(
|
||
Notification.workspace_id == ctx.ws_id,
|
||
Notification.user_id == ctx.user.id,
|
||
Notification.read_at.is_(None),
|
||
)
|
||
.values(read_at=datetime.now(timezone.utc))
|
||
)
|
||
return ok({"marked": result.rowcount})
|
||
|
||
|
||
__all__ = ["notify_router", "router"]
|