feat: recurring tasks, habits, attachments, import/export, audit and security
This commit is contained in:
+23
-3
@@ -4,7 +4,7 @@ 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 fastapi import Cookie, Depends, HTTPException, Request, Response, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -14,6 +14,7 @@ from .models import Session, User
|
||||
|
||||
password_hasher = PasswordHasher()
|
||||
COOKIE_NAME = "dodo_session"
|
||||
CSRF_COOKIE_NAME = "dodo_csrf"
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
@@ -31,15 +32,24 @@ def verify_password(password_hash: str, password: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def issue_session(db: AsyncSession, response: Response, user: User) -> None:
|
||||
async def issue_session(db: AsyncSession, response: Response, user: User, request: Request | None = None) -> None:
|
||||
token = secrets.token_urlsafe(32)
|
||||
csrf = secrets.token_urlsafe(24)
|
||||
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))
|
||||
db.add(Session(
|
||||
token_hash=hash_token(token), user_id=user.id, expires_at=expires,
|
||||
ip_address=request.client.host if request and request.client else None,
|
||||
user_agent=request.headers.get("user-agent", "")[:500] if request else None,
|
||||
))
|
||||
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="/",
|
||||
)
|
||||
response.set_cookie(
|
||||
CSRF_COOKIE_NAME, csrf, max_age=get_settings().session_days * 86400,
|
||||
httponly=False, secure=get_settings().cookie_secure, samesite="lax", path="/",
|
||||
)
|
||||
|
||||
|
||||
async def session_token(
|
||||
@@ -63,3 +73,13 @@ async def current_user(
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="会话已失效,请重新登录")
|
||||
return user
|
||||
|
||||
|
||||
async def current_session(
|
||||
token: str = Depends(session_token),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Session:
|
||||
row = await db.scalar(select(Session).where(Session.token_hash == hash_token(token), Session.expires_at > datetime.now(UTC)))
|
||||
if row is None:
|
||||
raise HTTPException(status_code=401, detail="会话已失效,请重新登录")
|
||||
return row
|
||||
|
||||
@@ -10,6 +10,10 @@ class Settings(BaseSettings):
|
||||
cookie_secure: bool = False
|
||||
trusted_proxies: str = ""
|
||||
auto_create_schema: bool = False
|
||||
attachment_dir: str = "./data/attachments"
|
||||
attachment_max_mb: int = 20
|
||||
login_attempts: int = 5
|
||||
login_window_seconds: int = 300
|
||||
|
||||
model_config = SettingsConfigDict(env_prefix="DODO_", env_file=".env", extra="ignore")
|
||||
|
||||
|
||||
+103
-7
@@ -1,12 +1,15 @@
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Query, Response
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response
|
||||
from fastapi.openapi.docs import get_swagger_ui_html
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy import delete, exists, func, or_, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@@ -14,6 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from .auth import (
|
||||
COOKIE_NAME,
|
||||
CSRF_COOKIE_NAME,
|
||||
current_user,
|
||||
hash_password,
|
||||
hash_token,
|
||||
@@ -23,6 +27,8 @@ from .auth import (
|
||||
)
|
||||
from .db import create_schema, get_db
|
||||
from .models import AppState, Folder, Session, Tag, Task, TaskList, TaskTag, User, utcnow
|
||||
from .mvp import audit
|
||||
from .mvp import router as mvp_router
|
||||
from .schemas import (
|
||||
BatchResult,
|
||||
BatchTaskUpdate,
|
||||
@@ -55,13 +61,44 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="dodo",
|
||||
version="0.1.0",
|
||||
version="0.2.0",
|
||||
lifespan=lifespan,
|
||||
docs_url="/api/docs",
|
||||
openapi_url="/api/openapi.json",
|
||||
docs_url=None,
|
||||
openapi_url=None,
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def security(request: Request, call_next):
|
||||
if request.method not in {"GET", "HEAD", "OPTIONS"} and request.cookies.get(COOKIE_NAME):
|
||||
csrf_cookie = request.cookies.get(CSRF_COOKIE_NAME)
|
||||
csrf_header = request.headers.get("x-csrf-token")
|
||||
# SameSite cookies plus same-origin validation; API clients may explicitly double-submit.
|
||||
origin = request.headers.get("origin")
|
||||
if origin and (not csrf_cookie or csrf_header != csrf_cookie):
|
||||
return JSONResponse({"detail": "CSRF 校验失败"}, status_code=403)
|
||||
response = await call_next(request)
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["Referrer-Policy"] = "same-origin"
|
||||
response.headers["Content-Security-Policy"] = "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'"
|
||||
return response
|
||||
|
||||
|
||||
@app.get("/api/docs", include_in_schema=False)
|
||||
async def docs(_: User = Depends(current_user)):
|
||||
return get_swagger_ui_html(openapi_url="/api/openapi.json", title="dodo API")
|
||||
|
||||
|
||||
@app.get("/api/openapi.json", include_in_schema=False)
|
||||
async def openapi(_: User = Depends(current_user)):
|
||||
return app.openapi()
|
||||
|
||||
|
||||
app.include_router(mvp_router)
|
||||
_login_attempts: dict[tuple[str, str], deque[float]] = defaultdict(deque)
|
||||
|
||||
|
||||
@app.get("/health/live")
|
||||
async def live():
|
||||
return {"status": "ok"}
|
||||
@@ -83,6 +120,7 @@ async def setup_status(db: AsyncSession = Depends(get_db)):
|
||||
async def initialize(
|
||||
payload: InitializeRequest,
|
||||
response: Response,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
count = await db.scalar(select(func.count()).select_from(User))
|
||||
@@ -99,7 +137,7 @@ async def initialize(
|
||||
db.add(TaskList(user_id=user.id, name="收集箱", is_inbox=True))
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
await issue_session(db, response, user)
|
||||
await issue_session(db, response, user, request)
|
||||
return user
|
||||
|
||||
|
||||
@@ -107,12 +145,26 @@ async def initialize(
|
||||
async def login(
|
||||
payload: LoginRequest,
|
||||
response: Response,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
from .config import get_settings
|
||||
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
key = (payload.username.casefold(), ip)
|
||||
now = time.monotonic()
|
||||
attempts = _login_attempts[key]
|
||||
window = get_settings().login_window_seconds
|
||||
while attempts and attempts[0] < now - window:
|
||||
attempts.popleft()
|
||||
if len(attempts) >= get_settings().login_attempts:
|
||||
raise HTTPException(status_code=429, detail="登录尝试过多,请稍后再试", headers={"Retry-After": str(window)})
|
||||
user = await db.scalar(select(User).where(User.username == payload.username))
|
||||
if user is None or not verify_password(user.password_hash, payload.password):
|
||||
attempts.append(now)
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
await issue_session(db, response, user)
|
||||
attempts.clear()
|
||||
await issue_session(db, response, user, request)
|
||||
return user
|
||||
|
||||
|
||||
@@ -132,6 +184,35 @@ async def logout(
|
||||
await db.delete(session)
|
||||
await db.commit()
|
||||
response.delete_cookie(COOKIE_NAME, path="/")
|
||||
response.delete_cookie(CSRF_COOKIE_NAME, path="/")
|
||||
return Response(status_code=204, headers=response.headers)
|
||||
|
||||
|
||||
@app.get("/api/v1/sessions")
|
||||
async def list_sessions(
|
||||
token: str = Depends(session_token),
|
||||
user: User = Depends(current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
current_hash = hash_token(token)
|
||||
rows = (await db.scalars(select(Session).where(Session.user_id == user.id).order_by(Session.created_at.desc()))).all()
|
||||
return [{"id": row.id, "current": row.token_hash == current_hash, "created_at": row.created_at, "last_seen_at": row.last_seen_at, "expires_at": row.expires_at, "ip_address": row.ip_address, "user_agent": row.user_agent} for row in rows]
|
||||
|
||||
|
||||
@app.delete("/api/v1/sessions/{session_id}", status_code=204)
|
||||
async def revoke_session(
|
||||
session_id: UUID,
|
||||
response: Response,
|
||||
token: str = Depends(session_token),
|
||||
user: User = Depends(current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
row = await db.scalar(select(Session).where(Session.id == session_id, Session.user_id == user.id))
|
||||
if not row: raise HTTPException(404, "会话不存在")
|
||||
current = row.token_hash == hash_token(token)
|
||||
await db.delete(row); await db.commit()
|
||||
if current:
|
||||
response.delete_cookie(COOKIE_NAME, path="/"); response.delete_cookie(CSRF_COOKIE_NAME, path="/")
|
||||
return Response(status_code=204, headers=response.headers)
|
||||
|
||||
|
||||
@@ -169,6 +250,8 @@ async def rename_folder(
|
||||
if folder is None:
|
||||
raise HTTPException(status_code=404, detail="文件夹不存在")
|
||||
folder.name = payload.name
|
||||
await db.flush()
|
||||
audit(db, user.id, "update", "folder", folder.id)
|
||||
await db.commit()
|
||||
return folder
|
||||
|
||||
@@ -187,6 +270,7 @@ async def delete_folder(
|
||||
if folder is None:
|
||||
raise HTTPException(status_code=404, detail="文件夹不存在")
|
||||
folder.deleted_at = utcnow()
|
||||
audit(db, user.id, "delete", "folder", folder.id)
|
||||
await db.execute(
|
||||
update(TaskList)
|
||||
.where(TaskList.user_id == user.id, TaskList.folder_id == folder.id)
|
||||
@@ -212,6 +296,8 @@ async def create_list(
|
||||
raise HTTPException(status_code=404, detail="文件夹不存在")
|
||||
item = TaskList(user_id=user.id, folder_id=payload.folder_id, name=payload.name)
|
||||
db.add(item)
|
||||
await db.flush()
|
||||
audit(db, user.id, "create", "list", item.id)
|
||||
await db.commit()
|
||||
await db.refresh(item)
|
||||
return item
|
||||
@@ -248,7 +334,10 @@ async def rename_list(
|
||||
if item.is_inbox:
|
||||
raise HTTPException(status_code=409, detail="系统收集箱不能重命名")
|
||||
item.name = payload.name
|
||||
await db.flush()
|
||||
audit(db, user.id, "update", "list", item.id)
|
||||
await db.commit()
|
||||
await db.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
@@ -364,6 +453,7 @@ async def create_task(
|
||||
task = Task(user_id=user.id, **data)
|
||||
db.add(task)
|
||||
await db.flush()
|
||||
audit(db, user.id, "create", "task", task.id)
|
||||
if tag_ids:
|
||||
db.add_all(TaskTag(task_id=task.id, tag_id=tag_id) for tag_id in tag_ids)
|
||||
await db.commit()
|
||||
@@ -499,6 +589,10 @@ async def update_task(
|
||||
.where(Task.parent_id == task.id, Task.user_id == user.id, Task.deleted_at.is_(None))
|
||||
.values(list_id=task.list_id, version=Task.version + 1, updated_at=utcnow())
|
||||
)
|
||||
changed = {k for k in data if k not in {"version", "updated_at"}}
|
||||
if changed & {"title", "description", "priority", "due_at", "list_id", "completed"}:
|
||||
action = "complete" if data.get("completed") is True else "update"
|
||||
audit(db, user.id, action, "task", task.id, fields=sorted(changed))
|
||||
await db.commit()
|
||||
return await _task_detail(db, task)
|
||||
|
||||
@@ -520,6 +614,7 @@ async def delete_task(
|
||||
.where(or_(Task.id == task.id, Task.parent_id == task.id), Task.user_id == user.id)
|
||||
.values(deleted_at=deleted_at, version=Task.version + 1, updated_at=deleted_at)
|
||||
)
|
||||
audit(db, user.id, "delete", "task", task.id)
|
||||
await db.commit()
|
||||
return Response(status_code=204)
|
||||
|
||||
@@ -564,6 +659,7 @@ async def restore_task(
|
||||
.where(or_(Task.id == task.id, Task.parent_id == task.id), Task.user_id == user.id)
|
||||
.values(deleted_at=None, version=Task.version + 1, updated_at=now)
|
||||
)
|
||||
audit(db, user.id, "restore", "task", task.id)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
return await _task_detail(db, task)
|
||||
|
||||
+99
-2
@@ -1,7 +1,18 @@
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, date, datetime
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
Date,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from uuid_utils import uuid7
|
||||
|
||||
@@ -38,6 +49,9 @@ class Session(Base):
|
||||
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)
|
||||
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
ip_address: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
user_agent: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
|
||||
class Folder(Base):
|
||||
@@ -80,6 +94,7 @@ class TaskTag(Base):
|
||||
|
||||
class Task(Base):
|
||||
__tablename__ = "tasks"
|
||||
__table_args__ = (UniqueConstraint("user_id", "external_id", name="uq_tasks_external_id"),)
|
||||
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)
|
||||
@@ -94,3 +109,85 @@ class Task(Base):
|
||||
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)
|
||||
external_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
|
||||
class RecurrenceTemplate(Base):
|
||||
__tablename__ = "recurrence_templates"
|
||||
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
||||
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
task_id: Mapped[UUID] = mapped_column(ForeignKey("tasks.id", ondelete="CASCADE"), unique=True)
|
||||
rrule: Mapped[str] = mapped_column(Text)
|
||||
starts_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
ends_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
|
||||
class RecurrenceException(Base):
|
||||
__tablename__ = "recurrence_exceptions"
|
||||
__table_args__ = (UniqueConstraint("template_id", "occurrence_at", name="uq_recurrence_exception"),)
|
||||
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
||||
template_id: Mapped[UUID] = mapped_column(ForeignKey("recurrence_templates.id", ondelete="CASCADE"), index=True)
|
||||
occurrence_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
title: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
completed: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
deleted: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
|
||||
class Habit(Base):
|
||||
__tablename__ = "habits"
|
||||
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(200))
|
||||
kind: Mapped[str] = mapped_column(String(16), default="boolean")
|
||||
target: Mapped[float] = mapped_column(Float, default=1)
|
||||
max_value: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
schedule_type: Mapped[str] = mapped_column(String(16), default="daily")
|
||||
weekdays: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
month_days: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
interval_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
start_date: Mapped[date] = mapped_column(Date)
|
||||
archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
|
||||
class HabitLog(Base):
|
||||
__tablename__ = "habit_logs"
|
||||
__table_args__ = (UniqueConstraint("habit_id", "day", name="uq_habit_log_day"),)
|
||||
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
||||
habit_id: Mapped[UUID] = mapped_column(ForeignKey("habits.id", ondelete="CASCADE"), index=True)
|
||||
day: Mapped[date] = mapped_column(Date)
|
||||
value: Mapped[float] = mapped_column(Float)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
|
||||
class HabitPause(Base):
|
||||
__tablename__ = "habit_pauses"
|
||||
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
||||
habit_id: Mapped[UUID] = mapped_column(ForeignKey("habits.id", ondelete="CASCADE"), index=True)
|
||||
start_date: Mapped[date] = mapped_column(Date)
|
||||
end_date: Mapped[date] = mapped_column(Date)
|
||||
|
||||
|
||||
class Attachment(Base):
|
||||
__tablename__ = "attachments"
|
||||
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
||||
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
task_id: Mapped[UUID] = mapped_column(ForeignKey("tasks.id", ondelete="CASCADE"), index=True)
|
||||
filename: Mapped[str] = mapped_column(String(255))
|
||||
storage_name: Mapped[str] = mapped_column(String(255), unique=True)
|
||||
mime_type: Mapped[str] = mapped_column(String(127))
|
||||
size: Mapped[int] = mapped_column(Integer)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
||||
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
action: Mapped[str] = mapped_column(String(32))
|
||||
entity_type: Mapped[str] = mapped_column(String(32))
|
||||
entity_id: Mapped[UUID | None] = mapped_column(nullable=True)
|
||||
details: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
+450
@@ -0,0 +1,450 @@
|
||||
import csv
|
||||
import io
|
||||
import re
|
||||
from datetime import UTC, date, datetime, time, timedelta
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from .auth import current_user
|
||||
from .config import get_settings
|
||||
from .db import get_db
|
||||
from .models import (
|
||||
Attachment,
|
||||
AuditLog,
|
||||
Folder,
|
||||
Habit,
|
||||
HabitLog,
|
||||
HabitPause,
|
||||
RecurrenceException,
|
||||
RecurrenceTemplate,
|
||||
Tag,
|
||||
Task,
|
||||
TaskList,
|
||||
User,
|
||||
new_id,
|
||||
utcnow,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v1")
|
||||
|
||||
|
||||
def audit(db: AsyncSession, user_id: UUID, action: str, entity_type: str, entity_id=None, **details):
|
||||
db.add(AuditLog(user_id=user_id, action=action, entity_type=entity_type, entity_id=entity_id, details=details))
|
||||
|
||||
|
||||
class RecurrenceCreate(BaseModel):
|
||||
task_id: UUID
|
||||
rrule: str = Field(min_length=5, max_length=1000)
|
||||
|
||||
|
||||
class RecurrenceChange(BaseModel):
|
||||
title: str | None = Field(None, min_length=1, max_length=500)
|
||||
due_at: datetime | None = None
|
||||
rrule: str | None = None
|
||||
|
||||
|
||||
class OccurrenceComplete(BaseModel):
|
||||
occurrence_at: datetime
|
||||
|
||||
|
||||
_RRULE_PART = re.compile(r"^[A-Z]+=[A-Z0-9,+-]+$")
|
||||
_WEEKDAYS = {"MO": 0, "TU": 1, "WE": 2, "TH": 3, "FR": 4, "SA": 5, "SU": 6}
|
||||
|
||||
|
||||
def parse_rrule(value: str) -> dict[str, str]:
|
||||
parts = {}
|
||||
for part in value.upper().split(";"):
|
||||
if not _RRULE_PART.fullmatch(part):
|
||||
raise HTTPException(422, "无效的 RRULE")
|
||||
key, val = part.split("=", 1)
|
||||
parts[key] = val
|
||||
if parts.get("FREQ") not in {"DAILY", "WEEKLY", "MONTHLY"}:
|
||||
raise HTTPException(422, "仅支持 DAILY、WEEKLY、MONTHLY")
|
||||
try:
|
||||
if "INTERVAL" in parts and int(parts["INTERVAL"]) < 1:
|
||||
raise ValueError
|
||||
if "COUNT" in parts and int(parts["COUNT"]) < 1:
|
||||
raise ValueError
|
||||
except ValueError as exc:
|
||||
raise HTTPException(422, "无效的 RRULE 数字") from exc
|
||||
return parts
|
||||
|
||||
|
||||
def occurrences(rule: str, starts: datetime, start: datetime, end: datetime, cutoff=None):
|
||||
parts = parse_rrule(rule)
|
||||
interval = int(parts.get("INTERVAL", 1))
|
||||
count = int(parts.get("COUNT", 100000))
|
||||
if "UNTIL" in parts:
|
||||
until = datetime.fromisoformat(parts["UNTIL"])
|
||||
until = until.replace(tzinfo=UTC) if until.tzinfo is None else until
|
||||
else:
|
||||
until = end
|
||||
if starts.tzinfo is None:
|
||||
starts = starts.replace(tzinfo=UTC)
|
||||
if until.tzinfo is None:
|
||||
until = until.replace(tzinfo=UTC)
|
||||
if start.tzinfo is None:
|
||||
start = start.replace(tzinfo=UTC)
|
||||
if end.tzinfo is None:
|
||||
end = end.replace(tzinfo=UTC)
|
||||
result = []
|
||||
cursor = starts
|
||||
emitted = 0
|
||||
while cursor <= until and emitted < count:
|
||||
include = False
|
||||
if parts["FREQ"] == "DAILY":
|
||||
include = (cursor.date() - starts.date()).days % interval == 0
|
||||
elif parts["FREQ"] == "WEEKLY":
|
||||
days = {_WEEKDAYS[x] for x in parts.get("BYDAY", list(_WEEKDAYS)[starts.weekday()]).split(",")}
|
||||
include = cursor.weekday() in days and ((cursor.date() - starts.date()).days // 7) % interval == 0
|
||||
else:
|
||||
month_delta = (cursor.year - starts.year) * 12 + cursor.month - starts.month
|
||||
month_days = {int(x) for x in parts.get("BYMONTHDAY", str(starts.day)).split(",")}
|
||||
include = month_delta % interval == 0 and cursor.day in month_days
|
||||
if include and cursor >= starts:
|
||||
emitted += 1
|
||||
if start <= cursor <= end:
|
||||
result.append(cursor)
|
||||
cursor += timedelta(days=1)
|
||||
return result
|
||||
|
||||
|
||||
async def owned_task(db, user_id, task_id):
|
||||
task = await db.scalar(select(Task).where(Task.id == task_id, Task.user_id == user_id, Task.deleted_at.is_(None)))
|
||||
if not task:
|
||||
raise HTTPException(404, "任务不存在")
|
||||
return task
|
||||
|
||||
|
||||
async def owned_recurrence(db, user_id, recurrence_id):
|
||||
row = await db.scalar(select(RecurrenceTemplate).where(RecurrenceTemplate.id == recurrence_id, RecurrenceTemplate.user_id == user_id))
|
||||
if not row:
|
||||
raise HTTPException(404, "重复规则不存在")
|
||||
return row
|
||||
|
||||
|
||||
@router.post("/recurrences", status_code=201)
|
||||
async def create_recurrence(payload: RecurrenceCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
task = await owned_task(db, user.id, payload.task_id)
|
||||
if not task.due_at:
|
||||
raise HTTPException(422, "重复任务需要截止时间")
|
||||
parse_rrule(payload.rrule)
|
||||
if await db.scalar(select(RecurrenceTemplate.id).where(RecurrenceTemplate.task_id == task.id)):
|
||||
raise HTTPException(409, "任务已有重复规则")
|
||||
row = RecurrenceTemplate(user_id=user.id, task_id=task.id, rrule=payload.rrule.upper(), starts_at=task.due_at)
|
||||
db.add(row)
|
||||
await db.commit(); await db.refresh(row)
|
||||
return {"id": row.id, "task_id": row.task_id, "rrule": row.rrule, "starts_at": row.starts_at}
|
||||
|
||||
|
||||
@router.get("/calendar")
|
||||
async def calendar(start: date, end: date, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
if end < start or (end - start).days > 366:
|
||||
raise HTTPException(422, "日期范围无效或超过一年")
|
||||
start_dt = datetime.combine(start, time.min, tzinfo=UTC)
|
||||
end_dt = datetime.combine(end, time.max, tzinfo=UTC)
|
||||
rows = (await db.execute(select(RecurrenceTemplate, Task).join(Task).where(RecurrenceTemplate.user_id == user.id, Task.deleted_at.is_(None)))).all()
|
||||
output = []
|
||||
for template, task in rows:
|
||||
exception_rows = (await db.scalars(select(RecurrenceException).where(RecurrenceException.template_id == template.id))).all()
|
||||
exceptions: dict[datetime, RecurrenceException] = {}
|
||||
for exc in exception_rows:
|
||||
key = exc.occurrence_at.replace(tzinfo=UTC) if exc.occurrence_at.tzinfo is None else exc.occurrence_at
|
||||
exceptions[key] = exc
|
||||
for at in occurrences(template.rrule, template.starts_at, start_dt, end_dt, template.ends_at):
|
||||
exception = exceptions.get(at)
|
||||
if exception and exception.deleted:
|
||||
continue
|
||||
output.append({"recurrence_id": template.id, "task_id": task.id, "occurrence_at": at, "title": exception.title if exception and exception.title else task.title, "due_at": exception.due_at if exception and exception.due_at else at, "completed": bool(exception and exception.completed)})
|
||||
return sorted(output, key=lambda item: item["occurrence_at"])
|
||||
|
||||
|
||||
async def upsert_exception(db, template_id, at):
|
||||
row = await db.scalar(select(RecurrenceException).where(RecurrenceException.template_id == template_id, RecurrenceException.occurrence_at == at))
|
||||
if not row:
|
||||
row = RecurrenceException(template_id=template_id, occurrence_at=at)
|
||||
db.add(row)
|
||||
await db.flush()
|
||||
return row
|
||||
|
||||
|
||||
@router.patch("/recurrences/{recurrence_id}")
|
||||
async def edit_recurrence(recurrence_id: UUID, payload: RecurrenceChange, scope: str = Query("all", pattern="^(this|this-and-future|all)$"), occurrence_at: datetime | None = None, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
template = await owned_recurrence(db, user.id, recurrence_id)
|
||||
task = await owned_task(db, user.id, template.task_id)
|
||||
if scope == "this":
|
||||
if not occurrence_at: raise HTTPException(422, "需要 occurrence_at")
|
||||
row = await upsert_exception(db, template.id, occurrence_at)
|
||||
if payload.title is not None: row.title = payload.title
|
||||
if payload.due_at is not None: row.due_at = payload.due_at
|
||||
elif scope == "this-and-future":
|
||||
if not occurrence_at: raise HTTPException(422, "需要 occurrence_at")
|
||||
template.ends_at = occurrence_at - timedelta(microseconds=1)
|
||||
if payload.rrule:
|
||||
parse_rrule(payload.rrule)
|
||||
db.add(RecurrenceTemplate(user_id=user.id, task_id=task.id, rrule=payload.rrule, starts_at=payload.due_at or occurrence_at))
|
||||
elif payload.title:
|
||||
task.title = payload.title
|
||||
else:
|
||||
if payload.rrule: parse_rrule(payload.rrule); template.rrule = payload.rrule.upper()
|
||||
if payload.title is not None: task.title = payload.title
|
||||
if payload.due_at is not None: task.due_at = payload.due_at; template.starts_at = payload.due_at
|
||||
await db.commit()
|
||||
return {"id": template.id, "scope": scope}
|
||||
|
||||
|
||||
@router.post("/recurrences/{recurrence_id}/complete")
|
||||
async def complete_occurrence(recurrence_id: UUID, payload: OccurrenceComplete, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
template = await owned_recurrence(db, user.id, recurrence_id)
|
||||
row = await upsert_exception(db, template.id, payload.occurrence_at); row.completed = True
|
||||
audit(db, user.id, "complete", "task", template.task_id, occurrence_at=payload.occurrence_at.isoformat())
|
||||
await db.commit(); return {"completed": True}
|
||||
|
||||
|
||||
@router.delete("/recurrences/{recurrence_id}", status_code=204)
|
||||
async def delete_recurrence(recurrence_id: UUID, scope: str = Query("all", pattern="^(this|this-and-future|all)$"), occurrence_at: datetime | None = None, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
template = await owned_recurrence(db, user.id, recurrence_id)
|
||||
if scope == "this":
|
||||
if not occurrence_at: raise HTTPException(422, "需要 occurrence_at")
|
||||
row = await upsert_exception(db, template.id, occurrence_at); row.deleted = True
|
||||
elif scope == "this-and-future":
|
||||
if not occurrence_at: raise HTTPException(422, "需要 occurrence_at")
|
||||
template.ends_at = occurrence_at - timedelta(microseconds=1)
|
||||
else: await db.delete(template)
|
||||
await db.commit(); return Response(status_code=204)
|
||||
|
||||
|
||||
class HabitCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=200)
|
||||
kind: str = Field("boolean", pattern="^(boolean|numeric)$")
|
||||
target: float = Field(1, gt=0)
|
||||
max_value: float | None = Field(None, gt=0)
|
||||
schedule_type: str = Field("daily", pattern="^(daily|weekly|monthly|interval)$")
|
||||
weekdays: list[int] | None = None
|
||||
month_days: list[int] | None = None
|
||||
interval_days: int | None = Field(None, ge=1)
|
||||
start_date: date = Field(default_factory=date.today)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def schedule_valid(self):
|
||||
if self.schedule_type == "interval" and not self.interval_days: raise ValueError("interval_days required")
|
||||
if self.kind == "boolean": self.target = 1; self.max_value = 1
|
||||
return self
|
||||
|
||||
|
||||
class HabitLogInput(BaseModel):
|
||||
day: date
|
||||
value: float = Field(gt=0)
|
||||
|
||||
|
||||
class HabitLogEdit(BaseModel): value: float = Field(ge=0)
|
||||
class PauseInput(BaseModel):
|
||||
start_date: date
|
||||
end_date: date
|
||||
@model_validator(mode="after")
|
||||
def ordered(self):
|
||||
if self.end_date < self.start_date: raise ValueError("invalid range")
|
||||
return self
|
||||
|
||||
|
||||
def habit_dict(h):
|
||||
return {"id": h.id, "name": h.name, "kind": h.kind, "target": h.target, "max_value": h.max_value, "schedule_type": h.schedule_type, "weekdays": [int(x) for x in h.weekdays.split(",")] if h.weekdays else None, "month_days": [int(x) for x in h.month_days.split(",")] if h.month_days else None, "interval_days": h.interval_days, "start_date": h.start_date, "archived_at": h.archived_at}
|
||||
|
||||
|
||||
async def owned_habit(db, user_id, habit_id):
|
||||
row = await db.scalar(select(Habit).where(Habit.id == habit_id, Habit.user_id == user_id))
|
||||
if not row: raise HTTPException(404, "习惯不存在")
|
||||
return row
|
||||
|
||||
|
||||
@router.post("/habits", status_code=201)
|
||||
async def create_habit(payload: HabitCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
row = Habit(user_id=user.id, **payload.model_dump(exclude={"weekdays", "month_days"}), weekdays=",".join(map(str, payload.weekdays)) if payload.weekdays else None, month_days=",".join(map(str, payload.month_days)) if payload.month_days else None)
|
||||
db.add(row); await db.commit(); await db.refresh(row); return habit_dict(row)
|
||||
|
||||
|
||||
@router.get("/habits")
|
||||
async def list_habits(archived: bool = False, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
condition = Habit.archived_at.is_not(None) if archived else Habit.archived_at.is_(None)
|
||||
return [habit_dict(h) for h in (await db.scalars(select(Habit).where(Habit.user_id == user.id, condition).order_by(Habit.created_at))).all()]
|
||||
|
||||
|
||||
@router.patch("/habits/{habit_id}")
|
||||
async def edit_habit(habit_id: UUID, payload: HabitCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
row = await owned_habit(db, user.id, habit_id)
|
||||
for key, value in payload.model_dump(exclude={"weekdays", "month_days"}).items(): setattr(row, key, value)
|
||||
row.weekdays = ",".join(map(str, payload.weekdays)) if payload.weekdays else None; row.month_days = ",".join(map(str, payload.month_days)) if payload.month_days else None
|
||||
await db.commit(); return habit_dict(row)
|
||||
|
||||
|
||||
@router.delete("/habits/{habit_id}", status_code=204)
|
||||
async def archive_habit(habit_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
row = await owned_habit(db, user.id, habit_id); row.archived_at = utcnow(); await db.commit(); return Response(status_code=204)
|
||||
|
||||
|
||||
@router.post("/habits/{habit_id}/logs")
|
||||
async def add_habit_log(habit_id: UUID, payload: HabitLogInput, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
habit = await owned_habit(db, user.id, habit_id)
|
||||
row = await db.scalar(select(HabitLog).where(HabitLog.habit_id == habit.id, HabitLog.day == payload.day))
|
||||
value = min((row.value if row else 0) + payload.value, habit.max_value or float("inf"))
|
||||
if habit.kind == "boolean": value = 1
|
||||
if row: row.value = value; row.updated_at = utcnow()
|
||||
else: row = HabitLog(habit_id=habit.id, day=payload.day, value=value); db.add(row)
|
||||
await db.commit(); return {"day": row.day, "value": row.value}
|
||||
|
||||
|
||||
@router.put("/habits/{habit_id}/logs/{day}")
|
||||
async def edit_habit_log(habit_id: UUID, day: date, payload: HabitLogEdit, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
habit = await owned_habit(db, user.id, habit_id)
|
||||
value = min(payload.value, habit.max_value or float("inf")); value = float(bool(value)) if habit.kind == "boolean" else value
|
||||
row = await db.scalar(select(HabitLog).where(HabitLog.habit_id == habit.id, HabitLog.day == day))
|
||||
if row: row.value = value; row.updated_at = utcnow()
|
||||
else: row = HabitLog(habit_id=habit.id, day=day, value=value); db.add(row)
|
||||
await db.commit(); return {"day": row.day, "value": row.value}
|
||||
|
||||
|
||||
@router.get("/habits/{habit_id}/logs")
|
||||
async def habit_logs(habit_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
habit = await owned_habit(db, user.id, habit_id)
|
||||
return [{"day": x.day, "value": x.value} for x in (await db.scalars(select(HabitLog).where(HabitLog.habit_id == habit.id).order_by(HabitLog.day.desc()))).all()]
|
||||
|
||||
|
||||
@router.post("/habits/{habit_id}/pauses", status_code=201)
|
||||
async def pause_habit(habit_id: UUID, payload: PauseInput, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
habit = await owned_habit(db, user.id, habit_id); row = HabitPause(habit_id=habit.id, **payload.model_dump()); db.add(row); await db.commit(); await db.refresh(row); return {"id": row.id, **payload.model_dump()}
|
||||
|
||||
|
||||
def scheduled(h, day):
|
||||
if day < h.start_date: return False
|
||||
if h.schedule_type == "daily": return True
|
||||
if h.schedule_type == "weekly": return day.weekday() in {int(x) for x in (h.weekdays or "").split(",") if x}
|
||||
if h.schedule_type == "monthly": return day.day in {int(x) for x in (h.month_days or "").split(",") if x}
|
||||
return (day - h.start_date).days % h.interval_days == 0
|
||||
|
||||
|
||||
@router.get("/habits/grid")
|
||||
async def habits_grid(week: date, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
start = week - timedelta(days=week.weekday()); days = [start + timedelta(days=i) for i in range(7)]
|
||||
habits = list((await db.scalars(select(Habit).where(Habit.user_id == user.id, Habit.archived_at.is_(None)))).all())
|
||||
output = []
|
||||
for h in habits:
|
||||
logs = {x.day: x.value for x in (await db.scalars(select(HabitLog).where(HabitLog.habit_id == h.id, HabitLog.day.between(days[0], days[-1])))).all()}
|
||||
pauses = list((await db.scalars(select(HabitPause).where(HabitPause.habit_id == h.id))).all())
|
||||
output.append({"id": h.id, "name": h.name, "cells": [{"day": d, "scheduled": scheduled(h, d), "paused": any(p.start_date <= d <= p.end_date for p in pauses), "value": logs.get(d, 0)} for d in days]})
|
||||
return {"days": days, "habits": output}
|
||||
|
||||
|
||||
@router.get("/habits/{habit_id}/stats")
|
||||
async def habit_stats(habit_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
habit = await owned_habit(db, user.id, habit_id); logs = list((await db.scalars(select(HabitLog).where(HabitLog.habit_id == habit.id).order_by(HabitLog.day))).all())
|
||||
return {"total": sum(x.value for x in logs), "completed_days": sum(x.value >= habit.target for x in logs), "logged_days": len(logs)}
|
||||
|
||||
|
||||
_ALLOWED_MIME = {"text/plain", "text/csv", "application/pdf", "image/jpeg", "image/png", "image/gif", "application/json", "application/zip"}
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/attachments", status_code=201)
|
||||
async def upload_attachment(task_id: UUID, file: UploadFile = File(...), user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
await owned_task(db, user.id, task_id)
|
||||
name = Path(file.filename or "").name
|
||||
if not name or name != file.filename or file.content_type not in _ALLOWED_MIME: raise HTTPException(400, "文件名或类型不允许")
|
||||
limit = get_settings().attachment_max_mb * 1024 * 1024; content = await file.read(limit + 1)
|
||||
if len(content) > limit: raise HTTPException(413, "文件过大")
|
||||
root = Path(get_settings().attachment_dir).resolve(); root.mkdir(parents=True, exist_ok=True); storage = str(new_id())
|
||||
(root / storage).write_bytes(content)
|
||||
row = Attachment(user_id=user.id, task_id=task_id, filename=name, storage_name=storage, mime_type=file.content_type, size=len(content)); db.add(row); await db.commit(); await db.refresh(row)
|
||||
return {"id": row.id, "task_id": row.task_id, "filename": row.filename, "mime_type": row.mime_type, "size": row.size}
|
||||
|
||||
|
||||
async def owned_attachment(db, user_id, attachment_id):
|
||||
row = await db.scalar(select(Attachment).where(Attachment.id == attachment_id, Attachment.user_id == user_id))
|
||||
if not row: raise HTTPException(404, "附件不存在")
|
||||
return row
|
||||
|
||||
|
||||
@router.get("/attachments/{attachment_id}")
|
||||
async def download_attachment(attachment_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
row = await owned_attachment(db, user.id, attachment_id); path = Path(get_settings().attachment_dir).resolve() / row.storage_name
|
||||
if not path.is_file(): raise HTTPException(404, "附件文件不存在")
|
||||
return FileResponse(path, media_type=row.mime_type, filename=row.filename)
|
||||
|
||||
|
||||
@router.delete("/attachments/{attachment_id}", status_code=204)
|
||||
async def delete_attachment(attachment_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
row = await owned_attachment(db, user.id, attachment_id); (Path(get_settings().attachment_dir).resolve() / row.storage_name).unlink(missing_ok=True); await db.delete(row); await db.commit(); return Response(status_code=204)
|
||||
|
||||
|
||||
def read_ticktick(content: bytes):
|
||||
try: text = content.decode("utf-8-sig")
|
||||
except UnicodeDecodeError as exc: raise HTTPException(422, "CSV 必须为 UTF-8") from exc
|
||||
reader = csv.DictReader(io.StringIO(text)); required = {"Title", "ID"}
|
||||
if not reader.fieldnames or not required <= set(reader.fieldnames): raise HTTPException(422, "CSV 缺少 Title 或 ID")
|
||||
rows = []; errors = []
|
||||
for index, row in enumerate(reader, 2):
|
||||
if not row.get("Title", "").strip() or not row.get("ID", "").strip(): errors.append({"row": index, "error": "Title/ID required"})
|
||||
else: rows.append(row)
|
||||
return rows, errors
|
||||
|
||||
|
||||
@router.post("/import/ticktick/preview")
|
||||
async def preview_ticktick(file: UploadFile = File(...), user: User = Depends(current_user)):
|
||||
rows, errors = read_ticktick(await file.read()); return {"valid": len(rows), "invalid": len(errors), "errors": errors, "sample": rows[:10]}
|
||||
|
||||
|
||||
@router.post("/import/ticktick")
|
||||
async def import_ticktick(file: UploadFile = File(...), user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
rows, errors = read_ticktick(await file.read())
|
||||
if errors: raise HTTPException(422, errors)
|
||||
inbox = await db.scalar(select(TaskList).where(TaskList.user_id == user.id, TaskList.is_inbox.is_(True)))
|
||||
imported = skipped = 0
|
||||
for raw in rows:
|
||||
external_id = raw["ID"].strip()
|
||||
if await db.scalar(select(Task.id).where(Task.user_id == user.id, Task.external_id == external_id)): skipped += 1; continue
|
||||
due = None
|
||||
if raw.get("Due Date"):
|
||||
try: due = datetime.combine(date.fromisoformat(raw["Due Date"][:10]), time.min, tzinfo=UTC)
|
||||
except ValueError: raise HTTPException(422, f"无效日期: {raw['Due Date']}")
|
||||
task = Task(user_id=user.id, list_id=inbox.id, title=raw["Title"].strip(), completed=raw.get("Status", "0").lower() in {"1", "completed", "true"}, due_at=due, external_id=external_id); db.add(task); imported += 1
|
||||
audit(db, user.id, "import", "task", count=imported); await db.commit(); return {"imported": imported, "skipped": skipped}
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
async def export_json(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
def serialize(row, fields):
|
||||
return {f: (str(v) if isinstance((v := getattr(row, f)), UUID) else v.isoformat() if isinstance(v, (date, datetime)) else v) for f in fields}
|
||||
folders = list((await db.scalars(select(Folder).where(Folder.user_id == user.id))).all()); lists = list((await db.scalars(select(TaskList).where(TaskList.user_id == user.id))).all()); tags = list((await db.scalars(select(Tag).where(Tag.user_id == user.id))).all()); tasks = list((await db.scalars(select(Task).where(Task.user_id == user.id))).all()); habits = list((await db.scalars(select(Habit).where(Habit.user_id == user.id))).all())
|
||||
return {"version": 1, "exported_at": utcnow(), "folders": [serialize(x,["id","name","position","deleted_at"]) for x in folders], "lists": [serialize(x,["id","folder_id","name","is_inbox","position","deleted_at"]) for x in lists], "tags": [serialize(x,["id","name","color"]) for x in tags], "tasks": [serialize(x,["id","list_id","parent_id","title","description","priority","completed","due_at","external_id","deleted_at"]) for x in tasks], "habits": [serialize(x,["id","name","kind","target","max_value","schedule_type","weekdays","month_days","interval_days","start_date","archived_at"]) for x in habits]}
|
||||
|
||||
|
||||
@router.post("/restore")
|
||||
async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merge|replace)$"), user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
if payload.get("version") != 1: raise HTTPException(422, "不支持的备份版本")
|
||||
if mode == "replace":
|
||||
await db.execute(delete(Task).where(Task.user_id == user.id)); await db.execute(delete(TaskList).where(TaskList.user_id == user.id)); await db.execute(delete(Folder).where(Folder.user_id == user.id))
|
||||
id_map = {}
|
||||
for raw in payload.get("folders", []):
|
||||
old = raw["id"]; row = Folder(user_id=user.id, name=raw["name"], position=raw.get("position",0)); db.add(row); await db.flush(); id_map[old] = row.id
|
||||
inbox = None
|
||||
for raw in payload.get("lists", []):
|
||||
row = TaskList(user_id=user.id, folder_id=id_map.get(raw.get("folder_id")), name=raw["name"], is_inbox=raw.get("is_inbox",False), position=raw.get("position",0)); db.add(row); await db.flush(); id_map[raw["id"]] = row.id
|
||||
if row.is_inbox: inbox = row
|
||||
if not inbox: inbox = TaskList(user_id=user.id, name="收集箱", is_inbox=True); db.add(inbox); await db.flush()
|
||||
restored = 0
|
||||
for raw in payload.get("tasks", []):
|
||||
ext = raw.get("external_id")
|
||||
existing = await db.scalar(select(Task).where(Task.user_id == user.id, Task.external_id == ext)) if ext else None
|
||||
if existing and mode == "merge": continue
|
||||
row = Task(user_id=user.id, list_id=id_map.get(raw.get("list_id"), inbox.id), title=raw["title"], description=raw.get("description", ""), priority=raw.get("priority",0), completed=raw.get("completed",False), due_at=datetime.fromisoformat(raw["due_at"]) if raw.get("due_at") else None, external_id=ext); db.add(row); restored += 1
|
||||
audit(db, user.id, "restore", "backup", count=restored, mode=mode); await db.commit(); return {"restored": restored, "mode": mode}
|
||||
|
||||
|
||||
@router.get("/audit-logs")
|
||||
async def audit_logs(limit: int = Query(100, ge=1, le=500), user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
rows = (await db.scalars(select(AuditLog).where(AuditLog.user_id == user.id).order_by(AuditLog.created_at.desc()).limit(limit))).all()
|
||||
return [{"id": x.id, "action": x.action, "entity_type": x.entity_type, "entity_id": x.entity_id, "details": x.details, "created_at": x.created_at} for x in rows]
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,3 +1,3 @@
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f15a29"><link rel="manifest" href="/manifest.json"><title>dodo</title> <script type="module" crossorigin src="/assets/index-BiihmXG7.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-hzru7WEs.css">
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f15a29"><link rel="manifest" href="/manifest.json"><title>dodo</title> <script type="module" crossorigin src="/assets/index-Dq8LoBCn.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CpBTIN38.css">
|
||||
</head><body><div id="app"></div></body></html>
|
||||
@@ -1 +1 @@
|
||||
{"name":"dodo","short_name":"dodo","start_url":"/","display":"standalone","background_color":"#f8f3e8","theme_color":"#f15a29","lang":"zh-CN"}
|
||||
{"name":"dodo","short_name":"dodo","description":"A handwritten-life task and habit planner.","start_url":"/","scope":"/","display":"standalone","orientation":"portrait","background_color":"#f8f3e8","theme_color":"#f15a29","lang":"zh-CN","icons":[{"src":"/icon-192.png","sizes":"192x192","type":"image/png","purpose":"any maskable"},{"src":"/icon-512.png","sizes":"512x512","type":"image/png","purpose":"any maskable"},{"src":"/apple-touch-icon.png","sizes":"180x180","type":"image/png","purpose":"any"}]}
|
||||
+21
-1
@@ -1 +1,21 @@
|
||||
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)))})
|
||||
const CACHE = 'dodo-shell-v2'
|
||||
const SHELL = ['/', '/manifest.json', '/icon-192.png', '/icon-512.png', '/apple-touch-icon.png']
|
||||
self.addEventListener('install', (event) => {
|
||||
self.skipWaiting()
|
||||
event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL)))
|
||||
})
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(caches.keys().then((keys) => Promise.all(keys.filter((key) => key !== CACHE).map((key) => caches.delete(key)))).then(() => self.clients.claim()))
|
||||
})
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url)
|
||||
if (url.pathname.startsWith('/api/')) return
|
||||
if (event.request.method !== 'GET') return
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((cached) => cached || fetch(event.request).then((response) => {
|
||||
const copy = response.clone()
|
||||
caches.open(CACHE).then((cache) => cache.put(event.request, copy))
|
||||
return response
|
||||
}).catch(() => caches.match('/'))),
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user