feat: bootstrap dodo phase one
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
.git
|
||||
.venv
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
__pycache__
|
||||
*.db
|
||||
.env
|
||||
@@ -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
@@ -0,0 +1,12 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
.env
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
.DS_Store
|
||||
*.db
|
||||
uploads/
|
||||
+19
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"dodo","short_name":"dodo","start_url":"/","display":"standalone","background_color":"#f8f3e8","theme_color":"#f15a29","lang":"zh-CN"}
|
||||
@@ -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)))})
|
||||
@@ -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
@@ -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`
|
||||
@@ -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
|
||||
@@ -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 启动命令
|
||||
@@ -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"}}
|
||||
@@ -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>
|
||||
@@ -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"}}
|
||||
Generated
+1749
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
{"name":"dodo","short_name":"dodo","start_url":"/","display":"standalone","background_color":"#f8f3e8","theme_color":"#f15a29","lang":"zh-CN"}
|
||||
@@ -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)))})
|
||||
@@ -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>
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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}}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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"]}
|
||||
@@ -0,0 +1 @@
|
||||
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/lib/utils.ts","./src/app.vue"],"version":"5.9.3"}
|
||||
@@ -0,0 +1 @@
|
||||
{"files":[],"references":[{"path":"./tsconfig.app.json"}]}
|
||||
@@ -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'}}})
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
@@ -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")
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user