fix: audit findings — list details, batch integrity, migrations, restore fidelity
ci / docker (push) Successful in 3m17s
ci / docker (push) Successful in 3m17s
This commit is contained in:
+57
-20
@@ -39,6 +39,7 @@ from .schemas import (
|
||||
ListOut,
|
||||
LoginRequest,
|
||||
NameUpdate,
|
||||
SessionOut,
|
||||
TagCreate,
|
||||
TagOut,
|
||||
TaskCreate,
|
||||
@@ -213,7 +214,7 @@ async def logout(
|
||||
return Response(status_code=204, headers=response.headers)
|
||||
|
||||
|
||||
@app.get("/api/v1/sessions")
|
||||
@app.get("/api/v1/sessions", response_model=list[SessionOut])
|
||||
async def list_sessions(
|
||||
token: str = Depends(session_token),
|
||||
user: User = Depends(current_user),
|
||||
@@ -559,7 +560,7 @@ async def list_tasks(
|
||||
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=items, total=total, page=page, page_size=size)
|
||||
return TaskPage(items=await _task_details(db, items), total=total, page=page, page_size=size)
|
||||
if cursor:
|
||||
created_at, task_id = _decode_cursor(cursor)
|
||||
query = query.where(
|
||||
@@ -569,31 +570,48 @@ async def list_tasks(
|
||||
has_more = len(rows) > limit
|
||||
items = rows[:limit]
|
||||
next_cursor = _encode_cursor(items[-1].created_at, items[-1].id) if has_more else None
|
||||
return TaskPage(items=items, next_cursor=next_cursor, total=total, page=1, page_size=limit)
|
||||
return TaskPage(items=await _task_details(db, items), next_cursor=next_cursor, total=total, page=1, page_size=limit)
|
||||
|
||||
|
||||
async def _task_detail(db: AsyncSession, task: Task) -> TaskDetailOut:
|
||||
tags = list(
|
||||
(
|
||||
await db.scalars(
|
||||
select(Tag)
|
||||
.join(TaskTag, TaskTag.tag_id == Tag.id)
|
||||
.where(TaskTag.task_id == task.id)
|
||||
.order_by(Tag.name, Tag.id)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
async def _task_details(db: AsyncSession, tasks: list[Task]) -> list[TaskDetailOut]:
|
||||
if not tasks:
|
||||
return []
|
||||
task_ids = [task.id for task in tasks]
|
||||
tag_rows = (
|
||||
await db.execute(
|
||||
select(TaskTag.task_id, Tag)
|
||||
.join(Tag, Tag.id == TaskTag.tag_id)
|
||||
.where(TaskTag.task_id.in_(task_ids))
|
||||
.order_by(Tag.name, Tag.id)
|
||||
)
|
||||
).all()
|
||||
subtasks = list(
|
||||
(
|
||||
await db.scalars(
|
||||
select(Task)
|
||||
.where(Task.parent_id == task.id, Task.deleted_at.is_(None))
|
||||
.where(Task.parent_id.in_(task_ids), Task.deleted_at.is_(None))
|
||||
.order_by(Task.position, Task.created_at, Task.id)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
data = TaskOut.model_validate(task).model_dump()
|
||||
return TaskDetailOut(**data, tags=tags, subtasks=subtasks)
|
||||
tags_by_task: dict[UUID, list[Tag]] = defaultdict(list)
|
||||
subtasks_by_task: dict[UUID, list[Task]] = defaultdict(list)
|
||||
for task_id, tag in tag_rows:
|
||||
tags_by_task[task_id].append(tag)
|
||||
for subtask in subtasks:
|
||||
subtasks_by_task[subtask.parent_id].append(subtask)
|
||||
return [
|
||||
TaskDetailOut(
|
||||
**TaskOut.model_validate(task).model_dump(),
|
||||
tags=tags_by_task[task.id],
|
||||
subtasks=subtasks_by_task[task.id],
|
||||
)
|
||||
for task in tasks
|
||||
]
|
||||
|
||||
|
||||
async def _task_detail(db: AsyncSession, task: Task) -> TaskDetailOut:
|
||||
return (await _task_details(db, [task]))[0]
|
||||
|
||||
|
||||
@app.get("/api/v1/tasks/{task_id}", response_model=TaskDetailOut)
|
||||
@@ -707,7 +725,7 @@ async def list_trash(
|
||||
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=items, total=total, page=page, page_size=size)
|
||||
return TaskPage(items=await _task_details(db, items), total=total, page=page, page_size=size)
|
||||
if cursor:
|
||||
created_at, task_id = _decode_cursor(cursor)
|
||||
query = query.where(
|
||||
@@ -717,7 +735,7 @@ async def list_trash(
|
||||
has_more = len(rows) > limit
|
||||
items = rows[:limit]
|
||||
next_cursor = _encode_cursor(items[-1].created_at, items[-1].id) if has_more else None
|
||||
return TaskPage(items=items, next_cursor=next_cursor, total=total, page=1, page_size=limit)
|
||||
return TaskPage(items=await _task_details(db, items), next_cursor=next_cursor, total=total, page=1, page_size=limit)
|
||||
|
||||
|
||||
@app.post("/api/v1/tasks/{task_id}/restore", response_model=TaskDetailOut)
|
||||
@@ -791,6 +809,9 @@ async def batch_update_tasks(
|
||||
raise HTTPException(status_code=404, detail="一个或多个任务不存在")
|
||||
if payload.list_id is not None:
|
||||
await _owned_list(db, user.id, payload.list_id)
|
||||
standalone_children = [task for task in tasks if task.parent_id is not None]
|
||||
if standalone_children:
|
||||
raise HTTPException(status_code=400, detail="子任务不能脱离父任务单独移动")
|
||||
tag_ids = await _validate_tags(db, user.id, payload.tag_ids)
|
||||
changes = payload.model_dump(exclude_unset=True, exclude={"task_ids", "tag_ids", "soft_delete"})
|
||||
if payload.soft_delete:
|
||||
@@ -798,9 +819,25 @@ async def batch_update_tasks(
|
||||
if changes:
|
||||
changes["version"] = Task.version + 1
|
||||
changes["updated_at"] = utcnow()
|
||||
target_ids = set(task_ids)
|
||||
if payload.soft_delete:
|
||||
parent_ids = [task.id for task in tasks if task.parent_id is None]
|
||||
if parent_ids:
|
||||
child_ids = list(
|
||||
(
|
||||
await db.scalars(
|
||||
select(Task.id).where(
|
||||
Task.parent_id.in_(parent_ids),
|
||||
Task.user_id == user.id,
|
||||
Task.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
target_ids.update(child_ids)
|
||||
await db.execute(
|
||||
update(Task)
|
||||
.where(Task.id.in_(task_ids), Task.user_id == user.id, Task.deleted_at.is_(None))
|
||||
.where(Task.id.in_(target_ids), Task.user_id == user.id, Task.deleted_at.is_(None))
|
||||
.values(**changes)
|
||||
)
|
||||
if payload.list_id is not None:
|
||||
|
||||
Reference in New Issue
Block a user