feat: bootstrap dodo phase one

This commit is contained in:
2026-09-05 10:47:44 +08:00
commit f182c704a4
48 changed files with 4221 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
.git
.venv
frontend/node_modules
frontend/dist
__pycache__
*.db
.env
+4
View File
@@ -0,0 +1,4 @@
DODO_DATABASE_URL=postgresql+asyncpg://postgres:[email protected]:5432/dodo
DODO_COOKIE_SECURE=true
DODO_SESSION_DAYS=30
DODO_TRUSTED_PROXIES=127.0.0.1
+12
View File
@@ -0,0 +1,12 @@
__pycache__/
*.py[cod]
.venv/
.env
node_modules/
dist/
coverage/
playwright-report/
test-results/
.DS_Store
*.db
uploads/
+19
View File
@@ -0,0 +1,19 @@
FROM node:22-alpine AS frontend
WORKDIR /build
COPY frontend/package.json frontend/pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY frontend/ ./
RUN pnpm build
FROM python:3.12-slim
WORKDIR /app
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
COPY pyproject.toml uv.lock alembic.ini ./
RUN uv sync --frozen --no-dev
COPY backend backend
COPY worker worker
COPY migrations migrations
COPY scripts scripts
COPY --from=frontend /build/dist backend/static
EXPOSE 8781
CMD ["sh", "scripts/start.sh"]
+7
View File
@@ -0,0 +1,7 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2026 dodo contributors
This project is licensed under the GNU Affero General Public License v3.0.
The full license text is available at https://www.gnu.org/licenses/agpl-3.0.txt
+47
View File
@@ -0,0 +1,47 @@
# dodo
一个自托管的任务与习惯管理工具,目标是做一个温暖、紧凑、可自己掌控数据的 TickTick-like 应用。
## 第一阶段能力
- 首次初始化管理员
- 用户名密码登录,Cookie Session
- 文件夹、清单、任务基础 CRUD
- 收集箱系统清单
- Vue 3 + PWA 应用外壳
- 手账生活感浅色 UI
## 技术栈
- Frontend: Vue 3 + TypeScript + Vite + Tailwind CSS
- Backend: FastAPI + SQLAlchemy 2 Async
- DB: PostgreSQL(测试环境使用 SQLite
- Package: uv + pnpm
## 本地开发
```bash
cd /Users/bboysoul/PycharmProjects/dodo
uv sync
uv run uvicorn backend.main:app --host 0.0.0.0 --port 8781
cd frontend
pnpm install
pnpm run dev
```
## 环境变量
```bash
DODO_DATABASE_URL=postgresql+asyncpg://user:pass@host:5432/dodo
DODO_COOKIE_SECURE=false
DODO_SESSION_DAYS=30
```
## 验证
```bash
uv run pytest -q
uv run ruff check backend tests
cd frontend && pnpm run build
```
+31
View File
@@ -0,0 +1,31 @@
[alembic]
script_location = migrations
prepend_sys_path = .
sqlalchemy.url = postgresql+asyncpg://dodo:dodo@localhost:5432/dodo
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
View File
+65
View File
@@ -0,0 +1,65 @@
import hashlib
import secrets
from datetime import UTC, datetime, timedelta
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError
from fastapi import Cookie, Depends, HTTPException, Response, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from .config import get_settings
from .db import get_db
from .models import Session, User
password_hasher = PasswordHasher()
COOKIE_NAME = "dodo_session"
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
def hash_password(password: str) -> str:
return password_hasher.hash(password)
def verify_password(password_hash: str, password: str) -> bool:
try:
return password_hasher.verify(password_hash, password)
except (InvalidHashError, VerificationError):
return False
async def issue_session(db: AsyncSession, response: Response, user: User) -> None:
token = secrets.token_urlsafe(32)
expires = datetime.now(UTC) + timedelta(days=get_settings().session_days)
db.add(Session(token_hash=hash_token(token), user_id=user.id, expires_at=expires))
await db.commit()
response.set_cookie(
COOKIE_NAME, token, max_age=get_settings().session_days * 86400,
httponly=True, secure=get_settings().cookie_secure, samesite="lax", path="/",
)
async def session_token(
token: str | None = Cookie(default=None, alias=COOKIE_NAME),
) -> str:
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="请先登录")
return token
async def current_user(
token: str = Depends(session_token),
db: AsyncSession = Depends(get_db),
) -> User:
result = await db.execute(
select(User).join(Session).where(
Session.token_hash == hash_token(token), Session.expires_at > datetime.now(UTC)
)
)
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="会话已失效,请重新登录")
return user
+19
View File
@@ -0,0 +1,19 @@
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "dodo"
database_url: str = "postgresql+asyncpg://dodo:dodo@localhost:5432/dodo"
session_days: int = 30
cookie_secure: bool = False
trusted_proxies: str = ""
auto_create_schema: bool = False
model_config = SettingsConfigDict(env_prefix="DODO_", env_file=".env", extra="ignore")
@lru_cache
def get_settings() -> Settings:
return Settings()
+41
View File
@@ -0,0 +1,41 @@
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from .config import get_settings
class Base(DeclarativeBase):
pass
_engine = None
_session_factory = None
def get_engine():
global _engine, _session_factory
if _engine is None:
_engine = create_async_engine(get_settings().database_url, pool_pre_ping=True)
_session_factory = async_sessionmaker(_engine, expire_on_commit=False)
return _engine
def reset_engine() -> None:
global _engine, _session_factory
_engine = None
_session_factory = None
async def get_db() -> AsyncIterator[AsyncSession]:
get_engine()
assert _session_factory is not None
async with _session_factory() as session:
yield session
async def create_schema() -> None:
from . import models # noqa: F401
async with get_engine().begin() as conn:
await conn.run_sync(Base.metadata.create_all)
+218
View File
@@ -0,0 +1,218 @@
from contextlib import asynccontextmanager
from pathlib import Path
from uuid import UUID
from fastapi import Depends, FastAPI, HTTPException, Response
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy import func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from .auth import (
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, Task, TaskList, User, utcnow
from .schemas import (
FolderCreate,
FolderOut,
InitializeRequest,
ListCreate,
ListOut,
LoginRequest,
TaskCreate,
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.1.0", lifespan=lifespan, docs_url="/api/docs", openapi_url="/api/openapi.json")
@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, 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)
return user
@app.post("/api/v1/auth/login", response_model=UserOut)
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="用户名或密码错误")
await issue_session(db, response, user)
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="/")
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)):
return list((await db.scalars(select(Folder).where(Folder.user_id == user.id).order_by(Folder.position, Folder.created_at))).all())
@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)):
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)
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())
@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="清单不存在")
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="父任务必须属于同一清单")
task = Task(user_id=user.id, **payload.model_dump())
db.add(task); 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)
@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)):
data = payload.model_dump(exclude_unset=True)
expected_version = data.pop("version")
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 = await db.scalar(
select(Task.id).where(Task.id == task_id, Task.user_id == user.id, Task.deleted_at.is_(None))
)
if exists:
raise HTTPException(status_code=409, detail="任务已被更新,请刷新后重试")
raise HTTPException(status_code=404, detail="任务不存在")
await db.commit()
return 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="任务不存在")
task.deleted_at = utcnow()
task.version += 1
await db.commit()
return Response(status_code=204)
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")
+78
View File
@@ -0,0 +1,78 @@
from datetime import UTC, datetime
from uuid import UUID
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from uuid_utils import uuid7
from .db import Base
def new_id() -> UUID:
return UUID(str(uuid7()))
def utcnow() -> datetime:
return datetime.now(UTC)
class AppState(Base):
__tablename__ = "app_state"
key: Mapped[str] = mapped_column(String(64), primary_key=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class User(Base):
__tablename__ = "users"
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(Text)
timezone: Mapped[str] = mapped_column(String(64), default="Asia/Shanghai")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class Session(Base):
__tablename__ = "sessions"
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class Folder(Base):
__tablename__ = "folders"
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(120))
position: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class TaskList(Base):
__tablename__ = "task_lists"
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
folder_id: Mapped[UUID | None] = mapped_column(ForeignKey("folders.id", ondelete="SET NULL"), nullable=True)
name: Mapped[str] = mapped_column(String(120))
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)
class Task(Base):
__tablename__ = "tasks"
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
list_id: Mapped[UUID] = mapped_column(ForeignKey("task_lists.id", ondelete="CASCADE"), index=True)
parent_id: Mapped[UUID | None] = mapped_column(ForeignKey("tasks.id", ondelete="CASCADE"), nullable=True)
title: Mapped[str] = mapped_column(String(500))
description: Mapped[str] = mapped_column(Text, default="")
priority: Mapped[int] = mapped_column(Integer, default=0)
completed: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
version: Mapped[int] = mapped_column(Integer, default=1)
position: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+79
View File
@@ -0,0 +1,79 @@
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
class InitializeRequest(BaseModel):
username: str = Field(min_length=3, max_length=64, pattern=r"^[A-Za-z0-9_.-]+$")
password: str = Field(min_length=12, max_length=256)
class LoginRequest(BaseModel):
username: str
password: str
class UserOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
username: str
class FolderCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
class FolderOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
name: str
class ListCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
folder_id: UUID | None = None
class ListOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
folder_id: UUID | None
name: str
is_inbox: bool
class TaskCreate(BaseModel):
title: str = Field(min_length=1, max_length=500)
list_id: UUID
description: str = ""
priority: int = Field(default=0, ge=0, le=3)
due_at: datetime | None = None
parent_id: UUID | None = None
class TaskUpdate(BaseModel):
title: str = Field(default=None, min_length=1, max_length=500)
description: str = Field(default=None)
priority: int | None = Field(default=None, ge=0, le=3)
due_at: datetime | None = None
completed: bool | None = None
version: int = Field(ge=1)
class TaskOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
list_id: UUID
parent_id: UUID | None
title: str
description: str
priority: int
completed: bool
due_at: datetime | None
version: int
class TaskPage(BaseModel):
items: list[TaskOut]
next_cursor: str | None = None
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3
View File
@@ -0,0 +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">
</head><body><div id="app"></div></body></html>
+1
View File
@@ -0,0 +1 @@
{"name":"dodo","short_name":"dodo","start_url":"/","display":"standalone","background_color":"#f8f3e8","theme_color":"#f15a29","lang":"zh-CN"}
+1
View File
@@ -0,0 +1 @@
const CACHE='dodo-shell-v1';self.addEventListener('install',e=>e.waitUntil(caches.open(CACHE).then(c=>c.addAll(['/','/manifest.json']))));self.addEventListener('activate',e=>e.waitUntil(self.clients.claim()));self.addEventListener('fetch',e=>{if(e.request.url.includes('/api/'))return;e.respondWith(caches.match(e.request).then(r=>r||fetch(e.request)))})
+12
View File
@@ -0,0 +1,12 @@
services:
dodo:
image: ghcr.io/example/dodo:latest
restart: unless-stopped
ports:
- "8781:8781"
environment:
DODO_DATABASE_URL: postgresql+asyncpg://dodo:[email protected]:5432/dodo
DODO_COOKIE_SECURE: "true"
DODO_SESSION_DAYS: "30"
volumes:
- ./uploads:/app/uploads
+27
View File
@@ -0,0 +1,27 @@
# API
Base path: `/api/v1`
## Setup / Auth
- `GET /setup/status`
- `POST /setup/initialize`
- `POST /auth/login`
- `GET /me`
## Folders
- `GET /folders`
- `POST /folders`
## Lists
- `GET /lists`
- `POST /lists`
## Tasks
- `GET /tasks`
- `POST /tasks`
- `PATCH /tasks/{task_id}` — 必须携带当前 `version`,冲突返回 409
- `DELETE /tasks/{task_id}` — 软删除
## Health
- `GET /health/live`
- `GET /health/ready`
+60
View File
@@ -0,0 +1,60 @@
# dodo 数据模型(第一阶段)
## app_state
- key
- created_at
## users
- id UUIDv7
- username
- password_hash
- timezone
- created_at
## sessions
- id UUIDv7
- token_hash
- user_id
- expires_at
- created_at
## folders
- id UUIDv7
- user_id
- name
- position
- created_at
## task_lists
- id UUIDv7
- user_id
- folder_id nullable
- name
- is_inbox
- position
- created_at
## tasks
- id UUIDv7
- user_id
- list_id
- parent_id nullable
- title
- description
- priority (0-3)
- completed
- due_at nullable
- version
- position
- created_at
- updated_at
- deleted_at nullable
## 后续阶段预留
- task_reminders
- task_recurrence_templates
- task_recurrence_exceptions
- tags / task_tags
- habits / habit_logs / habit_reminders
- attachments
- audit_logs
+26
View File
@@ -0,0 +1,26 @@
# 部署
第一阶段交付方式:单镜像 + 外部 PostgreSQL。
> 当前机器未检测到 Docker,因此本阶段先提供 Dockerfile 草案与运行参数,实际镜像构建需在有 Docker 的环境执行。
## 环境变量
```bash
DODO_DATABASE_URL=postgresql+asyncpg://dodo:password@postgres:5432/dodo
DODO_COOKIE_SECURE=true
DODO_SESSION_DAYS=30
```
## 反向代理
- HTTPS 由反向代理终止
- 应用监听 `0.0.0.0:8781`
- 健康检查:`/health/ready`
## 后续补充
- Dockerfile
- docker-compose.yml 示例
- Alembic 正式迁移目录
- Worker 启动命令
+1
View File
@@ -0,0 +1 @@
{"$schema":"https://shadcn-vue.com/schema.json","style":"new-york","typescript":true,"tailwind":{"css":"src/style.css","baseColor":"stone","cssVariables":true},"aliases":{"components":"@/components","utils":"@/lib/utils","ui":"@/components/ui"}}
+1
View File
@@ -0,0 +1 @@
<!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></head><body><div id="app"></div><script type="module" src="/src/main.ts"></script></body></html>
+1
View File
@@ -0,0 +1 @@
{"name":"dodo-frontend","private":true,"version":"0.1.0","type":"module","scripts":{"dev":"vite --host 0.0.0.0","build":"vue-tsc -b && vite build","test":"vitest run"},"dependencies":{"@vitejs/plugin-vue":"latest","class-variance-authority":"latest","clsx":"latest","lucide-vue-next":"^0.468.0","reka-ui":"latest","tailwind-merge":"latest","vue":"latest","vue-router":"latest"},"devDependencies":{"@tailwindcss/vite":"latest","@types/node":"latest","tailwindcss":"latest","typescript":"^5.7.2","vite":"latest","vitest":"latest","vue-tsc":"latest"}}
+1749
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
{"name":"dodo","short_name":"dodo","start_url":"/","display":"standalone","background_color":"#f8f3e8","theme_color":"#f15a29","lang":"zh-CN"}
+1
View File
@@ -0,0 +1 @@
const CACHE='dodo-shell-v1';self.addEventListener('install',e=>e.waitUntil(caches.open(CACHE).then(c=>c.addAll(['/','/manifest.json']))));self.addEventListener('activate',e=>e.waitUntil(self.clients.claim()));self.addEventListener('fetch',e=>{if(e.request.url.includes('/api/'))return;e.respondWith(caches.match(e.request).then(r=>r||fetch(e.request)))})
+28
View File
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { CalendarDays, Check, CirclePlus, Inbox, ListTodo, Menu, Search, Settings, Sprout } from 'lucide-vue-next'
type TaskList = { id:string; name:string; is_inbox:boolean }
type Task = { id:string; list_id:string; title:string; description:string; priority:number; completed:boolean; version:number; due_at:string|null }
const initialized=ref<boolean|null>(null), authenticated=ref(false), username=ref(''), password=ref(''), lists=ref<TaskList[]>([]), tasks=ref<Task[]>([]), activeList=ref(''), title=ref(''), error=ref('')
const activeName=computed(()=>lists.value.find(x=>x.id===activeList.value)?.name||'今天')
async function api(path:string, options:RequestInit={}) { const r=await fetch('/api/v1'+path,{credentials:'include',headers:{'Content-Type':'application/json',...(options.headers||{})},...options}); if(!r.ok) throw new Error((await r.json()).detail||'请求失败'); return r.status===204?null:r.json() }
async function bootstrap(){try{const s=await api('/setup/status');initialized.value=s.initialized;if(s.initialized){await api('/me');authenticated.value=true;await load()}}catch{authenticated.value=false}}
async function submitAuth(){error.value='';try{if(!initialized.value){await api('/setup/initialize',{method:'POST',body:JSON.stringify({username:username.value,password:password.value})});initialized.value=true}else await api('/auth/login',{method:'POST',body:JSON.stringify({username:username.value,password:password.value})});authenticated.value=true;await load()}catch(e){error.value=(e as Error).message}}
async function load(){lists.value=await api('/lists');activeList.value ||= lists.value[0]?.id;tasks.value=(await api('/tasks')).items}
async function addTask(){if(!title.value.trim()||!activeList.value)return;const t=await api('/tasks',{method:'POST',body:JSON.stringify({title:title.value.trim(),list_id:activeList.value})});tasks.value.push(t);title.value=''}
async function toggle(t:Task){const updated=await api(`/tasks/${t.id}`,{method:'PATCH',body:JSON.stringify({completed:!t.completed,version:t.version})});Object.assign(t,updated)}
function focusQuick(){document.querySelector<HTMLInputElement>('.quick input')?.focus()}
onMounted(bootstrap)
</script>
<template>
<div v-if="initialized===null" class="center">正在打开 dodo</div>
<div v-else-if="!authenticated" class="auth-shell"><section class="auth-card"><div class="brand">do<span>do</span></div><p>{{ initialized?'欢迎回来':'创建你的 dodo' }}</p><input v-model="username" placeholder="用户名"><input v-model="password" type="password" placeholder="密码(至少12位)" @keyup.enter="submitAuth"><button @click="submitAuth">{{ initialized?'登录':'开始使用' }}</button><small v-if="error">{{error}}</small></section></div>
<div v-else class="shell">
<aside><div class="brand small">do<span>do</span></div><nav><button class="active"><Inbox/>收集箱</button><button><ListTodo/>今天</button><button><CalendarDays/>最近7天</button><button><Sprout/>习惯</button></nav><div class="lists"><label>我的清单</label><button v-for="l in lists" :key="l.id" @click="activeList=l.id"><i></i>{{l.name}}</button></div><button class="settings"><Settings/>设置</button></aside>
<main><header><button class="mobile-menu"><Menu/></button><div><p>今天也慢慢来</p><h1>{{activeName}}</h1></div><button class="search"><Search/>搜索</button></header><form @submit.prevent="addTask" class="quick"><CirclePlus/><input v-model="title" placeholder="添加任务"><button>添加</button></form><section class="task-list"><article v-for="t in tasks.filter(x=>x.list_id===activeList)" :key="t.id" :class="{done:t.completed}"><button class="check" @click="toggle(t)"><Check v-if="t.completed"/></button><div><strong>{{t.title}}</strong><p v-if="t.description">{{t.description}}</p></div><span v-if="t.priority" class="priority">P{{4-t.priority}}</span></article><div v-if="!tasks.filter(x=>x.list_id===activeList).length" class="empty"><ListTodo/><b>这里还很安静</b><span>写下第一件想完成的小事吧</span></div></section></main>
<aside class="detail"><span>任务详情</span><div class="paper"><b>选中一个任务</b><p>日期提醒标签和备注会出现在这里</p></div></aside>
<nav class="bottom"><button><ListTodo/><span>今天</span></button><button><Inbox/><span>任务</span></button><button><CalendarDays/><span>日历</span></button><button><Sprout/><span>习惯</span></button></nav>
<button class="fab" @click="focusQuick"><CirclePlus/></button>
</div>
</template>
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')
if ('serviceWorker' in navigator && import.meta.env.PROD) navigator.serviceWorker.register('/sw.js')
+3
View File
@@ -0,0 +1,3 @@
@import "tailwindcss";
:root{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif;color:#29251f;background:#f8f3e8;--accent:#f15a29;--line:#e8dfcf}*{box-sizing:border-box}body{margin:0}button,input{font:inherit}.center,.auth-shell{min-height:100vh;display:grid;place-items:center}.auth-card{width:min(390px,90vw);background:#fffdf7;padding:36px;border:1px solid var(--line);border-radius:12px;box-shadow:0 12px 40px #5d4b3320;display:grid;gap:14px}.brand{font-size:38px;font-weight:800;letter-spacing:-3px}.brand span{color:var(--accent)}.brand.small{font-size:28px;padding:22px}.auth-card input,.quick{border:1px solid var(--line);border-radius:10px;background:white}.auth-card input{padding:12px}.auth-card button,.quick button{border:0;border-radius:8px;background:var(--accent);color:white;padding:11px 16px;font-weight:650}.auth-card small{color:#c0341d}.shell{height:100vh;display:grid;grid-template-columns:230px minmax(400px,1fr) 310px;background:#fffdf8}.shell>aside{border-right:1px solid var(--line);background:#f8f3e8;display:flex;flex-direction:column}.shell nav,.lists{display:grid;padding:8px 12px;gap:3px}.shell nav button,.lists button,.settings{display:flex;gap:10px;align-items:center;border:0;background:transparent;padding:10px 12px;border-radius:9px;color:#625c52;text-align:left}.shell nav svg,.settings svg{width:18px}.shell nav .active{background:#f3dfd2;color:#b73d18;font-weight:650}.lists label{font-size:12px;color:#9a9184;padding:15px 12px 5px}.lists i{width:9px;height:9px;background:#df9c61;border-radius:3px}.settings{margin:auto 12px 15px}main{min-width:0;padding:34px 38px;overflow:auto}header{display:flex;align-items:center;justify-content:space-between}header p{margin:0;color:#9a9184;font-size:13px}h1{font-size:28px;margin:4px 0 25px}.search{display:flex;gap:8px;border:1px solid var(--line);background:#faf7f0;color:#777065;padding:8px 12px;border-radius:10px}.search svg{width:16px}.quick{display:flex;align-items:center;padding:6px 7px 6px 13px;box-shadow:0 4px 18px #5d4b330d}.quick svg{color:var(--accent);width:20px}.quick input{flex:1;border:0;outline:0;padding:10px;background:transparent}.task-list{margin-top:22px;border-top:1px solid var(--line)}article{display:flex;align-items:center;gap:12px;min-height:54px;border-bottom:1px solid var(--line);transition:.2s}article.done{opacity:.45}article.done strong{text-decoration:line-through}.check{width:20px;height:20px;border:1.5px solid #b8ad9e;border-radius:50%;background:white;padding:2px;color:white}.done .check{background:var(--accent);border-color:var(--accent)}.check svg{width:13px;height:13px}.priority{margin-left:auto;color:var(--accent);font-size:12px}.empty{padding:90px 20px;display:grid;place-items:center;color:#a59b8d;gap:9px}.empty svg{width:42px;height:42px;stroke-width:1}.empty b{color:#6e675d}.detail{border-left:1px solid var(--line)!important;border-right:0!important;padding:28px 22px}.detail>span{color:#8f867a;font-size:13px}.paper{margin-top:22px;background:#fffaf0;border:1px dashed #dfd2be;border-radius:12px;padding:22px}.paper p{color:#948a7c;font-size:14px;line-height:1.6}.bottom,.fab,.mobile-menu{display:none}
@media(max-width:800px){.shell{display:block;height:auto;min-height:100vh}.shell>aside,.detail{display:none}main{padding:24px 18px 100px}.search{font-size:0}.bottom{display:flex;position:fixed;bottom:0;left:0;right:0;background:#fffdf8;border-top:1px solid var(--line);justify-content:space-around;padding:8px 0 max(8px,env(safe-area-inset-bottom))}.bottom button{border:0;background:transparent;color:#766e62;display:grid;place-items:center;font-size:11px;gap:2px}.bottom svg{width:20px}.fab{display:grid;place-items:center;position:fixed;right:20px;bottom:78px;width:52px;height:52px;border:0;border-radius:50%;background:var(--accent);color:white;box-shadow:0 8px 20px #f15a2950}.mobile-menu{display:block;border:0;background:transparent;padding:0;margin-right:12px}header>div{margin-right:auto}.quick button{display:none}}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+1
View File
@@ -0,0 +1 @@
{"compilerOptions":{"target":"ES2022","useDefineForClassFields":true,"module":"ESNext","moduleResolution":"Bundler","strict":true,"jsx":"preserve","resolveJsonModule":true,"isolatedModules":true,"esModuleInterop":true,"lib":["ES2022","DOM","DOM.Iterable"],"skipLibCheck":true,"noEmit":true},"include":["src/**/*.ts","src/**/*.vue"]}
+1
View File
@@ -0,0 +1 @@
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/lib/utils.ts","./src/app.vue"],"version":"5.9.3"}
+1
View File
@@ -0,0 +1 @@
{"files":[],"references":[{"path":"./tsconfig.app.json"}]}
+5
View File
@@ -0,0 +1,5 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({plugins:[vue(),tailwindcss()],server:{proxy:{'/api':'http://localhost:8781','/health':'http://localhost:8781'}}})
+46
View File
@@ -0,0 +1,46 @@
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import async_engine_from_config
from backend import models # noqa: F401
from backend.config import get_settings
from backend.db import Base
config = context.config
config.set_main_option("sqlalchemy.url", get_settings().database_url)
if config.config_file_name:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline():
context.configure(url=config.get_main_option("sqlalchemy.url"), target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle":"named"})
with context.begin_transaction(): context.run_migrations()
def do_run_migrations(connection):
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations():
connectable = async_engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online():
import asyncio
asyncio.run(run_async_migrations())
if context.is_offline_mode(): run_migrations_offline()
else: run_migrations_online()
+17
View File
@@ -0,0 +1,17 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
+99
View File
@@ -0,0 +1,99 @@
"""initial schema
Revision ID: 0001
Revises:
"""
import sqlalchemy as sa
from alembic import op
revision = "0001"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"app_state",
sa.Column("key", sa.String(64), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_table(
"users",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column("username", sa.String(64), nullable=False),
sa.Column("password_hash", sa.Text(), nullable=False),
sa.Column("timezone", sa.String(64), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint("username"),
)
op.create_index("ix_users_username", "users", ["username"])
op.create_table(
"sessions",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column("token_hash", sa.String(64), nullable=False),
sa.Column("user_id", sa.Uuid(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_sessions_token_hash", "sessions", ["token_hash"], unique=True)
op.create_index("ix_sessions_user_id", "sessions", ["user_id"])
op.create_table(
"folders",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column("user_id", sa.Uuid(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("name", sa.String(120), nullable=False),
sa.Column("position", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint("user_id", "id", name="uq_folders_user_id_id"),
)
op.create_index("ix_folders_user_id", "folders", ["user_id"])
op.create_table(
"task_lists",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("folder_id", sa.Uuid()),
sa.Column("name", sa.String(120), nullable=False),
sa.Column("is_inbox", sa.Boolean(), nullable=False),
sa.Column("position", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(
["user_id", "folder_id"], ["folders.user_id", "folders.id"], ondelete="SET NULL"
),
sa.UniqueConstraint("user_id", "id", name="uq_task_lists_user_id_id"),
)
op.create_index("ix_task_lists_user_id", "task_lists", ["user_id"])
op.create_table(
"tasks",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("list_id", sa.Uuid(), nullable=False),
sa.Column("parent_id", sa.Uuid()),
sa.Column("title", sa.String(500), nullable=False),
sa.Column("description", sa.Text(), nullable=False),
sa.Column("priority", sa.Integer(), nullable=False),
sa.Column("completed", sa.Boolean(), nullable=False),
sa.Column("due_at", sa.DateTime(timezone=True)),
sa.Column("version", sa.Integer(), nullable=False),
sa.Column("position", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True)),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(
["user_id", "list_id"], ["task_lists.user_id", "task_lists.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(["parent_id"], ["tasks.id"], ondelete="CASCADE"),
)
op.create_index("ix_tasks_user_id", "tasks", ["user_id"])
op.create_index("ix_tasks_list_id", "tasks", ["list_id"])
op.create_index("ix_tasks_completed", "tasks", ["completed"])
def downgrade() -> None:
op.drop_table("tasks")
op.drop_table("task_lists")
op.drop_table("folders")
op.drop_table("sessions")
op.drop_table("users")
op.drop_table("app_state")
+41
View File
@@ -0,0 +1,41 @@
[project]
name = "dodo"
version = "0.1.0"
description = "A warm, self-hosted task and habit manager"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.116,<1",
"uvicorn[standard]>=0.35,<1",
"sqlalchemy[asyncio]>=2.0,<3",
"asyncpg>=0.30,<1",
"alembic>=1.16,<2",
"pydantic-settings>=2.10,<3",
"argon2-cffi>=25,<26",
"python-multipart>=0.0.20,<1",
"uuid-utils>=0.11,<1",
"structlog>=25,<26",
"apscheduler>=3.11,<4",
]
[dependency-groups]
dev = [
"pytest>=8.4,<9",
"pytest-asyncio>=1.1,<2",
"httpx>=0.28,<1",
"aiosqlite>=0.21,<1",
"ruff>=0.12,<1",
]
[tool.pytest.ini_options]
pythonpath = ["."]
asyncio_mode = "auto"
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
ignore = ["B008"]
[tool.ruff.lint.per-file-ignores]
"migrations/versions/*.py" = ["E501"]
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
set -eu
uv run alembic upgrade head
exec uv run uvicorn backend.main:app --host 0.0.0.0 --port 8781
+19
View File
@@ -0,0 +1,19 @@
import os
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def client(tmp_path: Path):
os.environ["DODO_DATABASE_URL"] = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
os.environ["DODO_AUTO_CREATE_SCHEMA"] = "true"
from backend.config import get_settings
get_settings.cache_clear()
from backend.db import reset_engine
reset_engine()
from backend.main import app
with TestClient(app) as test_client:
yield test_client
reset_engine()
+130
View File
@@ -0,0 +1,130 @@
import os
from fastapi.testclient import TestClient
os.environ.setdefault("DODO_DATABASE_URL", "sqlite+aiosqlite:////tmp/dodo-health-test.db")
os.environ.setdefault("DODO_AUTO_CREATE_SCHEMA", "true")
from backend.main import app
def test_health_live():
with TestClient(app) as client:
response = client.get("/health/live")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
def test_setup_status_starts_uninitialized(client):
response = client.get("/api/v1/setup/status")
assert response.status_code == 200
assert response.json() == {"initialized": False}
def test_initialize_login_and_create_task(client):
initialized = client.post(
"/api/v1/setup/initialize",
json={"username": "owner", "password": "correct horse battery staple"},
)
assert initialized.status_code == 201
assert initialized.json()["username"] == "owner"
assert "dodo_session" in initialized.cookies
folder = client.post("/api/v1/folders", json={"name": "工作"})
assert folder.status_code == 201
task_list = client.post(
"/api/v1/lists", json={"name": "本周", "folder_id": folder.json()["id"]}
)
assert task_list.status_code == 201
task = client.post(
"/api/v1/tasks",
json={"title": "完成 dodo 第一阶段", "list_id": task_list.json()["id"], "priority": 3},
)
assert task.status_code == 201
assert task.json()["title"] == "完成 dodo 第一阶段"
tasks = client.get("/api/v1/tasks")
assert tasks.status_code == 200
assert len(tasks.json()["items"]) == 1
def test_initialize_is_closed_after_first_user(client):
payload = {"username": "owner", "password": "correct horse battery staple"}
assert client.post("/api/v1/setup/initialize", json=payload).status_code == 201
response = client.post("/api/v1/setup/initialize", json=payload)
assert response.status_code == 409
def test_unauthenticated_task_access_is_rejected(client):
response = client.get("/api/v1/tasks")
assert response.status_code == 401
def test_task_can_be_updated_completed_and_soft_deleted(client):
client.post(
"/api/v1/setup/initialize",
json={"username": "owner", "password": "correct horse battery staple"},
)
inbox = client.get("/api/v1/lists").json()[0]
task = client.post(
"/api/v1/tasks", json={"title": "旧标题", "list_id": inbox["id"]}
).json()
updated = client.patch(
f"/api/v1/tasks/{task['id']}",
json={"title": "新标题", "completed": True, "version": task["version"]},
)
assert updated.status_code == 200
assert updated.json()["title"] == "新标题"
assert updated.json()["completed"] is True
assert updated.json()["version"] == 2
conflict = client.patch(
f"/api/v1/tasks/{task['id']}",
json={"title": "冲突标题", "version": task["version"]},
)
assert conflict.status_code == 409
deleted = client.delete(f"/api/v1/tasks/{task['id']}")
assert deleted.status_code == 204
assert client.get("/api/v1/tasks").json()["items"] == []
def test_task_update_rejects_null_title(client):
client.post(
"/api/v1/setup/initialize",
json={"username": "owner", "password": "correct horse battery staple"},
)
inbox = client.get("/api/v1/lists").json()[0]
task = client.post("/api/v1/tasks", json={"title": "任务", "list_id": inbox["id"]}).json()
response = client.patch(
f"/api/v1/tasks/{task['id']}", json={"title": None, "version": task["version"]}
)
assert response.status_code == 422
def test_subtask_parent_must_belong_to_same_user_and_list(client):
client.post(
"/api/v1/setup/initialize",
json={"username": "owner", "password": "correct horse battery staple"},
)
inbox = client.get("/api/v1/lists").json()[0]
other = client.post("/api/v1/lists", json={"name": "其他"}).json()
parent = client.post("/api/v1/tasks", json={"title": "父任务", "list_id": inbox["id"]}).json()
response = client.post(
"/api/v1/tasks",
json={"title": "子任务", "list_id": other["id"], "parent_id": parent["id"]},
)
assert response.status_code == 400
def test_logout_revokes_current_session(client):
client.post(
"/api/v1/setup/initialize",
json={"username": "owner", "password": "correct horse battery staple"},
)
assert client.get("/api/v1/me").status_code == 200
assert client.post("/api/v1/auth/logout").status_code == 204
assert client.get("/api/v1/me").status_code == 401
Generated
+1278
View File
File diff suppressed because it is too large Load Diff
View File
+20
View File
@@ -0,0 +1,20 @@
import asyncio
from apscheduler.schedulers.asyncio import AsyncIOScheduler
async def scan_due_reminders() -> None:
# Reminder delivery is implemented in phase 2. Keeping the scheduler isolated
# ensures API restarts never own reminder timing.
return None
async def main() -> None:
scheduler = AsyncIOScheduler(timezone="UTC")
scheduler.add_job(scan_due_reminders, "interval", seconds=30, max_instances=1)
scheduler.start()
await asyncio.Event().wait()
if __name__ == "__main__":
asyncio.run(main())