CI / backend (push) Canceled after 0s
- 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 按已验证口径重写
101 lines
3.6 KiB
Python
101 lines
3.6 KiB
Python
"""任务状态机与"视图"口径。
|
||
|
||
两条原则:
|
||
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
|