789 lines
28 KiB
Python
789 lines
28 KiB
Python
import base64
|
|
import json
|
|
import time
|
|
from collections import defaultdict, deque
|
|
from contextlib import asynccontextmanager
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from uuid import UUID
|
|
|
|
from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response
|
|
from fastapi.openapi.docs import get_swagger_ui_html
|
|
from fastapi.responses import FileResponse, JSONResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from sqlalchemy import delete, exists, func, or_, select, update
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from .auth import (
|
|
COOKIE_NAME,
|
|
CSRF_COOKIE_NAME,
|
|
current_user,
|
|
hash_password,
|
|
hash_token,
|
|
issue_session,
|
|
session_token,
|
|
verify_password,
|
|
)
|
|
from .db import create_schema, get_db
|
|
from .models import AppState, Folder, Session, Tag, Task, TaskList, TaskTag, User, utcnow
|
|
from .mvp import audit
|
|
from .mvp import router as mvp_router
|
|
from .schemas import (
|
|
BatchResult,
|
|
BatchTaskUpdate,
|
|
FolderCreate,
|
|
FolderOut,
|
|
InitializeRequest,
|
|
ListCreate,
|
|
ListOut,
|
|
LoginRequest,
|
|
NameUpdate,
|
|
TagCreate,
|
|
TagOut,
|
|
TaskCreate,
|
|
TaskDetailOut,
|
|
TaskOut,
|
|
TaskPage,
|
|
TaskUpdate,
|
|
UserOut,
|
|
)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
from .config import get_settings
|
|
|
|
if get_settings().auto_create_schema:
|
|
await create_schema()
|
|
yield
|
|
|
|
|
|
app = FastAPI(
|
|
title="dodo",
|
|
version="0.2.0",
|
|
lifespan=lifespan,
|
|
docs_url=None,
|
|
openapi_url=None,
|
|
)
|
|
|
|
|
|
@app.middleware("http")
|
|
async def security(request: Request, call_next):
|
|
if request.method not in {"GET", "HEAD", "OPTIONS"} and request.cookies.get(COOKIE_NAME):
|
|
csrf_cookie = request.cookies.get(CSRF_COOKIE_NAME)
|
|
csrf_header = request.headers.get("x-csrf-token")
|
|
# SameSite cookies plus same-origin validation; API clients may explicitly double-submit.
|
|
origin = request.headers.get("origin")
|
|
if origin and (not csrf_cookie or csrf_header != csrf_cookie):
|
|
return JSONResponse({"detail": "CSRF 校验失败"}, status_code=403)
|
|
response = await call_next(request)
|
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
response.headers["X-Frame-Options"] = "DENY"
|
|
response.headers["Referrer-Policy"] = "same-origin"
|
|
response.headers["Content-Security-Policy"] = "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'"
|
|
return response
|
|
|
|
|
|
@app.get("/api/docs", include_in_schema=False)
|
|
async def docs(_: User = Depends(current_user)):
|
|
return get_swagger_ui_html(openapi_url="/api/openapi.json", title="dodo API")
|
|
|
|
|
|
@app.get("/api/openapi.json", include_in_schema=False)
|
|
async def openapi(_: User = Depends(current_user)):
|
|
return app.openapi()
|
|
|
|
|
|
app.include_router(mvp_router)
|
|
_login_attempts: dict[tuple[str, str], deque[float]] = defaultdict(deque)
|
|
|
|
|
|
@app.get("/health/live")
|
|
async def live():
|
|
return {"status": "ok"}
|
|
|
|
|
|
@app.get("/health/ready")
|
|
async def ready(db: AsyncSession = Depends(get_db)):
|
|
await db.execute(select(1))
|
|
return {"status": "ok"}
|
|
|
|
|
|
@app.get("/api/v1/setup/status")
|
|
async def setup_status(db: AsyncSession = Depends(get_db)):
|
|
count = await db.scalar(select(func.count()).select_from(User))
|
|
return {"initialized": bool(count)}
|
|
|
|
|
|
@app.post("/api/v1/setup/initialize", response_model=UserOut, status_code=201)
|
|
async def initialize(
|
|
payload: InitializeRequest,
|
|
response: Response,
|
|
request: Request,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
count = await db.scalar(select(func.count()).select_from(User))
|
|
if count:
|
|
raise HTTPException(status_code=409, detail="系统已经初始化")
|
|
db.add(AppState(key="initialized"))
|
|
user = User(username=payload.username, password_hash=hash_password(payload.password))
|
|
db.add(user)
|
|
try:
|
|
await db.flush()
|
|
except IntegrityError as exc:
|
|
await db.rollback()
|
|
raise HTTPException(status_code=409, detail="系统已经初始化") from exc
|
|
db.add(TaskList(user_id=user.id, name="收集箱", is_inbox=True))
|
|
await db.commit()
|
|
await db.refresh(user)
|
|
await issue_session(db, response, user, request)
|
|
return user
|
|
|
|
|
|
@app.post("/api/v1/auth/login", response_model=UserOut)
|
|
async def login(
|
|
payload: LoginRequest,
|
|
response: Response,
|
|
request: Request,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
from .config import get_settings
|
|
|
|
ip = request.client.host if request.client else "unknown"
|
|
key = (payload.username.casefold(), ip)
|
|
now = time.monotonic()
|
|
attempts = _login_attempts[key]
|
|
window = get_settings().login_window_seconds
|
|
while attempts and attempts[0] < now - window:
|
|
attempts.popleft()
|
|
if len(attempts) >= get_settings().login_attempts:
|
|
raise HTTPException(status_code=429, detail="登录尝试过多,请稍后再试", headers={"Retry-After": str(window)})
|
|
user = await db.scalar(select(User).where(User.username == payload.username))
|
|
if user is None or not verify_password(user.password_hash, payload.password):
|
|
attempts.append(now)
|
|
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
|
attempts.clear()
|
|
await issue_session(db, response, user, request)
|
|
return user
|
|
|
|
|
|
@app.get("/api/v1/me", response_model=UserOut)
|
|
async def me(user: User = Depends(current_user)):
|
|
return user
|
|
|
|
|
|
@app.post("/api/v1/auth/logout", status_code=204)
|
|
async def logout(
|
|
response: Response,
|
|
token: str = Depends(session_token),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
session = await db.scalar(select(Session).where(Session.token_hash == hash_token(token)))
|
|
if session is not None:
|
|
await db.delete(session)
|
|
await db.commit()
|
|
response.delete_cookie(COOKIE_NAME, path="/")
|
|
response.delete_cookie(CSRF_COOKIE_NAME, path="/")
|
|
return Response(status_code=204, headers=response.headers)
|
|
|
|
|
|
@app.get("/api/v1/sessions")
|
|
async def list_sessions(
|
|
token: str = Depends(session_token),
|
|
user: User = Depends(current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
current_hash = hash_token(token)
|
|
rows = (await db.scalars(select(Session).where(Session.user_id == user.id).order_by(Session.created_at.desc()))).all()
|
|
return [{"id": row.id, "current": row.token_hash == current_hash, "created_at": row.created_at, "last_seen_at": row.last_seen_at, "expires_at": row.expires_at, "ip_address": row.ip_address, "user_agent": row.user_agent} for row in rows]
|
|
|
|
|
|
@app.delete("/api/v1/sessions/{session_id}", status_code=204)
|
|
async def revoke_session(
|
|
session_id: UUID,
|
|
response: Response,
|
|
token: str = Depends(session_token),
|
|
user: User = Depends(current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
row = await db.scalar(select(Session).where(Session.id == session_id, Session.user_id == user.id))
|
|
if not row: raise HTTPException(404, "会话不存在")
|
|
current = row.token_hash == hash_token(token)
|
|
await db.delete(row); await db.commit()
|
|
if current:
|
|
response.delete_cookie(COOKIE_NAME, path="/"); response.delete_cookie(CSRF_COOKIE_NAME, path="/")
|
|
return Response(status_code=204, headers=response.headers)
|
|
|
|
|
|
@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),
|
|
):
|
|
folder = Folder(user_id=user.id, name=payload.name)
|
|
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)):
|
|
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.flush()
|
|
audit(db, user.id, "update", "folder", folder.id)
|
|
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()
|
|
audit(db, user.id, "delete", "folder", folder.id)
|
|
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,
|
|
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.flush()
|
|
audit(db, user.id, "create", "list", item.id)
|
|
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)):
|
|
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.flush()
|
|
audit(db, user.id, "update", "list", item.id)
|
|
await db.commit()
|
|
await db.refresh(item)
|
|
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),
|
|
):
|
|
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(
|
|
Task.id == payload.parent_id,
|
|
Task.user_id == user.id,
|
|
Task.list_id == payload.list_id,
|
|
Task.parent_id.is_(None),
|
|
Task.deleted_at.is_(None),
|
|
)
|
|
)
|
|
if parent is None:
|
|
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()
|
|
audit(db, user.id, "create", "task", task.id)
|
|
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(
|
|
q: str | None = None,
|
|
list_id: UUID | None = None,
|
|
completed: bool | None = None,
|
|
due_from: datetime | None = None,
|
|
due_to: datetime | None = None,
|
|
cursor: str | None = None,
|
|
page: int | None = Query(default=None, ge=1),
|
|
page_size: int | None = Query(default=None, ge=1, le=100),
|
|
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 list_id is not None:
|
|
await _owned_list(db, user.id, list_id)
|
|
query = query.where(Task.list_id == list_id)
|
|
if completed is not None:
|
|
query = query.where(Task.completed.is_(completed))
|
|
if due_from is not None:
|
|
query = query.where(Task.due_at >= due_from)
|
|
if due_to is not None:
|
|
query = query.where(Task.due_at < due_to)
|
|
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)
|
|
)
|
|
total = await db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0
|
|
ordering = (Task.created_at, Task.id)
|
|
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)
|
|
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(*ordering).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, 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()
|
|
)
|
|
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(
|
|
update(Task)
|
|
.where(
|
|
Task.id == task_id,
|
|
Task.user_id == user.id,
|
|
Task.deleted_at.is_(None),
|
|
Task.version == expected_version,
|
|
)
|
|
.values(**data)
|
|
.returning(Task)
|
|
)
|
|
task = result.scalar_one_or_none()
|
|
if task is None:
|
|
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_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())
|
|
)
|
|
changed = {k for k in data if k not in {"version", "updated_at"}}
|
|
if changed & {"title", "description", "priority", "due_at", "list_id", "completed"}:
|
|
action = "complete" if data.get("completed") is True else "update"
|
|
audit(db, user.id, action, "task", task.id, fields=sorted(changed))
|
|
await db.commit()
|
|
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))
|
|
)
|
|
if task is None:
|
|
raise HTTPException(status_code=404, detail="任务不存在")
|
|
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)
|
|
)
|
|
audit(db, user.id, "delete", "task", task.id)
|
|
await db.commit()
|
|
return Response(status_code=204)
|
|
|
|
|
|
@app.get("/api/v1/trash", response_model=TaskPage)
|
|
async def list_trash(
|
|
cursor: str | None = None,
|
|
page: int | None = Query(default=None, ge=1),
|
|
page_size: int | None = Query(default=None, ge=1, le=100),
|
|
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)
|
|
)
|
|
total = await db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0
|
|
ordering = (Task.created_at, Task.id)
|
|
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)
|
|
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(*ordering).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, total=total, page=1, page_size=limit)
|
|
|
|
|
|
@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)
|
|
)
|
|
audit(db, user.id, "restore", "task", task.id)
|
|
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")
|
|
|
|
@app.get("/{path:path}", include_in_schema=False)
|
|
async def spa(path: str):
|
|
root = static_dir.resolve()
|
|
target = (root / path).resolve()
|
|
if target.is_file() and target.is_relative_to(root):
|
|
return FileResponse(target)
|
|
return FileResponse(root / "index.html")
|