feat: organize lists with drag and drop
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 3m22s

This commit is contained in:
2026-09-09 20:28:29 +08:00
parent 30705e1cec
commit 0e291ddb9c
8 changed files with 679 additions and 8 deletions
+150 -1
View File
@@ -37,7 +37,9 @@ from .schemas import (
FolderOut,
InitializeRequest,
ListCreate,
ListMove,
ListOut,
ListReorder,
LoginRequest,
NameUpdate,
SessionOut,
@@ -320,6 +322,12 @@ async def delete_folder(
return Response(status_code=204)
async def _lock_list_order(db: AsyncSession, user_id: UUID) -> None:
# Serialize list creation/moves/reorders per user so max-position allocation
# and full-scope reorder validation cannot interleave.
await db.scalar(select(User.id).where(User.id == user_id).with_for_update())
@app.post("/api/v1/lists", response_model=ListOut, status_code=201)
async def create_list(
payload: ListCreate,
@@ -334,7 +342,26 @@ async def create_list(
)
):
raise HTTPException(status_code=404, detail="文件夹不存在")
item = TaskList(user_id=user.id, folder_id=payload.folder_id, name=payload.name)
await _lock_list_order(db, user.id)
folder_scope = (
TaskList.folder_id == payload.folder_id
if payload.folder_id is not None
else TaskList.folder_id.is_(None)
)
max_position = await db.scalar(
select(func.max(TaskList.position)).where(
TaskList.user_id == user.id,
folder_scope,
TaskList.is_inbox.is_(False),
TaskList.deleted_at.is_(None),
)
)
item = TaskList(
user_id=user.id,
folder_id=payload.folder_id,
name=payload.name,
position=(max_position if max_position is not None else -1) + 1,
)
db.add(item)
await db.flush()
audit(db, user.id, "create", "list", item.id)
@@ -371,6 +398,128 @@ async def _owned_list(
return item
@app.put("/api/v1/lists/reorder", status_code=204)
async def reorder_lists(
payload: ListReorder,
user: User = Depends(current_user),
db: AsyncSession = Depends(get_db),
):
if payload.folder_id is not None:
folder = await db.scalar(
select(Folder).where(
Folder.id == payload.folder_id,
Folder.user_id == user.id,
Folder.deleted_at.is_(None),
)
)
if folder is None:
raise HTTPException(status_code=404, detail="文件夹不存在")
await _lock_list_order(db, user.id)
folder_scope = (
TaskList.folder_id == payload.folder_id
if payload.folder_id is not None
else TaskList.folder_id.is_(None)
)
scope_rows = list(
(
await db.scalars(
select(TaskList)
.where(
TaskList.user_id == user.id,
folder_scope,
TaskList.is_inbox.is_(False),
TaskList.deleted_at.is_(None),
)
.order_by(TaskList.position, TaskList.created_at, TaskList.id)
.with_for_update()
)
).all()
)
if any(list_id not in {row.id for row in scope_rows} for list_id in payload.list_ids):
inbox_requested = await db.scalar(
select(TaskList.id).where(
TaskList.id.in_(payload.list_ids),
TaskList.user_id == user.id,
TaskList.is_inbox.is_(True),
)
)
if inbox_requested:
raise HTTPException(status_code=409, detail="系统收集箱不能排序")
raise HTTPException(status_code=409, detail="清单不属于指定作用域")
if set(payload.list_ids) != {row.id for row in scope_rows}:
raise HTTPException(status_code=409, detail="清单顺序已变化,请刷新后重试")
rows_by_id = {row.id: row for row in scope_rows}
for position, list_id in enumerate(payload.list_ids):
rows_by_id[list_id].position = position
await db.commit()
return Response(status_code=204)
@app.put("/api/v1/lists/{list_id}/move", response_model=ListOut)
async def move_list_to_folder(
list_id: UUID,
payload: ListMove,
user: User = Depends(current_user),
db: AsyncSession = Depends(get_db),
):
await _lock_list_order(db, user.id)
item = await _owned_list(db, user.id, list_id)
if item.is_inbox:
raise HTTPException(status_code=409, detail="系统收集箱不能移动")
if payload.folder_id is not None:
folder = await db.scalar(
select(Folder).where(
Folder.id == payload.folder_id,
Folder.user_id == user.id,
Folder.deleted_at.is_(None),
)
)
if folder is None:
raise HTTPException(status_code=404, detail="文件夹不存在")
target_scope = (
TaskList.folder_id == payload.folder_id
if payload.folder_id is not None
else TaskList.folder_id.is_(None)
)
target_rows = list(
(
await db.scalars(
select(TaskList)
.where(
TaskList.user_id == user.id,
target_scope,
TaskList.is_inbox.is_(False),
TaskList.deleted_at.is_(None),
TaskList.id != item.id,
)
.order_by(TaskList.position, TaskList.created_at, TaskList.id)
.with_for_update()
)
).all()
)
expected_ids = {row.id for row in target_rows} | {item.id}
if set(payload.list_ids) != expected_ids:
raise HTTPException(status_code=409, detail="目标清单顺序已变化,请刷新后重试")
rows_by_id = {row.id: row for row in target_rows}
rows_by_id[item.id] = item
item.folder_id = payload.folder_id
for position, ordered_id in enumerate(payload.list_ids):
rows_by_id[ordered_id].position = position
await db.flush()
audit(
db,
user.id,
"move",
"list",
item.id,
folder_id=str(payload.folder_id) if payload.folder_id else None,
)
await db.commit()
await db.refresh(item)
return item
@app.patch("/api/v1/lists/{list_id}", response_model=ListOut)
async def rename_list(
list_id: UUID,
+22
View File
@@ -74,8 +74,30 @@ class ListOut(BaseModel):
folder_id: UUID | None
name: str
is_inbox: bool
position: int
class ListMove(BaseModel):
folder_id: UUID | None
list_ids: list[UUID] = Field(min_length=1)
@model_validator(mode="after")
def unique_ids(self):
if len(self.list_ids) != len(set(self.list_ids)):
raise ValueError("list_ids must be unique")
return self
class ListReorder(BaseModel):
folder_id: UUID | None
list_ids: list[UUID] = Field(min_length=1)
@model_validator(mode="after")
def unique_ids(self):
if len(self.list_ids) != len(set(self.list_ids)):
raise ValueError("list_ids must be unique")
return self
class TaskCreate(BaseModel):
title: str = Field(min_length=1, max_length=500)