feat: organize lists with drag and drop
This commit is contained in:
+150
-1
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user