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")
|
||||
|
||||
+19
-1
@@ -1,7 +1,7 @@
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from uuid_utils import uuid7
|
||||
|
||||
@@ -47,6 +47,7 @@ class Folder(Base):
|
||||
name: Mapped[str] = mapped_column(String(120))
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class TaskList(Base):
|
||||
@@ -58,6 +59,23 @@ class TaskList(Base):
|
||||
is_inbox: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class Tag(Base):
|
||||
__tablename__ = "tags"
|
||||
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_tags_user_id_name"),)
|
||||
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
||||
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(80))
|
||||
color: Mapped[str] = mapped_column(String(32), default="#f15a29")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
|
||||
class TaskTag(Base):
|
||||
__tablename__ = "task_tags"
|
||||
task_id: Mapped[UUID] = mapped_column(ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=True)
|
||||
tag_id: Mapped[UUID] = mapped_column(ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True)
|
||||
|
||||
|
||||
class Task(Base):
|
||||
|
||||
+57
-4
@@ -1,7 +1,7 @@
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
class InitializeRequest(BaseModel):
|
||||
@@ -20,10 +20,14 @@ class UserOut(BaseModel):
|
||||
username: str
|
||||
|
||||
|
||||
class FolderCreate(BaseModel):
|
||||
class NameUpdate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
|
||||
|
||||
class FolderCreate(NameUpdate):
|
||||
pass
|
||||
|
||||
|
||||
class FolderOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
@@ -43,6 +47,18 @@ class ListOut(BaseModel):
|
||||
is_inbox: bool
|
||||
|
||||
|
||||
class TagCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=80)
|
||||
color: str = Field(default="#f15a29", min_length=1, max_length=32)
|
||||
|
||||
|
||||
class TagOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
name: str
|
||||
color: str
|
||||
|
||||
|
||||
class TaskCreate(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=500)
|
||||
list_id: UUID
|
||||
@@ -50,16 +66,26 @@ class TaskCreate(BaseModel):
|
||||
priority: int = Field(default=0, ge=0, le=3)
|
||||
due_at: datetime | None = None
|
||||
parent_id: UUID | None = None
|
||||
tag_ids: list[UUID] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TaskUpdate(BaseModel):
|
||||
title: str = Field(default=None, min_length=1, max_length=500)
|
||||
description: str = Field(default=None)
|
||||
title: str | None = Field(default=None, min_length=1, max_length=500)
|
||||
description: str | None = None
|
||||
priority: int | None = Field(default=None, ge=0, le=3)
|
||||
due_at: datetime | None = None
|
||||
completed: bool | None = None
|
||||
list_id: UUID | None = None
|
||||
tag_ids: list[UUID] | None = None
|
||||
version: int = Field(ge=1)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def reject_null_non_nullable_fields(self):
|
||||
for field in ("title", "description", "priority", "completed", "list_id"):
|
||||
if field in self.model_fields_set and getattr(self, field) is None:
|
||||
raise ValueError(f"{field} cannot be null")
|
||||
return self
|
||||
|
||||
|
||||
class TaskOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -74,6 +100,33 @@ class TaskOut(BaseModel):
|
||||
version: int
|
||||
|
||||
|
||||
class TaskDetailOut(TaskOut):
|
||||
tags: list[TagOut] = Field(default_factory=list)
|
||||
subtasks: list[TaskOut] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TaskPage(BaseModel):
|
||||
items: list[TaskOut]
|
||||
next_cursor: str | None = None
|
||||
|
||||
|
||||
class BatchTaskUpdate(BaseModel):
|
||||
task_ids: list[UUID] = Field(min_length=1)
|
||||
completed: bool | None = None
|
||||
list_id: UUID | None = None
|
||||
due_at: datetime | None = None
|
||||
tag_ids: list[UUID] | None = None
|
||||
soft_delete: bool | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_operation(self):
|
||||
operations = {"completed", "list_id", "due_at", "tag_ids", "soft_delete"}
|
||||
if not self.model_fields_set.intersection(operations):
|
||||
raise ValueError("at least one batch operation is required")
|
||||
if self.soft_delete is False:
|
||||
raise ValueError("soft_delete can only be true")
|
||||
return self
|
||||
|
||||
|
||||
class BatchResult(BaseModel):
|
||||
updated: int
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,3 +1,3 @@
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f15a29"><link rel="manifest" href="/manifest.json"><title>dodo</title> <script type="module" crossorigin src="/assets/index-BHdY9q3o.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BBQtE9Jy.css">
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f15a29"><link rel="manifest" href="/manifest.json"><title>dodo</title> <script type="module" crossorigin src="/assets/index-BiihmXG7.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-hzru7WEs.css">
|
||||
</head><body><div id="app"></div></body></html>
|
||||
Reference in New Issue
Block a user