Files
dodo/backend/mvp.py
T
bboysoul 31c2218edc
ci / gitleaks (push) Successful in 10s
ci / docker (push) Successful in 3m38s
feat: add standalone memos
2026-09-12 10:22:35 +08:00

1584 lines
70 KiB
Python

import calendar
import csv
import io
import json
import re
from datetime import UTC, date, datetime, time, timedelta
from pathlib import Path
from uuid import UUID, uuid5
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field, StrictBool, field_validator, model_validator
from sqlalchemy import case, delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from .auth import current_user
from .config import get_settings
from .db import get_db
from .lunar_support import (
lunar_label_with_year,
lunar_to_solar_safe,
next_lunar_occurrence,
solar_to_lunar_parts,
solar_to_lunar_text,
)
from .models import (
Attachment,
AuditLog,
Countdown,
Folder,
Habit,
HabitLog,
HabitPause,
Memo,
RecurrenceException,
RecurrenceTemplate,
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 MemoCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
content: str = ""
@field_validator("title", mode="before")
@classmethod
def clean_title(cls, value: str) -> str:
return value.strip()
class MemoUpdate(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=200)
content: str | None = None
version: int = Field(ge=1)
@field_validator("title", mode="before")
@classmethod
def clean_title(cls, value: str | None) -> str | None:
if value is None:
return value
return value.strip()
@model_validator(mode="after")
def reject_nulls(self):
for field in ("title", "content"):
if field in self.model_fields_set and getattr(self, field) is None:
raise ValueError(f"{field} cannot be null")
return self
class MemoOut(BaseModel):
model_config = {"from_attributes": True}
id: UUID
title: str
content: str
version: int
created_at: datetime
updated_at: datetime
deleted_at: datetime | None
class MemoListItem(BaseModel):
id: UUID
title: str
excerpt: str
version: int
created_at: datetime
updated_at: datetime
deleted_at: datetime | None
def _memo_excerpt(content: str) -> str:
collapsed = " ".join(content.split())
return collapsed if len(collapsed) <= 120 else collapsed[:119] + "…"
@router.get("/memos")
async def list_memos(
scope: str = Query("active", pattern="^(active|trash)$"),
q: str = "",
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
user: User = Depends(current_user),
db: AsyncSession = Depends(get_db),
):
filters = [Memo.user_id == user.id]
filters.append(Memo.deleted_at.is_(None) if scope == "active" else Memo.deleted_at.is_not(None))
query = q.strip()
if query:
pattern = f"%{query}%"
filters.append((Memo.title.ilike(pattern)) | (Memo.content.ilike(pattern)))
total = await db.scalar(select(func.count()).select_from(Memo).where(*filters))
rows = (await db.scalars(
select(Memo).where(*filters).order_by(Memo.updated_at.desc(), Memo.id.desc())
.offset((page - 1) * page_size).limit(page_size)
)).all()
items = [MemoListItem(
id=row.id, title=row.title, excerpt=_memo_excerpt(row.content), version=row.version,
created_at=row.created_at, updated_at=row.updated_at, deleted_at=row.deleted_at,
) for row in rows]
return {"items": items, "total": total or 0, "page": page, "page_size": page_size}
@router.post("/memos", response_model=MemoOut, status_code=201)
async def create_memo(payload: MemoCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
row = Memo(user_id=user.id, title=payload.title, content=payload.content)
db.add(row)
await db.flush()
audit(db, user.id, "create", "memo", row.id, fields=["title"])
await db.commit()
await db.refresh(row)
return row
@router.patch("/memos/{memo_id}", response_model=MemoOut)
async def update_memo(
memo_id: UUID, payload: MemoUpdate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)
):
existing = await db.scalar(select(Memo).where(Memo.id == memo_id, Memo.user_id == user.id))
if not existing:
raise HTTPException(404, "备忘录不存在")
if existing.deleted_at is not None:
raise HTTPException(409, "已删除的备忘录不能编辑")
values = payload.model_dump(exclude={"version"}, exclude_unset=True)
now = utcnow()
result = await db.execute(
update(Memo).where(
Memo.id == memo_id, Memo.user_id == user.id, Memo.deleted_at.is_(None), Memo.version == payload.version
).values(**values, version=Memo.version + 1, updated_at=now)
)
if result.rowcount != 1:
raise HTTPException(409, "备忘录版本冲突")
audit(db, user.id, "update", "memo", memo_id, fields=sorted(values))
await db.commit()
return await db.scalar(select(Memo).where(Memo.id == memo_id, Memo.user_id == user.id))
@router.post("/memos/{memo_id}/restore", response_model=MemoOut)
async def restore_memo(memo_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
row = await db.scalar(select(Memo).where(Memo.id == memo_id, Memo.user_id == user.id))
if not row:
raise HTTPException(404, "备忘录不存在")
if row.deleted_at is not None:
row.deleted_at = None
row.updated_at = utcnow()
audit(db, user.id, "restore", "memo", row.id)
await db.commit()
await db.refresh(row)
return row
@router.delete("/memos/{memo_id}/purge", status_code=204)
async def purge_memo(memo_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
row = await db.scalar(select(Memo).where(Memo.id == memo_id, Memo.user_id == user.id))
if not row:
raise HTTPException(404, "备忘录不存在")
if row.deleted_at is None:
raise HTTPException(409, "只能永久删除回收站中的备忘录")
audit(db, user.id, "purge", "memo", row.id)
await db.delete(row)
await db.commit()
return Response(status_code=204)
@router.delete("/memos/{memo_id}", status_code=204)
async def delete_memo(memo_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
row = await db.scalar(select(Memo).where(Memo.id == memo_id, Memo.user_id == user.id))
if not row:
raise HTTPException(404, "备忘录不存在")
if row.deleted_at is None:
now = utcnow()
row.deleted_at = now
row.updated_at = now
audit(db, user.id, "delete", "memo", row.id)
await db.commit()
return Response(status_code=204)
@router.get("/memos/{memo_id}", response_model=MemoOut)
async def get_memo(memo_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
row = await db.scalar(select(Memo).where(Memo.id == memo_id, Memo.user_id == user.id))
if not row:
raise HTTPException(404, "备忘录不存在")
return row
class RecurrenceCreate(BaseModel):
task_id: UUID
rrule: str | None = Field(default=None, min_length=5, max_length=1000)
trigger_mode: str = Field(default="scheduled", pattern="^(scheduled|after_completion)$")
after_completion_days: int | None = Field(default=None, ge=1, le=3650)
@model_validator(mode="after")
def validate_mode(self):
if self.trigger_mode == "scheduled" and self.rrule is None:
raise ValueError("scheduled recurrence requires rrule")
if self.trigger_mode == "after_completion" and (
self.after_completion_days is None or self.rrule is not None
):
raise ValueError("after_completion requires days and no rrule")
return self
class RecurrenceChange(BaseModel):
title: str | None = Field(None, min_length=1, max_length=500)
due_at: datetime | None = None
rrule: str | None = None
trigger_mode: str | None = Field(default=None, pattern="^(scheduled|after_completion)$")
after_completion_days: int | None = Field(default=None, ge=1, le=3650)
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
allowed = {"FREQ", "INTERVAL", "BYDAY", "BYMONTHDAY", "BYMONTH", "COUNT", "UNTIL"}
if set(parts) - allowed:
raise HTTPException(422, "重复规则包含不支持的字段")
if parts.get("FREQ") not in {"DAILY", "WEEKLY", "MONTHLY", "YEARLY"}:
raise HTTPException(422, "仅支持 DAILY、WEEKLY、MONTHLY、YEARLY")
if "BYDAY" in parts:
weekdays = parts["BYDAY"].split(",")
if not weekdays or any(day not in _WEEKDAYS for day in weekdays):
raise HTTPException(422, "无效的重复星期")
try:
month_days = [int(day) for day in parts.get("BYMONTHDAY", "").split(",") if day]
months = [int(month) for month in parts.get("BYMONTH", "").split(",") if month]
if any(day < 1 or day > 31 for day in month_days) or any(month < 1 or month > 12 for month in months):
raise ValueError
if "UNTIL" in parts:
datetime.fromisoformat(parts["UNTIL"])
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.astimezone(UTC)
else:
until = end
if starts.tzinfo is None:
starts = starts.replace(tzinfo=UTC)
if start.tzinfo is None:
start = start.replace(tzinfo=UTC)
if end.tzinfo is None:
end = end.replace(tzinfo=UTC)
if cutoff is not None:
cutoff = cutoff.replace(tzinfo=UTC) if cutoff.tzinfo is None else cutoff.astimezone(UTC)
until = min(until, cutoff)
if until.tzinfo is None:
until = until.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
elif parts["FREQ"] == "MONTHLY":
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
else:
years = cursor.year - starts.year
months = {int(x) for x in parts.get("BYMONTH", str(starts.month)).split(",")}
month_days = {int(x) for x in parts.get("BYMONTHDAY", str(starts.day)).split(",")}
include = years % interval == 0 and cursor.month in months 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
def is_occurrence(rule: str, starts: datetime, at: datetime) -> bool:
"""Return True when `at` is the exact timestamp of an occurrence this rule generates."""
parts = parse_rrule(rule)
if starts.tzinfo is None:
starts = starts.replace(tzinfo=UTC)
else:
starts = starts.astimezone(UTC)
if at.tzinfo is None:
at = at.replace(tzinfo=UTC)
else:
at = at.astimezone(UTC)
if at < starts:
return False
if "UNTIL" in parts:
until = datetime.fromisoformat(parts["UNTIL"])
until = until.replace(tzinfo=UTC) if until.tzinfo is None else until.astimezone(UTC)
if at > until:
return False
# Exact-match validation via the same canonical generator used for recurrence
# mutations, so COUNT/UNTIL, time-of-day, BYDAY/BYMONTHDAY and sparse yearly
# rules all share one behavior.
candidates = occurrences(rule, starts, starts, at, None)
return at in candidates
def is_occurrence_utc(rule: str, starts: datetime, at: datetime) -> bool:
return is_occurrence(rule, starts, at)
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
def ensure_real_occurrence(template: RecurrenceTemplate, occurrence_at: datetime) -> None:
"""Reject occurrence_at values that are not valid occurrences of this recurrence rule."""
if not is_occurrence(template.rrule, template.starts_at, occurrence_at):
raise HTTPException(422, "occurrence_at 不是该重复规则的有效发生时刻")
if template.ends_at:
at = occurrence_at.replace(tzinfo=UTC) if occurrence_at.tzinfo is None else occurrence_at.astimezone(UTC)
ends = template.ends_at.replace(tzinfo=UTC) if template.ends_at.tzinfo is None else template.ends_at.astimezone(UTC)
if at > ends:
raise HTTPException(422, "occurrence_at 晚于该重复规则的有效截止时间")
@router.get("/tasks/{task_id}/recurrence")
async def get_task_recurrence(task_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
await owned_task(db, user.id, task_id)
row = await db.scalar(select(RecurrenceTemplate).where(
RecurrenceTemplate.task_id == task_id, RecurrenceTemplate.user_id == user.id
))
if row is None:
return None
return {
"id": row.id,
"task_id": row.task_id,
"rrule": row.rrule,
"starts_at": row.starts_at,
"ends_at": row.ends_at,
"trigger_mode": row.trigger_mode,
"after_completion_days": row.after_completion_days,
"last_completed_at": row.last_completed_at,
}
@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 task.parent_id is not None:
raise HTTPException(422, "仅顶层任务可设置重复")
if not task.due_at:
raise HTTPException(422, "重复任务需要截止时间")
if payload.rrule:
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() if payload.rrule else None,
starts_at=task.due_at,
trigger_mode=payload.trigger_mode,
after_completion_days=payload.after_completion_days,
)
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,
"ends_at": row.ends_at,
"trigger_mode": row.trigger_mode,
"after_completion_days": row.after_completion_days,
"last_completed_at": row.last_completed_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)
requested_mode = payload.trigger_mode or template.trigger_mode
if requested_mode == "after_completion" and scope != "all":
raise HTTPException(422, "完成后重复仅支持修改全部规则")
if scope == "this":
if not occurrence_at: raise HTTPException(422, "需要 occurrence_at")
ensure_real_occurrence(template, 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")
ensure_real_occurrence(template, 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:
requested_days = (
payload.after_completion_days
if "after_completion_days" in payload.model_fields_set
else template.after_completion_days
)
requested_rrule = payload.rrule if payload.rrule is not None else template.rrule
if requested_mode == "after_completion":
if requested_days is None:
raise HTTPException(422, "完成后重复需要间隔天数")
template.trigger_mode = requested_mode
template.after_completion_days = requested_days
template.rrule = None
else:
if requested_rrule is None:
raise HTTPException(422, "定期重复需要 RRULE")
parse_rrule(requested_rrule)
template.trigger_mode = requested_mode
template.after_completion_days = None
template.rrule = requested_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()
await db.refresh(template)
return {
"id": template.id,
"task_id": template.task_id,
"rrule": template.rrule,
"starts_at": template.starts_at,
"ends_at": template.ends_at,
"trigger_mode": template.trigger_mode,
"after_completion_days": template.after_completion_days,
"last_completed_at": template.last_completed_at,
}
@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)
if template.trigger_mode != "scheduled":
raise HTTPException(422, "完成后重复不支持按发生时刻完成")
ensure_real_occurrence(template, payload.occurrence_at)
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 template.trigger_mode == "after_completion" and scope != "all":
raise HTTPException(422, "完成后重复仅支持取消全部规则")
if scope == "this":
if not occurrence_at: raise HTTPException(422, "需要 occurrence_at")
ensure_real_occurrence(template, 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")
ensure_real_occurrence(template, occurrence_at)
template.ends_at = occurrence_at - timedelta(microseconds=1)
else: await db.delete(template)
await db.commit(); return Response(status_code=204)
def _clamped_date(year: int, month: int, day: int) -> date:
return date(year, month, min(day, calendar.monthrange(year, month)[1]))
def countdown_occurrence(event_date: date, repeat_rule: str, today: date) -> date:
"""Return the first date-only occurrence on or after today."""
if repeat_rule == "none" or event_date >= today:
return event_date
if repeat_rule == "weekly":
days = (event_date.weekday() - today.weekday()) % 7
return today + timedelta(days=days)
if repeat_rule == "monthly":
candidate = _clamped_date(today.year, today.month, event_date.day)
if candidate < today:
year = today.year + (today.month == 12)
month = 1 if today.month == 12 else today.month + 1
candidate = _clamped_date(year, month, event_date.day)
return candidate
candidate = _clamped_date(today.year, event_date.month, event_date.day)
if candidate < today:
candidate = _clamped_date(today.year + 1, event_date.month, event_date.day)
return candidate
def countdown_status(display_date: date, today: date) -> tuple[int, str]:
days = (display_date - today).days
if days > 0:
return days, f"还有 {days} 天"
if days == 0:
return 0, "就是今天"
return days, f"已经 {-days} 天"
class CountdownInput(BaseModel):
title: str = Field(min_length=1, max_length=200)
event_date: date
calendar_mode: str = Field("solar", pattern="^(solar|lunar)$")
lunar_month: int | None = None
lunar_day: int | None = None
ignore_year: StrictBool = False
kind: str = Field("countdown", pattern="^(countdown|anniversary|birthday)$")
repeat_rule: str = Field("none", pattern="^(none|weekly|monthly|yearly)$")
icon: str = Field("📅", min_length=1, max_length=32)
pinned: StrictBool = False
@field_validator("title", "icon")
@classmethod
def non_blank_text(cls, value: str):
value = value.strip()
if not value:
raise ValueError("text cannot be blank")
return value
@model_validator(mode="after")
def calendar_fields_valid(self):
if self.calendar_mode == "solar":
if self.lunar_month is not None or self.lunar_day is not None:
raise ValueError("solar countdown cannot include lunar fields")
return self
if self.lunar_month is None or self.lunar_day is None:
raise ValueError("lunar_month and lunar_day are required")
if self.lunar_month == 0 or not -12 <= self.lunar_month <= 12:
raise ValueError("lunar_month must be 1..12 or -1..-12 for leap months")
if not 1 <= self.lunar_day <= 30:
raise ValueError("lunar_day must be 1..30")
converted = lunar_to_solar_safe(self.event_date.year, self.lunar_month, self.lunar_day)
if converted is None:
raise ValueError("lunar date does not exist in the selected year")
self.event_date = converted
return self
class CountdownUpdate(BaseModel):
title: str | None = Field(None, min_length=1, max_length=200)
event_date: date | None = None
calendar_mode: str | None = Field(None, pattern="^(solar|lunar)$")
lunar_month: int | None = None
lunar_day: int | None = None
ignore_year: StrictBool | None = None
kind: str | None = Field(None, pattern="^(countdown|anniversary|birthday)$")
repeat_rule: str | None = Field(None, pattern="^(none|weekly|monthly|yearly)$")
icon: str | None = Field(None, min_length=1, max_length=32)
expected_updated_at: datetime | None = None
@field_validator("title", "icon")
@classmethod
def non_blank_text(cls, value: str | None):
if value is None:
return value
value = value.strip()
if not value:
raise ValueError("text cannot be blank")
return value
@model_validator(mode="after")
def reject_explicit_nulls(self):
for field in self.model_fields_set:
if getattr(self, field) is None:
raise ValueError(f"{field} cannot be null")
if self.lunar_month is not None and (self.lunar_month == 0 or not -12 <= self.lunar_month <= 12):
raise ValueError("lunar_month must be 1..12 or -1..-12 for leap months")
if self.lunar_day is not None and not 1 <= self.lunar_day <= 30:
raise ValueError("lunar_day must be 1..30")
return self
def countdown_dict(row: Countdown, today: date | None = None):
today = today or datetime.now(ZoneInfo("Asia/Shanghai")).date()
if row.calendar_mode == "lunar" and (row.ignore_year or row.repeat_rule == "yearly"):
search_from = max(today, row.event_date) if not row.ignore_year else today
display_date = next_lunar_occurrence(
row.lunar_month, row.lunar_day, True, "yearly", search_from
) or row.event_date
effective_repeat = "yearly"
else:
repeat_rule = "yearly" if row.ignore_year else row.repeat_rule
display_date = countdown_occurrence(row.event_date, repeat_rule, today)
effective_repeat = repeat_rule
days, day_text = countdown_status(display_date, today)
lunar_year = None
lunar_text = None
if row.calendar_mode == "lunar":
lunar_year, _, _ = solar_to_lunar_parts(row.event_date)
lunar_text = (
solar_to_lunar_text(display_date)
if row.ignore_year or row.repeat_rule == "yearly"
else lunar_label_with_year(row.event_date)
)
return {
"id": row.id,
"title": row.title,
"event_date": row.event_date,
"display_date": display_date,
"calendar_mode": row.calendar_mode,
"lunar_year": lunar_year,
"lunar_month": row.lunar_month,
"lunar_day": row.lunar_day,
"ignore_year": row.ignore_year,
"repeat_rule": effective_repeat,
"lunar_text": lunar_text,
"kind": row.kind,
"icon": row.icon,
"pinned": row.pinned,
"archived_at": row.archived_at,
"days": days,
"day_text": day_text,
"created_at": row.created_at,
"updated_at": row.updated_at,
}
async def owned_countdown(db, user_id, countdown_id):
row = await db.scalar(select(Countdown).where(Countdown.id == countdown_id, Countdown.user_id == user_id))
if not row:
raise HTTPException(404, "倒数日不存在")
return row
@router.post("/countdowns", status_code=201)
async def create_countdown(payload: CountdownInput, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
if payload.pinned:
await db.execute(update(Countdown).where(Countdown.user_id == user.id).values(pinned=False))
row = Countdown(user_id=user.id, **payload.model_dump())
db.add(row)
await db.flush()
audit(db, user.id, "create", "countdown", row.id)
await db.commit()
await db.refresh(row)
return countdown_dict(row)
@router.get("/countdowns")
async def list_countdowns(archived: bool = False, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
condition = Countdown.archived_at.is_not(None) if archived else Countdown.archived_at.is_(None)
rows = (await db.scalars(
select(Countdown)
.where(Countdown.user_id == user.id, condition)
.order_by(Countdown.pinned.desc(), Countdown.event_date, Countdown.created_at)
)).all()
result = [countdown_dict(row) for row in rows]
return sorted(result, key=lambda item: (not item["pinned"], item["display_date"], item["created_at"]))
@router.patch("/countdowns/{countdown_id}")
async def edit_countdown(countdown_id: UUID, payload: CountdownUpdate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
row = await db.scalar(
select(Countdown)
.where(Countdown.id == countdown_id, Countdown.user_id == user.id)
.with_for_update()
)
if not row:
raise HTTPException(404, "倒数日不存在")
if row.archived_at is not None:
raise HTTPException(409, "请先恢复倒数日再编辑")
values = payload.model_dump(exclude_unset=True)
expected_updated_at = values.pop("expected_updated_at", None)
if expected_updated_at is not None:
actual_updated_at = row.updated_at
if actual_updated_at.tzinfo is None and expected_updated_at.tzinfo is not None:
actual_updated_at = actual_updated_at.replace(tzinfo=UTC)
if expected_updated_at.tzinfo is None and actual_updated_at.tzinfo is not None:
expected_updated_at = expected_updated_at.replace(tzinfo=UTC)
if actual_updated_at != expected_updated_at:
raise HTTPException(409, "倒数日已被其他操作更新,请刷新后重试")
combined = {
"event_date": values.get("event_date", row.event_date),
"calendar_mode": values.get("calendar_mode", row.calendar_mode),
"lunar_month": values.get("lunar_month", row.lunar_month),
"lunar_day": values.get("lunar_day", row.lunar_day),
"ignore_year": values.get("ignore_year", row.ignore_year),
}
calendar_fields_changed = bool(
{"event_date", "calendar_mode", "lunar_month", "lunar_day"} & values.keys()
)
if combined["calendar_mode"] == "solar":
if combined["lunar_month"] is not None or combined["lunar_day"] is not None:
if "calendar_mode" not in values:
raise HTTPException(422, "公历倒数日不能设置农历日期")
combined["lunar_month"] = combined["lunar_day"] = None
else:
if combined["lunar_month"] is None or combined["lunar_day"] is None:
raise HTTPException(422, "农历倒数日需要月份和日期")
if calendar_fields_changed:
if "event_date" in values:
lunar_year = combined["event_date"].year
else:
lunar_year, _, _ = solar_to_lunar_parts(row.event_date)
converted = lunar_to_solar_safe(
lunar_year, combined["lunar_month"], combined["lunar_day"]
)
if converted is None:
raise HTTPException(422, "所选年份不存在该农历日期")
combined["event_date"] = converted
values.update(combined)
for key, value in values.items():
setattr(row, key, value)
row.updated_at = utcnow()
audit(db, user.id, "update", "countdown", row.id)
await db.commit()
await db.refresh(row)
return countdown_dict(row)
@router.post("/countdowns/{countdown_id}/pin")
async def pin_countdown(countdown_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
row = await owned_countdown(db, user.id, countdown_id)
if row.archived_at is not None:
raise HTTPException(409, "已归档倒数日不能置顶")
await db.execute(update(Countdown).where(Countdown.user_id == user.id, Countdown.id != row.id).values(pinned=False))
row.pinned = True
row.updated_at = utcnow()
await db.commit()
await db.refresh(row)
return countdown_dict(row)
@router.delete("/countdowns/{countdown_id}", status_code=204)
async def archive_countdown(countdown_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
row = await owned_countdown(db, user.id, countdown_id)
if row.archived_at is None:
row.archived_at = utcnow()
row.pinned = False
audit(db, user.id, "archive", "countdown", row.id)
await db.commit()
return Response(status_code=204)
@router.post("/countdowns/{countdown_id}/restore")
async def restore_countdown(countdown_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
row = await owned_countdown(db, user.id, countdown_id)
if row.archived_at is None:
raise HTTPException(409, "倒数日未归档")
row.archived_at = None
row.updated_at = utcnow()
audit(db, user.id, "restore", "countdown", row.id)
await db.commit()
await db.refresh(row)
return countdown_dict(row)
@router.delete("/countdowns/{countdown_id}/purge", status_code=204)
async def purge_countdown(countdown_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
row = await owned_countdown(db, user.id, countdown_id)
if row.archived_at is None:
raise HTTPException(409, "请先归档再永久删除")
await db.delete(row)
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, allow_inf_nan=False)
max_value: float | None = Field(None, gt=0, allow_inf_nan=False)
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)
@field_validator("name")
@classmethod
def clean_name(cls, value):
value = value.strip()
if not value:
raise ValueError("name cannot be blank")
return value
@model_validator(mode="after")
def values_valid(self):
if self.schedule_type == "weekly":
if not self.weekdays or len(self.weekdays) != len(set(self.weekdays)) or any(day < 0 or day > 6 for day in self.weekdays):
raise ValueError("weekdays must be non-empty, unique, and between 0 and 6")
elif self.schedule_type == "monthly":
if not self.month_days or len(self.month_days) != len(set(self.month_days)) or any(day < 1 or day > 31 for day in self.month_days):
raise ValueError("month_days must be non-empty, unique, and between 1 and 31")
elif self.schedule_type == "interval" and self.interval_days is None:
raise ValueError("interval_days required")
if self.schedule_type != "weekly":
self.weekdays = None
if self.schedule_type != "monthly":
self.month_days = None
if self.schedule_type != "interval":
self.interval_days = None
if self.kind == "boolean":
self.target = 1
self.max_value = 1
elif self.max_value is not None and self.max_value < self.target:
raise ValueError("max_value must be greater than or equal to target")
return self
class HabitUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=200)
kind: str | None = Field(None, pattern="^(boolean|numeric)$")
target: float | None = Field(None, gt=0, allow_inf_nan=False)
max_value: float | None = Field(None, gt=0, allow_inf_nan=False)
schedule_type: str | None = Field(None, 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 | None = None
@field_validator("name")
@classmethod
def clean_name(cls, value):
if value is None:
return value
value = value.strip()
if not value:
raise ValueError("name cannot be blank")
return value
@model_validator(mode="after")
def reject_nulls(self):
for field in self.model_fields_set:
if getattr(self, field) is None:
raise ValueError(f"{field} cannot be null")
return self
class HabitReorder(BaseModel):
habit_ids: list[UUID] = Field(min_length=1)
@model_validator(mode="after")
def unique_ids(self):
if len(self.habit_ids) != len(set(self.habit_ids)):
raise ValueError("habit_ids must be unique")
return self
class HabitLogInput(BaseModel):
day: date
value: float = Field(gt=0, allow_inf_nan=False)
class HabitLogEdit(BaseModel): value: float = Field(ge=0, allow_inf_nan=False)
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, "position": h.position}
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
def require_active_habit(habit):
if habit.archived_at is not None:
raise HTTPException(409, "归档习惯不可修改")
async def require_positive_log_day(db, habit, day):
if not scheduled(habit, day):
raise HTTPException(409, "非计划日不可记录正向进度")
paused = await db.scalar(select(HabitPause.id).where(
HabitPause.habit_id == habit.id,
HabitPause.start_date <= day,
HabitPause.end_date >= day,
))
if paused is not None:
raise HTTPException(409, "暂停日不可记录正向进度")
async def lock_habit_order(db: AsyncSession, user_id: UUID) -> None:
"""Serialize active-habit order changes for one user.
PostgreSQL supports a row-level user lock. SQLite ignores ``FOR UPDATE``, so
a no-op write acquires its transaction-wide write lock before order reads.
"""
connection = await db.connection()
if connection.dialect.name == "sqlite":
await db.execute(
update(User).where(User.id == user_id).values(id=User.id)
)
return
await db.scalar(select(User.id).where(User.id == user_id).with_for_update())
@router.post("/habits", status_code=201)
async def create_habit(payload: HabitCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
await lock_habit_order(db, user.id)
max_position = await db.scalar(select(func.max(Habit.position)).where(Habit.user_id == user.id))
row = Habit(user_id=user.id, position=(max_position if max_position is not None else -1) + 1, **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)
order = (
(Habit.archived_at.desc(), Habit.created_at.desc(), Habit.id.asc())
if archived
else (Habit.position, Habit.created_at)
)
return [
habit_dict(h)
for h in (
await db.scalars(
select(Habit).where(Habit.user_id == user.id, condition).order_by(*order)
)
).all()
]
@router.put("/habits/reorder", status_code=204)
async def reorder_habits(payload: HabitReorder, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
await lock_habit_order(db, user.id)
rows = list((await db.scalars(select(Habit).where(
Habit.id.in_(payload.habit_ids), Habit.user_id == user.id, Habit.archived_at.is_(None)
))).all())
if len(rows) != len(payload.habit_ids):
raise HTTPException(404, "习惯不存在")
scope_rows = list((await db.scalars(select(Habit).where(
Habit.user_id == user.id, Habit.archived_at.is_(None)
).order_by(Habit.position, Habit.created_at))).all())
requested = set(payload.habit_ids)
ordered_rows = iter([next(row for row in rows if row.id == habit_id) for habit_id in payload.habit_ids])
merged = [next(ordered_rows) if row.id in requested else row for row in scope_rows]
for position, row in enumerate(merged):
row.position = position
await db.commit()
return Response(status_code=204)
@router.patch("/habits/{habit_id}")
async def edit_habit(habit_id: UUID, payload: HabitUpdate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
row = await owned_habit(db, user.id, habit_id)
require_active_habit(row)
current = habit_dict(row)
merged = {key: current[key] for key in HabitCreate.model_fields}
merged.update(payload.model_dump(exclude_unset=True))
try:
validated = HabitCreate.model_validate(merged)
except ValueError as exc:
raise HTTPException(422, str(exc)) from exc
values = validated.model_dump(exclude={"weekdays", "month_days"})
for key, value in values.items():
setattr(row, key, value)
row.weekdays = ",".join(map(str, validated.weekdays)) if validated.weekdays else None
row.month_days = ",".join(map(str, validated.month_days)) if validated.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}/restore")
async def restore_habit(habit_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
await lock_habit_order(db, user.id)
row = await owned_habit(db, user.id, habit_id)
if row.archived_at is None:
raise HTTPException(409, "习惯未归档")
max_position = await db.scalar(select(func.max(Habit.position)).where(
Habit.user_id == user.id, Habit.archived_at.is_(None)
))
position = (max_position if max_position is not None else -1) + 1
result = await db.execute(
update(Habit)
.where(
Habit.id == habit_id,
Habit.user_id == user.id,
Habit.archived_at.is_not(None),
)
.values(archived_at=None, position=position)
)
if result.rowcount != 1:
await db.rollback()
raise HTTPException(409, "习惯未归档")
audit(db, user.id, "restore", "habit", row.id)
await db.commit()
await db.refresh(row)
return habit_dict(row)
@router.delete("/habits/{habit_id}/permanent", status_code=204)
async def delete_habit_permanently(habit_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
row = await owned_habit(db, user.id, habit_id)
if row.archived_at is None:
raise HTTPException(409, "请先归档再永久删除")
await db.delete(row)
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)
require_active_habit(habit)
await require_positive_log_day(db, habit, payload.day)
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)
require_active_habit(habit)
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 value > (row.value if row else 0):
await require_positive_log_day(db, habit, 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.delete("/habits/{habit_id}/logs/{day}", status_code=204)
async def delete_habit_log(habit_id: UUID, day: date, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
habit = await owned_habit(db, user.id, habit_id)
require_active_habit(habit)
row = await db.scalar(select(HabitLog).where(HabitLog.habit_id == habit.id, HabitLog.day == day))
if row is not None:
await db.delete(row)
await db.commit()
return Response(status_code=204)
@router.get("/habits/{habit_id}/logs")
async def habit_logs(habit_id: UUID, from_date: date | None = Query(default=None, alias="from"), to_date: date | None = Query(default=None, alias="to"), user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
habit = await owned_habit(db, user.id, habit_id)
condition = HabitLog.habit_id == habit.id
if from_date is not None:
condition = condition & (HabitLog.day >= from_date)
if to_date is not None:
condition = condition & (HabitLog.day <= to_date)
return [{"day": x.day, "value": x.value} for x in (await db.scalars(select(HabitLog).where(condition).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)
require_active_habit(habit)
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)).order_by(Habit.position, Habit.created_at))).all())
if not habits:
return {"days": days, "habits": []}
habit_ids = [habit.id for habit in habits]
week_logs = (await db.scalars(select(HabitLog).where(HabitLog.habit_id.in_(habit_ids), HabitLog.day.between(days[0], days[-1])))).all()
stats_rows = (await db.execute(
select(
HabitLog.habit_id,
func.sum(HabitLog.value),
func.count(HabitLog.id),
func.sum(case((HabitLog.value >= Habit.target, 1), else_=0)),
)
.join(Habit, Habit.id == HabitLog.habit_id)
.where(HabitLog.habit_id.in_(habit_ids))
.group_by(HabitLog.habit_id)
)).all()
pause_rows = (await db.scalars(select(HabitPause).where(HabitPause.habit_id.in_(habit_ids), HabitPause.end_date >= days[0], HabitPause.start_date <= days[-1]))).all()
logs_by_habit = {}
stats_by_habit = {}
pauses_by_habit = {}
for log in week_logs:
logs_by_habit.setdefault(log.habit_id, {})[log.day] = log.value
for habit_id, total, logged_days, completed_days in stats_rows:
stats_by_habit[habit_id] = {"total": total or 0, "completed_days": completed_days or 0, "logged_days": logged_days or 0}
for pause in pause_rows:
pauses_by_habit.setdefault(pause.habit_id, []).append(pause)
output = []
for habit in habits:
logs = logs_by_habit.get(habit.id, {})
pauses = pauses_by_habit.get(habit.id, [])
data = habit_dict(habit)
data["cells"] = [{"day": day, "scheduled": scheduled(habit, day), "paused": any(pause.start_date <= day <= pause.end_date for pause in pauses), "value": logs.get(day, 0)} for day in days]
data["stats"] = stats_by_habit.get(habit.id, {"total": 0, "completed_days": 0, "logged_days": 0})
output.append(data)
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)))
external_ids = [row["ID"].strip() for row in rows if row.get("ID", "").strip()]
existing_ids = set(
(
await db.scalars(
select(Task.external_id).where(Task.user_id == user.id, Task.external_id.in_(external_ids))
)
).all()
) if external_ids else set()
imported = skipped = 0
for raw in rows:
external_id = raw["ID"].strip()
if not external_id or external_id in existing_ids:
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); existing_ids.add(external_id); imported += 1
audit(db, user.id, "import", "task", count=imported); await db.commit(); return {"imported": imported, "skipped": skipped}
def _serialize_export_value(value):
if isinstance(value, UUID):
return str(value)
if isinstance(value, (date, datetime)):
return value.isoformat()
return value
def _export_payload(folders, lists, tasks, recurrences, habits, countdowns, memos):
def serialize(row, fields):
return {field: _serialize_export_value(getattr(row, field)) for field in fields}
return {
"version": 1,
"exported_at": utcnow().isoformat(),
"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],
"tasks": [serialize(x, ["id", "list_id", "parent_id", "title", "description", "priority", "completed", "due_at", "due_has_time", "external_id", "deleted_at"]) for x in tasks],
"recurrences": [serialize(x, ["id", "task_id", "rrule", "starts_at", "ends_at", "trigger_mode", "after_completion_days", "last_completed_at"]) for x in recurrences],
"habits": [serialize(x, ["id", "name", "kind", "target", "max_value", "schedule_type", "weekdays", "month_days", "interval_days", "start_date", "archived_at", "position"]) for x in habits],
"countdowns": [serialize(x, ["id", "title", "event_date", "calendar_mode", "lunar_month", "lunar_day", "ignore_year", "kind", "repeat_rule", "icon", "pinned", "archived_at", "created_at", "updated_at"]) for x in countdowns],
"memos": [serialize(x, ["id", "title", "content", "version", "created_at", "updated_at", "deleted_at"]) for x in memos],
}
async def _load_export_rows(user, db):
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())
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())
countdowns = list((await db.scalars(select(Countdown).where(Countdown.user_id == user.id))).all())
recurrences = list((await db.scalars(select(RecurrenceTemplate).where(RecurrenceTemplate.user_id == user.id))).all())
memos = list((await db.scalars(select(Memo).where(Memo.user_id == user.id))).all())
return folders, lists, tasks, recurrences, habits, countdowns, memos
@router.get("/export")
async def export_json(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
return _export_payload(*(await _load_export_rows(user, db)))
@router.get("/export.csv")
async def export_csv(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
payload = _export_payload(*(await _load_export_rows(user, db)))
output = io.StringIO()
writer = csv.writer(output, lineterminator="\n")
writer.writerow(["entity", "data"])
for entity in ("folders", "lists", "tasks", "recurrences", "habits", "countdowns", "memos"):
for row in payload[entity]:
writer.writerow([entity, json.dumps(row, ensure_ascii=False, separators=(",", ":"))])
content = "\ufeff" + output.getvalue()
return Response(
content=content,
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": 'attachment; filename="dodo-export.csv"'},
)
@router.post("/restore.csv")
async def restore_csv(
file: UploadFile = File(...),
mode: str = Query("merge", pattern="^(merge|replace)$"),
user: User = Depends(current_user),
db: AsyncSession = Depends(get_db),
):
text = (await file.read()).decode("utf-8-sig")
payload = {
"version": 1,
"folders": [],
"lists": [],
"tasks": [],
"recurrences": [],
"habits": [],
"countdowns": [],
"memos": [],
}
try:
for row in csv.DictReader(io.StringIO(text)):
entity = row.get("entity", "")
if entity not in payload or entity == "version":
raise ValueError("unknown entity")
payload[entity].append(json.loads(row["data"]))
except (csv.Error, json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
raise HTTPException(422, "无效的 Dodo CSV 备份") from exc
return await restore_json(payload, mode, user, db)
def _parse_backup_datetime(value, field_name: str):
if value in (None, ""):
return None
if not isinstance(value, str):
raise HTTPException(422, f"无效的倒数日备份字段:{field_name}")
try:
return datetime.fromisoformat(value)
except ValueError as exc:
raise HTTPException(422, f"无效的倒数日备份字段:{field_name}") from exc
def _validate_countdown_backups(payload: dict) -> list[dict]:
raw_items = payload.get("countdowns", [])
if not isinstance(raw_items, list):
raise HTTPException(422, "倒数日备份必须是数组")
parsed = []
seen_ids = set()
for raw in raw_items:
if not isinstance(raw, dict):
raise HTTPException(422, "无效的倒数日备份数据")
try:
source_id = UUID(raw["id"])
if source_id in seen_ids:
raise HTTPException(422, "倒数日备份包含重复 ID")
seen_ids.add(source_id)
raw_event_date = date.fromisoformat(raw["event_date"])
calendar_mode = raw.get("calendar_mode", "solar")
lunar_month = raw.get("lunar_month")
lunar_day = raw.get("lunar_day")
lunar_year = None
validation_date = raw_event_date
if calendar_mode == "lunar":
lunar_year, actual_month, actual_day = solar_to_lunar_parts(raw_event_date)
if (actual_month, actual_day) != (lunar_month, lunar_day):
raise HTTPException(422, "倒数日备份中的公历与农历日期不一致")
validation_date = date(lunar_year, 1, 1)
item = CountdownInput(
title=raw["title"],
event_date=validation_date,
calendar_mode=calendar_mode,
lunar_month=lunar_month,
lunar_day=lunar_day,
ignore_year=raw.get("ignore_year", False),
kind=raw.get("kind", "countdown"),
repeat_rule=raw.get("repeat_rule", "none"),
icon=raw.get("icon", "📅"),
pinned=raw.get("pinned", False),
)
except HTTPException:
raise
except (KeyError, TypeError, ValueError) as exc:
raise HTTPException(422, "无效的倒数日备份数据") from exc
item.event_date = raw_event_date
parsed.append({
"source_id": source_id,
"item": item,
"archived_at": _parse_backup_datetime(raw.get("archived_at"), "archived_at"),
"created_at": _parse_backup_datetime(raw.get("created_at"), "created_at") or utcnow(),
"updated_at": _parse_backup_datetime(raw.get("updated_at"), "updated_at") or utcnow(),
})
return parsed
@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, "不支持的备份版本")
parsed_countdowns = _validate_countdown_backups(payload)
if mode == "replace":
await db.execute(delete(Memo).where(Memo.user_id == user.id))
await db.execute(delete(Countdown).where(Countdown.user_id == user.id))
await db.execute(delete(Task).where(Task.user_id == user.id))
await db.execute(delete(Habit).where(Habit.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 = {}
task_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
pending_tasks = []
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":
task_id_map[raw["id"]] = existing.id
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,
due_has_time=raw.get("due_has_time", True),
external_id=ext,
)
db.add(row)
await db.flush()
task_id_map[raw["id"]] = row.id
pending_tasks.append((row.id, raw.get("parent_id")))
restored += 1
for task_id, old_parent_id in pending_tasks:
if old_parent_id and old_parent_id in task_id_map:
await db.execute(update(Task).where(Task.id == task_id, Task.user_id == user.id).values(parent_id=task_id_map[old_parent_id]))
for raw in payload.get("recurrences", []):
task_id = task_id_map.get(raw.get("task_id"))
if not task_id:
continue
trigger_mode = raw.get("trigger_mode", "scheduled")
days = raw.get("after_completion_days")
rrule = raw.get("rrule")
if trigger_mode not in {"scheduled", "after_completion"}:
raise HTTPException(422, "无效的重复触发模式")
if trigger_mode == "after_completion":
if not isinstance(days, int) or isinstance(days, bool) or not 1 <= days <= 3650 or rrule is not None:
raise HTTPException(422, "无效的完成后重复备份")
elif not isinstance(rrule, str):
raise HTTPException(422, "定期重复缺少 RRULE")
db.add(RecurrenceTemplate(
user_id=user.id,
task_id=task_id,
rrule=rrule,
starts_at=datetime.fromisoformat(raw["starts_at"]),
ends_at=datetime.fromisoformat(raw["ends_at"]) if raw.get("ends_at") else None,
trigger_mode=trigger_mode,
after_completion_days=days,
last_completed_at=datetime.fromisoformat(raw["last_completed_at"])
if raw.get("last_completed_at") else None,
))
for raw in payload.get("habits", []):
row = Habit(
user_id=user.id,
name=raw["name"],
kind=raw.get("kind", "boolean"),
target=raw.get("target", 1),
max_value=raw.get("max_value"),
schedule_type=raw.get("schedule_type", "daily"),
weekdays=raw.get("weekdays"),
month_days=raw.get("month_days"),
interval_days=raw.get("interval_days"),
start_date=date.fromisoformat(raw["start_date"]),
archived_at=datetime.fromisoformat(raw["archived_at"]) if raw.get("archived_at") else None,
position=raw.get("position", 0),
)
db.add(row)
existing_countdown_ids = set((await db.scalars(select(Countdown.id).where(Countdown.user_id == user.id))).all())
occupied_countdown_ids = dict((await db.execute(select(Countdown.id, Countdown.user_id))).all())
has_pinned_countdown = bool(await db.scalar(select(Countdown.id).where(Countdown.user_id == user.id, Countdown.pinned.is_(True))))
for parsed in parsed_countdowns:
source_id = parsed["source_id"]
row_id = source_id
while row_id in occupied_countdown_ids and occupied_countdown_ids[row_id] != user.id:
row_id = uuid5(user.id, str(row_id))
if mode == "merge" and row_id in existing_countdown_ids:
continue
item = parsed["item"]
item.pinned = item.pinned and not has_pinned_countdown
row = Countdown(
id=row_id,
user_id=user.id,
**item.model_dump(),
archived_at=parsed["archived_at"],
created_at=parsed["created_at"],
updated_at=parsed["updated_at"],
)
if row.archived_at is not None:
row.pinned = False
has_pinned_countdown = has_pinned_countdown or row.pinned
db.add(row)
existing_countdown_ids.add(row_id)
occupied_countdown_ids[row_id] = user.id
restored += 1
existing_memo_ids = set((await db.scalars(select(Memo.id).where(Memo.user_id == user.id))).all())
for raw in payload.get("memos", []):
try:
source_id = UUID(raw["id"])
title = raw["title"].strip()
content = raw.get("content", "")
version = raw.get("version", 1)
if not title or len(title) > 200 or not isinstance(content, str):
raise ValueError
if not isinstance(version, int) or isinstance(version, bool) or version < 1:
raise ValueError
created_at = datetime.fromisoformat(raw["created_at"])
updated_at = datetime.fromisoformat(raw["updated_at"])
deleted_at = datetime.fromisoformat(raw["deleted_at"]) if raw.get("deleted_at") else None
except (KeyError, TypeError, ValueError) as exc:
raise HTTPException(422, "无效的备忘录备份数据") from exc
occupied_user = await db.scalar(select(Memo.user_id).where(Memo.id == source_id))
row_id = source_id
while occupied_user not in (None, user.id):
row_id = uuid5(user.id, str(row_id))
occupied_user = await db.scalar(select(Memo.user_id).where(Memo.id == row_id))
if mode == "merge" and row_id in existing_memo_ids:
continue
db.add(Memo(
id=row_id, user_id=user.id, title=title, content=content, version=version,
created_at=created_at, updated_at=updated_at, deleted_at=deleted_at,
))
existing_memo_ids.add(row_id)
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]