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, HTMLResponse, 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.get("/api/v1/bootstrap") async def bootstrap_data(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): folders = list((await db.scalars( select(Folder) .where(Folder.user_id == user.id, Folder.deleted_at.is_(None)) .order_by(Folder.position, Folder.created_at) )).all()) lists = list((await db.scalars( select(TaskList) .where(TaskList.user_id == user.id, TaskList.deleted_at.is_(None)) .order_by(TaskList.is_inbox.desc(), TaskList.position, TaskList.created_at) )).all()) tags = list((await db.scalars( select(Tag).where(Tag.user_id == user.id).order_by(Tag.name, Tag.id) )).all()) inbox = next((item for item in lists if item.is_inbox), None) return { "user": UserOut.model_validate(user), "folders": [FolderOut.model_validate(item) for item in folders], "lists": [ListOut.model_validate(item) for item in lists], "tags": [TagOut.model_validate(item) for item in tags], "inbox_id": inbox.id if inbox else None, } @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): if path == "api" or path.startswith("api/"): raise HTTPException(status_code=404, detail="Not Found") root = static_dir.resolve() target = (root / path).resolve() headers = {"Cache-Control": "no-cache, no-store, must-revalidate, max-age=0"} if target.is_file() and target.is_relative_to(root): if target.name == "sw.js": return HTMLResponse( "self.addEventListener('install',()=>self.skipWaiting());" "self.addEventListener('activate',e=>e.waitUntil(" "caches.keys().then(k=>Promise.all(k.map(x=>caches.delete(x))))" ".then(()=>self.registration.unregister())" ".then(()=>self.clients.matchAll({type:'window'}))" ".then(cs=>cs.forEach(c=>c.navigate(c.url)))));", media_type="application/javascript", headers=headers, ) return FileResponse(target) return FileResponse(root / "index.html", headers=headers)