Files
DadaoTask/backend/app/domain/task.py
T
Steven e25a1a4e31
CI / backend (push) Canceled after 0s
feat(task): M1 任务域后端与 Web 列表(状态机/乐观锁/视图/搜索)
- 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 按已验证口径重写
2026-10-28 03:48:48 +08:00

101 lines
3.6 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.
"""任务状态机与"视图"口径。
两条原则:
1. **状态机是契约**:客户端据此置灰按钮,服务端必须再校验一次。
任何"直接改 status 列"的代码路径都是 bug。
2. **视图是查询口径,不是状态**:`today` / `week` 是筛选条件,不往 item 上落字段,
否则每天都要批量刷库,还会因为时区边界出错。
"""
from __future__ import annotations
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from ..errors import AppError, ErrorCode
# 允许的流转。没列出的就是禁止的(显式白名单,默认拒绝)。
TRANSITIONS: dict[str, frozenset[str]] = {
# 待整理:进来先放着,决定做或不做
"inbox": frozenset({"todo", "canceled"}),
# 待办:可以开工,也可以直接标记完成(写文档类的事不需要"进行中")
"todo": frozenset({"inbox", "doing", "done", "canceled"}),
# 进行中
"doing": frozenset({"todo", "done", "blocked", "canceled"}),
# 被阻塞(M2 起:任务升级成工单会自动进这里)
"blocked": frozenset({"doing", "canceled"}),
# 已完成:只能重开
"done": frozenset({"todo"}),
# 已取消:只能重开
"canceled": frozenset({"todo"}),
}
# 表示"这件事还没完"的状态
OPEN_STATUSES: tuple[str, ...] = ("inbox", "todo", "doing", "blocked")
ACTIVE_STATUSES: tuple[str, ...] = ("todo", "doing", "blocked")
ALL_STATUSES: tuple[str, ...] = tuple(TRANSITIONS)
# 视图 → 状态集合。`today` / `week` 还要叠加截止时间窗口(见 due_window)
VIEW_STATUSES: dict[str, tuple[str, ...]] = {
"inbox": ("inbox",),
"active": ACTIVE_STATUSES,
"today": ACTIVE_STATUSES,
"week": ACTIVE_STATUSES,
"done": ("done",),
"canceled": ("canceled",),
"all": ALL_STATUSES,
}
DEFAULT_VIEW = "active"
def can_transition(current: str, target: str) -> bool:
return target in TRANSITIONS.get(current, frozenset())
def ensure_transition(current: str, target: str) -> None:
"""非法流转直接拒绝,并把"当前能去哪"一起返回,客户端不用自己猜。"""
if current == target:
return
if not can_transition(current, target):
raise AppError(
ErrorCode.ITEM_STATUS_INVALID,
f"状态不能从 {current} 直接变成 {target}",
details={
"from": current,
"to": target,
"allowed": sorted(TRANSITIONS.get(current, ())),
},
)
def apply_status(item: dict, target: str, *, now: datetime) -> None:
"""就地更新 status/completed_at。`completed_at` 只在 done 时有值。"""
item["status"] = target
item["completed_at"] = now if target == "done" else None
def view_statuses(view: str | None) -> tuple[str, ...]:
return VIEW_STATUSES.get(view or DEFAULT_VIEW, ACTIVE_STATUSES)
def due_window(
view: str | None, *, now: datetime, tz: str
) -> tuple[datetime | None, datetime | None]:
"""返回 (due_from, due_to) 半开区间 `[from, to)`。
`today` = 今天 00:00(含)到明天 00:00(不含),按**用户时区**算,
不是 UTC —— 中国用户 23:30 创建"今天到期"的任务,UTC 下已经是明天了。
"""
if view not in ("today", "week"):
return None, None
zone = ZoneInfo(tz)
local_now = now.astimezone(zone)
start = local_now.replace(hour=0, minute=0, second=0, microsecond=0)
if view == "today":
end = start + timedelta(days=1)
else:
# 本周剩余:到今天所在自然周的周日结束
end = start + timedelta(days=7 - start.weekday())
return start, end