feat: complete task management workflow
This commit is contained in:
+476
-31
@@ -1,11 +1,14 @@
|
||||
import base64
|
||||
import json
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Response
|
||||
from fastapi import Depends, FastAPI, HTTPException, Query, Response
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy import delete, exists, func, or_, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -19,15 +22,21 @@ from .auth import (
|
||||
verify_password,
|
||||
)
|
||||
from .db import create_schema, get_db
|
||||
from .models import AppState, Folder, Session, Task, TaskList, User, utcnow
|
||||
from .models import AppState, Folder, Session, Tag, Task, TaskList, TaskTag, User, utcnow
|
||||
from .schemas import (
|
||||
BatchResult,
|
||||
BatchTaskUpdate,
|
||||
FolderCreate,
|
||||
FolderOut,
|
||||
InitializeRequest,
|
||||
ListCreate,
|
||||
ListOut,
|
||||
LoginRequest,
|
||||
NameUpdate,
|
||||
TagCreate,
|
||||
TagOut,
|
||||
TaskCreate,
|
||||
TaskDetailOut,
|
||||
TaskOut,
|
||||
TaskPage,
|
||||
TaskUpdate,
|
||||
@@ -44,7 +53,13 @@ async def lifespan(app: FastAPI):
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="dodo", version="0.1.0", lifespan=lifespan, docs_url="/api/docs", openapi_url="/api/openapi.json")
|
||||
app = FastAPI(
|
||||
title="dodo",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
docs_url="/api/docs",
|
||||
openapi_url="/api/openapi.json",
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health/live")
|
||||
@@ -65,7 +80,11 @@ async def setup_status(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@app.post("/api/v1/setup/initialize", response_model=UserOut, status_code=201)
|
||||
async def initialize(payload: InitializeRequest, response: Response, db: AsyncSession = Depends(get_db)):
|
||||
async def initialize(
|
||||
payload: InitializeRequest,
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
count = await db.scalar(select(func.count()).select_from(User))
|
||||
if count:
|
||||
raise HTTPException(status_code=409, detail="系统已经初始化")
|
||||
@@ -85,7 +104,11 @@ async def initialize(payload: InitializeRequest, response: Response, db: AsyncSe
|
||||
|
||||
|
||||
@app.post("/api/v1/auth/login", response_model=UserOut)
|
||||
async def login(payload: LoginRequest, response: Response, db: AsyncSession = Depends(get_db)):
|
||||
async def login(
|
||||
payload: LoginRequest,
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
user = await db.scalar(select(User).where(User.username == payload.username))
|
||||
if user is None or not verify_password(user.password_hash, payload.password):
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
@@ -113,35 +136,218 @@ async def logout(
|
||||
|
||||
|
||||
@app.post("/api/v1/folders", response_model=FolderOut, status_code=201)
|
||||
async def create_folder(payload: FolderCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
async def create_folder(
|
||||
payload: FolderCreate,
|
||||
user: User = Depends(current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
folder = Folder(user_id=user.id, name=payload.name)
|
||||
db.add(folder); await db.commit(); await db.refresh(folder)
|
||||
db.add(folder)
|
||||
await db.commit()
|
||||
await db.refresh(folder)
|
||||
return folder
|
||||
|
||||
|
||||
@app.get("/api/v1/folders", response_model=list[FolderOut])
|
||||
async def list_folders(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
return list((await db.scalars(select(Folder).where(Folder.user_id == user.id).order_by(Folder.position, Folder.created_at))).all())
|
||||
query = select(Folder).where(Folder.user_id == user.id, Folder.deleted_at.is_(None))
|
||||
return list((await db.scalars(query.order_by(Folder.position, Folder.created_at))).all())
|
||||
|
||||
|
||||
@app.patch("/api/v1/folders/{folder_id}", response_model=FolderOut)
|
||||
async def rename_folder(
|
||||
folder_id: UUID,
|
||||
payload: NameUpdate,
|
||||
user: User = Depends(current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
folder = await db.scalar(
|
||||
select(Folder).where(
|
||||
Folder.id == folder_id, Folder.user_id == user.id, Folder.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
if folder is None:
|
||||
raise HTTPException(status_code=404, detail="文件夹不存在")
|
||||
folder.name = payload.name
|
||||
await db.commit()
|
||||
return folder
|
||||
|
||||
|
||||
@app.delete("/api/v1/folders/{folder_id}", status_code=204)
|
||||
async def delete_folder(
|
||||
folder_id: UUID,
|
||||
user: User = Depends(current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
folder = await db.scalar(
|
||||
select(Folder).where(
|
||||
Folder.id == folder_id, Folder.user_id == user.id, Folder.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
if folder is None:
|
||||
raise HTTPException(status_code=404, detail="文件夹不存在")
|
||||
folder.deleted_at = utcnow()
|
||||
await db.execute(
|
||||
update(TaskList)
|
||||
.where(TaskList.user_id == user.id, TaskList.folder_id == folder.id)
|
||||
.values(folder_id=None)
|
||||
)
|
||||
await db.commit()
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@app.post("/api/v1/lists", response_model=ListOut, status_code=201)
|
||||
async def create_list(payload: ListCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
if payload.folder_id and not await db.scalar(select(Folder.id).where(Folder.id == payload.folder_id, Folder.user_id == user.id)):
|
||||
async def create_list(
|
||||
payload: ListCreate,
|
||||
user: User = Depends(current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if payload.folder_id and not await db.scalar(
|
||||
select(Folder.id).where(
|
||||
Folder.id == payload.folder_id,
|
||||
Folder.user_id == user.id,
|
||||
Folder.deleted_at.is_(None),
|
||||
)
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="文件夹不存在")
|
||||
item = TaskList(user_id=user.id, folder_id=payload.folder_id, name=payload.name)
|
||||
db.add(item); await db.commit(); await db.refresh(item)
|
||||
db.add(item)
|
||||
await db.commit()
|
||||
await db.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
@app.get("/api/v1/lists", response_model=list[ListOut])
|
||||
async def list_lists(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
return list((await db.scalars(select(TaskList).where(TaskList.user_id == user.id).order_by(TaskList.is_inbox.desc(), TaskList.position, TaskList.created_at))).all())
|
||||
query = select(TaskList).where(TaskList.user_id == user.id, TaskList.deleted_at.is_(None))
|
||||
ordering = (TaskList.is_inbox.desc(), TaskList.position, TaskList.created_at)
|
||||
return list((await db.scalars(query.order_by(*ordering))).all())
|
||||
|
||||
|
||||
async def _owned_list(db: AsyncSession, user_id: UUID, list_id: UUID) -> TaskList:
|
||||
item = await db.scalar(
|
||||
select(TaskList).where(
|
||||
TaskList.id == list_id,
|
||||
TaskList.user_id == user_id,
|
||||
TaskList.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="清单不存在")
|
||||
return item
|
||||
|
||||
|
||||
@app.patch("/api/v1/lists/{list_id}", response_model=ListOut)
|
||||
async def rename_list(
|
||||
list_id: UUID,
|
||||
payload: NameUpdate,
|
||||
user: User = Depends(current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
item = await _owned_list(db, user.id, list_id)
|
||||
if item.is_inbox:
|
||||
raise HTTPException(status_code=409, detail="系统收集箱不能重命名")
|
||||
item.name = payload.name
|
||||
await db.commit()
|
||||
return item
|
||||
|
||||
|
||||
@app.delete("/api/v1/lists/{list_id}", status_code=204)
|
||||
async def delete_list(
|
||||
list_id: UUID,
|
||||
user: User = Depends(current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
item = await _owned_list(db, user.id, list_id)
|
||||
if item.is_inbox:
|
||||
raise HTTPException(status_code=409, detail="系统收集箱不能删除")
|
||||
inbox_id = await db.scalar(
|
||||
select(TaskList.id).where(
|
||||
TaskList.user_id == user.id,
|
||||
TaskList.is_inbox.is_(True),
|
||||
TaskList.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
if inbox_id is None:
|
||||
raise HTTPException(status_code=409, detail="系统收集箱不存在")
|
||||
await db.execute(
|
||||
update(Task)
|
||||
.where(Task.user_id == user.id, Task.list_id == item.id, Task.deleted_at.is_(None))
|
||||
.values(list_id=inbox_id, version=Task.version + 1, updated_at=utcnow())
|
||||
)
|
||||
item.deleted_at = utcnow()
|
||||
await db.commit()
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@app.post("/api/v1/tags", response_model=TagOut, status_code=201)
|
||||
async def create_tag(
|
||||
payload: TagCreate,
|
||||
user: User = Depends(current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
existing = await db.scalar(
|
||||
select(Tag.id).where(Tag.user_id == user.id, func.lower(Tag.name) == payload.name.lower())
|
||||
)
|
||||
if existing:
|
||||
raise HTTPException(status_code=409, detail="标签名称已存在")
|
||||
tag = Tag(user_id=user.id, **payload.model_dump())
|
||||
db.add(tag)
|
||||
await db.commit()
|
||||
await db.refresh(tag)
|
||||
return tag
|
||||
|
||||
|
||||
@app.get("/api/v1/tags", response_model=list[TagOut])
|
||||
async def list_tags(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
return list(
|
||||
(await db.scalars(select(Tag).where(Tag.user_id == user.id).order_by(Tag.name, Tag.id))).all()
|
||||
)
|
||||
|
||||
|
||||
async def _validate_tags(
|
||||
db: AsyncSession, user_id: UUID, tag_ids: list[UUID] | None
|
||||
) -> list[UUID] | None:
|
||||
if tag_ids is None:
|
||||
return None
|
||||
unique_ids = list(dict.fromkeys(tag_ids))
|
||||
if not unique_ids:
|
||||
return []
|
||||
found = set(
|
||||
(await db.scalars(select(Tag.id).where(Tag.user_id == user_id, Tag.id.in_(unique_ids)))).all()
|
||||
)
|
||||
if found != set(unique_ids):
|
||||
raise HTTPException(status_code=404, detail="标签不存在")
|
||||
return unique_ids
|
||||
|
||||
|
||||
async def _replace_tags(db: AsyncSession, task_ids: list[UUID], tag_ids: list[UUID]) -> None:
|
||||
await db.execute(delete(TaskTag).where(TaskTag.task_id.in_(task_ids)))
|
||||
db.add_all(TaskTag(task_id=task_id, tag_id=tag_id) for task_id in task_ids for tag_id in tag_ids)
|
||||
|
||||
|
||||
def _encode_cursor(created_at: datetime, task_id: UUID) -> str:
|
||||
raw = json.dumps([created_at.isoformat(), str(task_id)]).encode()
|
||||
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
||||
|
||||
|
||||
def _decode_cursor(cursor: str) -> tuple[datetime, UUID]:
|
||||
try:
|
||||
raw = base64.urlsafe_b64decode(cursor + "=" * (-len(cursor) % 4))
|
||||
timestamp, task_id = json.loads(raw)
|
||||
return datetime.fromisoformat(timestamp), UUID(task_id)
|
||||
except (ValueError, TypeError, json.JSONDecodeError) as exc:
|
||||
raise HTTPException(status_code=422, detail="无效的游标") from exc
|
||||
|
||||
|
||||
@app.post("/api/v1/tasks", response_model=TaskOut, status_code=201)
|
||||
async def create_task(payload: TaskCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
if not await db.scalar(select(TaskList.id).where(TaskList.id == payload.list_id, TaskList.user_id == user.id)):
|
||||
raise HTTPException(status_code=404, detail="清单不存在")
|
||||
async def create_task(
|
||||
payload: TaskCreate,
|
||||
user: User = Depends(current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await _owned_list(db, user.id, payload.list_id)
|
||||
tag_ids = await _validate_tags(db, user.id, payload.tag_ids)
|
||||
if payload.parent_id:
|
||||
parent = await db.scalar(
|
||||
select(Task).where(
|
||||
@@ -153,22 +359,117 @@ async def create_task(payload: TaskCreate, user: User = Depends(current_user), d
|
||||
)
|
||||
)
|
||||
if parent is None:
|
||||
raise HTTPException(status_code=400, detail="父任务必须属于同一清单")
|
||||
task = Task(user_id=user.id, **payload.model_dump())
|
||||
db.add(task); await db.commit(); await db.refresh(task)
|
||||
raise HTTPException(status_code=400, detail="父任务必须是同一清单的顶层任务")
|
||||
data = payload.model_dump(exclude={"tag_ids"})
|
||||
task = Task(user_id=user.id, **data)
|
||||
db.add(task)
|
||||
await db.flush()
|
||||
if tag_ids:
|
||||
db.add_all(TaskTag(task_id=task.id, tag_id=tag_id) for tag_id in tag_ids)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
return task
|
||||
|
||||
|
||||
@app.get("/api/v1/tasks", response_model=TaskPage)
|
||||
async def list_tasks(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
items = list((await db.scalars(select(Task).where(Task.user_id == user.id, Task.deleted_at.is_(None), Task.parent_id.is_(None)).order_by(Task.completed, Task.position, Task.created_at))).all())
|
||||
return TaskPage(items=items)
|
||||
async def list_tasks(
|
||||
q: str | None = None,
|
||||
cursor: str | None = None,
|
||||
limit: int = Query(default=50, ge=1, le=100),
|
||||
user: User = Depends(current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
query = select(Task).where(
|
||||
Task.user_id == user.id, Task.deleted_at.is_(None), Task.parent_id.is_(None)
|
||||
)
|
||||
if q:
|
||||
pattern = f"%{q}%"
|
||||
tag_match = exists(
|
||||
select(TaskTag.task_id)
|
||||
.join(Tag, Tag.id == TaskTag.tag_id)
|
||||
.where(TaskTag.task_id == Task.id, Tag.user_id == user.id, Tag.name.ilike(pattern))
|
||||
)
|
||||
list_match = exists(
|
||||
select(TaskList.id).where(
|
||||
TaskList.id == Task.list_id,
|
||||
TaskList.user_id == user.id,
|
||||
TaskList.name.ilike(pattern),
|
||||
)
|
||||
)
|
||||
query = query.where(
|
||||
or_(Task.title.ilike(pattern), Task.description.ilike(pattern), tag_match, list_match)
|
||||
)
|
||||
if cursor:
|
||||
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))
|
||||
)
|
||||
rows = list((await db.scalars(query.order_by(Task.created_at, Task.id).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
|
||||
return TaskPage(items=items, next_cursor=next_cursor)
|
||||
|
||||
|
||||
@app.patch("/api/v1/tasks/{task_id}", response_model=TaskOut)
|
||||
async def update_task(task_id: UUID, payload: TaskUpdate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
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()
|
||||
)
|
||||
subtasks = list(
|
||||
(
|
||||
await db.scalars(
|
||||
select(Task)
|
||||
.where(Task.parent_id == task.id, 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)
|
||||
|
||||
|
||||
@app.get("/api/v1/tasks/{task_id}", response_model=TaskDetailOut)
|
||||
async def get_task(
|
||||
task_id: UUID,
|
||||
user: User = Depends(current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
task = await db.scalar(
|
||||
select(Task).where(Task.id == task_id, Task.user_id == user.id, Task.deleted_at.is_(None))
|
||||
)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
return await _task_detail(db, task)
|
||||
|
||||
|
||||
@app.patch("/api/v1/tasks/{task_id}", response_model=TaskDetailOut)
|
||||
async def update_task(
|
||||
task_id: UUID,
|
||||
payload: TaskUpdate,
|
||||
user: User = Depends(current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
expected_version = data.pop("version")
|
||||
tag_ids = await _validate_tags(db, user.id, data.pop("tag_ids", None))
|
||||
if "list_id" in data:
|
||||
await _owned_list(db, user.id, data["list_id"])
|
||||
parent_id = await db.scalar(
|
||||
select(Task.parent_id).where(Task.id == task_id, Task.user_id == user.id)
|
||||
)
|
||||
if parent_id:
|
||||
parent_list = await db.scalar(
|
||||
select(Task.list_id).where(Task.id == parent_id, Task.user_id == user.id)
|
||||
)
|
||||
if parent_list != data["list_id"]:
|
||||
raise HTTPException(status_code=400, detail="子任务必须与父任务属于同一清单")
|
||||
data["version"] = Task.version + 1
|
||||
data["updated_at"] = utcnow()
|
||||
result = await db.execute(
|
||||
@@ -184,27 +485,171 @@ async def update_task(task_id: UUID, payload: TaskUpdate, user: User = Depends(c
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
if task is None:
|
||||
exists = await db.scalar(
|
||||
exists_id = await db.scalar(
|
||||
select(Task.id).where(Task.id == task_id, Task.user_id == user.id, Task.deleted_at.is_(None))
|
||||
)
|
||||
if exists:
|
||||
if exists_id:
|
||||
raise HTTPException(status_code=409, detail="任务已被更新,请刷新后重试")
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
if tag_ids is not None:
|
||||
await _replace_tags(db, [task_id], tag_ids)
|
||||
if "list_id" in data and task.parent_id is None:
|
||||
await db.execute(
|
||||
update(Task)
|
||||
.where(Task.parent_id == task.id, Task.user_id == user.id, Task.deleted_at.is_(None))
|
||||
.values(list_id=task.list_id, version=Task.version + 1, updated_at=utcnow())
|
||||
)
|
||||
await db.commit()
|
||||
return task
|
||||
return await _task_detail(db, task)
|
||||
|
||||
|
||||
@app.delete("/api/v1/tasks/{task_id}", status_code=204)
|
||||
async def delete_task(task_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
task = await db.scalar(select(Task).where(Task.id == task_id, Task.user_id == user.id, Task.deleted_at.is_(None)))
|
||||
async def delete_task(
|
||||
task_id: UUID,
|
||||
user: User = Depends(current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
task = await db.scalar(
|
||||
select(Task).where(Task.id == task_id, Task.user_id == user.id, Task.deleted_at.is_(None))
|
||||
)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
task.deleted_at = utcnow()
|
||||
task.version += 1
|
||||
deleted_at = utcnow()
|
||||
await db.execute(
|
||||
update(Task)
|
||||
.where(or_(Task.id == task.id, Task.parent_id == task.id), Task.user_id == user.id)
|
||||
.values(deleted_at=deleted_at, version=Task.version + 1, updated_at=deleted_at)
|
||||
)
|
||||
await db.commit()
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@app.get("/api/v1/trash", response_model=TaskPage)
|
||||
async def list_trash(
|
||||
cursor: str | None = None,
|
||||
limit: int = Query(default=50, ge=1, le=100),
|
||||
user: User = Depends(current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
query = select(Task).where(
|
||||
Task.user_id == user.id, Task.deleted_at.is_not(None), Task.parent_id.is_(None)
|
||||
)
|
||||
if cursor:
|
||||
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))
|
||||
)
|
||||
rows = list((await db.scalars(query.order_by(Task.created_at, Task.id).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
|
||||
return TaskPage(items=items, next_cursor=next_cursor)
|
||||
|
||||
|
||||
@app.post("/api/v1/tasks/{task_id}/restore", response_model=TaskDetailOut)
|
||||
async def restore_task(
|
||||
task_id: UUID,
|
||||
user: User = Depends(current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
task = await db.scalar(
|
||||
select(Task).where(Task.id == task_id, Task.user_id == user.id, Task.deleted_at.is_not(None))
|
||||
)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="回收站中不存在该任务")
|
||||
await _owned_list(db, user.id, task.list_id)
|
||||
now = utcnow()
|
||||
await db.execute(
|
||||
update(Task)
|
||||
.where(or_(Task.id == task.id, Task.parent_id == task.id), Task.user_id == user.id)
|
||||
.values(deleted_at=None, version=Task.version + 1, updated_at=now)
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
return await _task_detail(db, task)
|
||||
|
||||
|
||||
@app.delete("/api/v1/trash/{task_id}", status_code=204)
|
||||
async def permanently_delete_task(
|
||||
task_id: UUID,
|
||||
user: User = Depends(current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
task = await db.scalar(
|
||||
select(Task).where(Task.id == task_id, Task.user_id == user.id, Task.deleted_at.is_not(None))
|
||||
)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="回收站中不存在该任务")
|
||||
ids = list(
|
||||
(
|
||||
await db.scalars(
|
||||
select(Task.id).where(
|
||||
or_(Task.id == task.id, Task.parent_id == task.id), Task.user_id == user.id
|
||||
)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
await db.execute(delete(TaskTag).where(TaskTag.task_id.in_(ids)))
|
||||
await db.execute(delete(Task).where(Task.parent_id == task.id, Task.user_id == user.id))
|
||||
await db.delete(task)
|
||||
await db.commit()
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@app.post("/api/v1/tasks/batch", response_model=BatchResult)
|
||||
async def batch_update_tasks(
|
||||
payload: BatchTaskUpdate,
|
||||
user: User = Depends(current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
task_ids = list(dict.fromkeys(payload.task_ids))
|
||||
tasks = list(
|
||||
(
|
||||
await db.scalars(
|
||||
select(Task).where(
|
||||
Task.id.in_(task_ids), Task.user_id == user.id, Task.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
if len(tasks) != len(task_ids):
|
||||
raise HTTPException(status_code=404, detail="一个或多个任务不存在")
|
||||
if payload.list_id is not None:
|
||||
await _owned_list(db, user.id, payload.list_id)
|
||||
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:
|
||||
changes["deleted_at"] = utcnow()
|
||||
if changes:
|
||||
changes["version"] = Task.version + 1
|
||||
changes["updated_at"] = utcnow()
|
||||
await db.execute(
|
||||
update(Task)
|
||||
.where(Task.id.in_(task_ids), Task.user_id == user.id, Task.deleted_at.is_(None))
|
||||
.values(**changes)
|
||||
)
|
||||
if payload.list_id is not None:
|
||||
parent_ids = [task.id for task in tasks if task.parent_id is None]
|
||||
if parent_ids:
|
||||
await db.execute(
|
||||
update(Task)
|
||||
.where(
|
||||
Task.parent_id.in_(parent_ids),
|
||||
Task.user_id == user.id,
|
||||
Task.deleted_at.is_(None),
|
||||
)
|
||||
.values(
|
||||
list_id=payload.list_id,
|
||||
version=Task.version + 1,
|
||||
updated_at=utcnow(),
|
||||
)
|
||||
)
|
||||
if tag_ids is not None:
|
||||
await _replace_tags(db, task_ids, tag_ids)
|
||||
await db.commit()
|
||||
return BatchResult(updated=len(task_ids))
|
||||
|
||||
|
||||
static_dir = Path(__file__).parent / "static"
|
||||
if static_dir.exists():
|
||||
app.mount("/assets", StaticFiles(directory=static_dir / "assets"), name="assets")
|
||||
|
||||
Reference in New Issue
Block a user