feat: add task and habit reordering
ci / docker (push) Successful in 3m33s

This commit is contained in:
2026-09-08 06:51:31 +08:00
parent a02b6f660c
commit 988d131d9b
13 changed files with 329 additions and 22 deletions
+52 -10
View File
@@ -45,6 +45,7 @@ from .schemas import (
TaskDetailOut,
TaskOut,
TaskPage,
TaskReorder,
TaskUpdate,
UserOut,
)
@@ -434,16 +435,16 @@ async def restore_list(
def _encode_cursor(created_at: datetime, task_id: UUID) -> str:
raw = json.dumps([created_at.isoformat(), str(task_id)]).encode()
def _encode_cursor(position: int, created_at: datetime, task_id: UUID) -> str:
raw = json.dumps([position, created_at.isoformat(), str(task_id)]).encode()
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
def _decode_cursor(cursor: str) -> tuple[datetime, UUID]:
def _decode_cursor(cursor: str) -> tuple[int, datetime, UUID]:
try:
raw = base64.urlsafe_b64decode(cursor + "=" * (-len(cursor) % 4))
timestamp, task_id = json.loads(raw)
return datetime.fromisoformat(timestamp), UUID(task_id)
position, timestamp, task_id = json.loads(raw)
return int(position), datetime.fromisoformat(timestamp), UUID(task_id)
except (ValueError, TypeError, json.JSONDecodeError) as exc:
raise HTTPException(status_code=422, detail="无效的游标") from exc
@@ -468,7 +469,14 @@ async def create_task(
if parent is None:
raise HTTPException(status_code=400, detail="父任务必须是同一清单的顶层任务")
data = payload.model_dump()
task = Task(user_id=user.id, **data)
parent_filter = Task.parent_id == payload.parent_id if payload.parent_id else Task.parent_id.is_(None)
max_position = await db.scalar(select(func.max(Task.position)).where(
Task.user_id == user.id,
Task.list_id == payload.list_id,
parent_filter,
Task.deleted_at.is_(None),
))
task = Task(user_id=user.id, position=(max_position if max_position is not None else -1) + 1, **data)
db.add(task)
await db.flush()
audit(db, user.id, "create", "task", task.id)
@@ -516,20 +524,24 @@ async def list_tasks(
or_(Task.title.ilike(pattern), Task.description.ilike(pattern), list_match)
)
total = await db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0
ordering = (Task.created_at, Task.id)
ordering = (Task.position, Task.created_at, Task.id)
if page is not None:
size = page_size or limit
items = list((await db.scalars(query.order_by(*ordering).offset((page - 1) * size).limit(size))).all())
return TaskPage(items=await _task_details(db, items), total=total, page=page, page_size=size)
if cursor:
created_at, task_id = _decode_cursor(cursor)
position, created_at, task_id = _decode_cursor(cursor)
query = query.where(
or_(Task.created_at > created_at, (Task.created_at == created_at) & (Task.id > task_id))
or_(
Task.position > position,
(Task.position == position) & (Task.created_at > created_at),
(Task.position == position) & (Task.created_at == created_at) & (Task.id > task_id),
)
)
rows = list((await db.scalars(query.order_by(*ordering).limit(limit + 1))).all())
has_more = len(rows) > limit
items = rows[:limit]
next_cursor = _encode_cursor(items[-1].created_at, items[-1].id) if has_more else None
next_cursor = _encode_cursor(items[-1].position, items[-1].created_at, items[-1].id) if has_more else None
return TaskPage(items=await _task_details(db, items), next_cursor=next_cursor, total=total, page=1, page_size=limit)
@@ -561,6 +573,36 @@ async def _task_detail(db: AsyncSession, task: Task) -> TaskDetailOut:
return (await _task_details(db, [task]))[0]
@app.put("/api/v1/tasks/reorder", status_code=204)
async def reorder_tasks(
payload: TaskReorder,
user: User = Depends(current_user),
db: AsyncSession = Depends(get_db),
):
rows = list((await db.scalars(select(Task).where(
Task.id.in_(payload.task_ids), Task.user_id == user.id, Task.deleted_at.is_(None)
))).all())
if len(rows) != len(payload.task_ids):
raise HTTPException(status_code=404, detail="任务不存在")
parent_scopes = {row.parent_id for row in rows}
if len(parent_scopes) != 1:
raise HTTPException(status_code=400, detail="只能调整同一层级任务的顺序")
parent_id = next(iter(parent_scopes))
scope_query = select(Task).where(
Task.user_id == user.id,
Task.deleted_at.is_(None),
Task.parent_id.is_(None) if parent_id is None else Task.parent_id == parent_id,
).order_by(Task.position, Task.created_at, Task.id)
scope_rows = list((await db.scalars(scope_query)).all())
requested = set(payload.task_ids)
ordered_rows = iter([next(row for row in rows if row.id == task_id) for task_id in payload.task_ids])
merged = [next(ordered_rows) if row.id in requested else row for row in scope_rows]
for position, row in enumerate(merged):
row.position = position
await db.commit()
return Response(status_code=204)
@app.get("/api/v1/tasks/{task_id}", response_model=TaskDetailOut)
async def get_task(
task_id: UUID,