refactor: simplify dodo core experience
ci / docker (push) Successful in 3m54s

This commit is contained in:
2026-09-06 14:41:08 +08:00
parent fcaabce99f
commit d595af4f52
29 changed files with 187 additions and 955 deletions
+4 -92
View File
@@ -26,7 +26,7 @@ from .auth import (
verify_password,
)
from .db import create_schema, get_db
from .models import AppState, Folder, Session, Tag, Task, TaskList, TaskTag, User, utcnow
from .models import AppState, Folder, Session, Task, TaskList, User, utcnow
from .mvp import audit
from .mvp import router as mvp_router
from .schemas import (
@@ -40,8 +40,6 @@ from .schemas import (
LoginRequest,
NameUpdate,
SessionOut,
TagCreate,
TagOut,
TaskCreate,
TaskDetailOut,
TaskOut,
@@ -186,15 +184,11 @@ async def bootstrap_data(user: User = Depends(current_user), db: AsyncSession =
.where(TaskList.user_id == user.id, TaskList.deleted_at.is_(None))
.order_by(TaskList.is_inbox.desc(), TaskList.position, TaskList.created_at)
)).all())
tags = list((await db.scalars(
select(Tag).where(Tag.user_id == user.id).order_by(Tag.name, Tag.id)
)).all())
inbox = next((item for item in lists if item.is_inbox), None)
return {
"user": UserOut.model_validate(user),
"folders": [FolderOut.model_validate(item) for item in folders],
"lists": [ListOut.model_validate(item) for item in lists],
"tags": [TagOut.model_validate(item) for item in tags],
"inbox_id": inbox.id if inbox else None,
}
@@ -420,51 +414,6 @@ async def restore_list(
return item
@app.post("/api/v1/tags", response_model=TagOut, status_code=201)
async def create_tag(
payload: TagCreate,
user: User = Depends(current_user),
db: AsyncSession = Depends(get_db),
):
existing = await db.scalar(
select(Tag.id).where(Tag.user_id == user.id, func.lower(Tag.name) == payload.name.lower())
)
if existing:
raise HTTPException(status_code=409, detail="标签名称已存在")
tag = Tag(user_id=user.id, **payload.model_dump())
db.add(tag)
await db.commit()
await db.refresh(tag)
return tag
@app.get("/api/v1/tags", response_model=list[TagOut])
async def list_tags(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
return list(
(await db.scalars(select(Tag).where(Tag.user_id == user.id).order_by(Tag.name, Tag.id))).all()
)
async def _validate_tags(
db: AsyncSession, user_id: UUID, tag_ids: list[UUID] | None
) -> list[UUID] | None:
if tag_ids is None:
return None
unique_ids = list(dict.fromkeys(tag_ids))
if not unique_ids:
return []
found = set(
(await db.scalars(select(Tag.id).where(Tag.user_id == user_id, Tag.id.in_(unique_ids)))).all()
)
if found != set(unique_ids):
raise HTTPException(status_code=404, detail="标签不存在")
return unique_ids
async def _replace_tags(db: AsyncSession, task_ids: list[UUID], tag_ids: list[UUID]) -> None:
await db.execute(delete(TaskTag).where(TaskTag.task_id.in_(task_ids)))
db.add_all(TaskTag(task_id=task_id, tag_id=tag_id) for task_id in task_ids for tag_id in tag_ids)
def _encode_cursor(created_at: datetime, task_id: UUID) -> str:
raw = json.dumps([created_at.isoformat(), str(task_id)]).encode()
@@ -487,7 +436,6 @@ async def create_task(
db: AsyncSession = Depends(get_db),
):
await _owned_list(db, user.id, payload.list_id)
tag_ids = await _validate_tags(db, user.id, payload.tag_ids)
if payload.parent_id:
parent = await db.scalar(
select(Task).where(
@@ -500,13 +448,11 @@ async def create_task(
)
if parent is None:
raise HTTPException(status_code=400, detail="父任务必须是同一清单的顶层任务")
data = payload.model_dump(exclude={"tag_ids"})
data = payload.model_dump()
task = Task(user_id=user.id, **data)
db.add(task)
await db.flush()
audit(db, user.id, "create", "task", task.id)
if tag_ids:
db.add_all(TaskTag(task_id=task.id, tag_id=tag_id) for tag_id in tag_ids)
await db.commit()
await db.refresh(task)
return task
@@ -540,11 +486,6 @@ async def list_tasks(
query = query.where(Task.due_at < due_to)
if q:
pattern = f"%{q}%"
tag_match = exists(
select(TaskTag.task_id)
.join(Tag, Tag.id == TaskTag.tag_id)
.where(TaskTag.task_id == Task.id, Tag.user_id == user.id, Tag.name.ilike(pattern))
)
list_match = exists(
select(TaskList.id).where(
TaskList.id == Task.list_id,
@@ -553,7 +494,7 @@ async def list_tasks(
)
)
query = query.where(
or_(Task.title.ilike(pattern), Task.description.ilike(pattern), tag_match, list_match)
or_(Task.title.ilike(pattern), Task.description.ilike(pattern), list_match)
)
total = await db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0
ordering = (Task.created_at, Task.id)
@@ -577,14 +518,6 @@ async def _task_details(db: AsyncSession, tasks: list[Task]) -> list[TaskDetailO
if not tasks:
return []
task_ids = [task.id for task in tasks]
tag_rows = (
await db.execute(
select(TaskTag.task_id, Tag)
.join(Tag, Tag.id == TaskTag.tag_id)
.where(TaskTag.task_id.in_(task_ids))
.order_by(Tag.name, Tag.id)
)
).all()
subtasks = list(
(
await db.scalars(
@@ -594,22 +527,17 @@ async def _task_details(db: AsyncSession, tasks: list[Task]) -> list[TaskDetailO
)
).all()
)
tags_by_task: dict[UUID, list[Tag]] = defaultdict(list)
subtasks_by_task: dict[UUID, list[Task]] = defaultdict(list)
for task_id, tag in tag_rows:
tags_by_task[task_id].append(tag)
for subtask in subtasks:
subtasks_by_task[subtask.parent_id].append(subtask)
return [
TaskDetailOut(
**TaskOut.model_validate(task).model_dump(),
tags=tags_by_task[task.id],
subtasks=subtasks_by_task[task.id],
)
for task in tasks
]
async def _task_detail(db: AsyncSession, task: Task) -> TaskDetailOut:
return (await _task_details(db, [task]))[0]
@@ -637,7 +565,6 @@ async def update_task(
):
data = payload.model_dump(exclude_unset=True)
expected_version = data.pop("version")
tag_ids = await _validate_tags(db, user.id, data.pop("tag_ids", None))
if "list_id" in data:
await _owned_list(db, user.id, data["list_id"])
parent_id = await db.scalar(
@@ -670,8 +597,6 @@ async def update_task(
if exists_id:
raise HTTPException(status_code=409, detail="任务已被更新,请刷新后重试")
raise HTTPException(status_code=404, detail="任务不存在")
if tag_ids is not None:
await _replace_tags(db, [task_id], tag_ids)
if "list_id" in data and task.parent_id is None:
await db.execute(
update(Task)
@@ -773,16 +698,6 @@ async def permanently_delete_task(
)
if task is None:
raise HTTPException(status_code=404, detail="回收站中不存在该任务")
ids = list(
(
await db.scalars(
select(Task.id).where(
or_(Task.id == task.id, Task.parent_id == task.id), Task.user_id == user.id
)
)
).all()
)
await db.execute(delete(TaskTag).where(TaskTag.task_id.in_(ids)))
await db.execute(delete(Task).where(Task.parent_id == task.id, Task.user_id == user.id))
await db.delete(task)
await db.commit()
@@ -812,8 +727,7 @@ async def batch_update_tasks(
standalone_children = [task for task in tasks if task.parent_id is not None]
if standalone_children:
raise HTTPException(status_code=400, detail="子任务不能脱离父任务单独移动")
tag_ids = await _validate_tags(db, user.id, payload.tag_ids)
changes = payload.model_dump(exclude_unset=True, exclude={"task_ids", "tag_ids", "soft_delete"})
changes = payload.model_dump(exclude_unset=True, exclude={"task_ids", "soft_delete"})
if payload.soft_delete:
changes["deleted_at"] = utcnow()
if changes:
@@ -856,8 +770,6 @@ async def batch_update_tasks(
updated_at=utcnow(),
)
)
if tag_ids is not None:
await _replace_tags(db, task_ids, tag_ids)
await db.commit()
return BatchResult(updated=len(task_ids))
-26
View File
@@ -77,21 +77,6 @@ class TaskList(Base):
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class Tag(Base):
__tablename__ = "tags"
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_tags_user_id_name"),)
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(80))
color: Mapped[str] = mapped_column(String(32), default="#f15a29")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class TaskTag(Base):
__tablename__ = "task_tags"
task_id: Mapped[UUID] = mapped_column(ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=True)
tag_id: Mapped[UUID] = mapped_column(ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True)
class Task(Base):
__tablename__ = "tasks"
@@ -188,17 +173,6 @@ class Attachment(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class CalendarSubscription(Base):
__tablename__ = "calendar_subscriptions"
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
name: Mapped[str] = mapped_column(String(120))
url: Mapped[str] = mapped_column(Text)
color: Mapped[str] = mapped_column(String(32), default="#f15a29")
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
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)
+1 -300
View File
@@ -1,21 +1,12 @@
import asyncio
import csv
import http.client
import io
import ipaddress
import re
import socket
import ssl
import urllib.parse
from datetime import UTC, date, datetime, time, timedelta
from pathlib import Path
from uuid import UUID
from zoneinfo import ZoneInfo
from dateutil.rrule import rrulestr
from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile
from fastapi.responses import FileResponse
from icalendar import Calendar
from pydantic import BaseModel, Field, model_validator
from sqlalchemy import case, delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
@@ -26,17 +17,14 @@ from .db import get_db
from .models import (
Attachment,
AuditLog,
CalendarSubscription,
Folder,
Habit,
HabitLog,
HabitPause,
RecurrenceException,
RecurrenceTemplate,
Tag,
Task,
TaskList,
TaskTag,
User,
new_id,
utcnow,
@@ -262,265 +250,6 @@ async def delete_recurrence(recurrence_id: UUID, scope: str = Query("all", patte
await db.commit(); return Response(status_code=204)
class CalendarSubscriptionCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
url: str = Field(min_length=1, max_length=2000)
color: str = Field(default="#f15a29", min_length=1, max_length=32)
def calendar_subscription_dict(row: CalendarSubscription):
return {"id": row.id, "name": row.name, "url": row.url, "color": row.color, "enabled": row.enabled}
LOCAL_TZ = ZoneInfo("Asia/Shanghai")
def _is_forbidden_ip(value: str) -> bool:
ip = ipaddress.ip_address(value)
return ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved
def _localized_ics_datetime(value, default_tz=LOCAL_TZ):
if isinstance(value, datetime):
return value if value.tzinfo else value.replace(tzinfo=default_tz), False
if isinstance(value, date):
return datetime.combine(value, time.min, tzinfo=default_tz), True
raise ValueError("unsupported ICS datetime")
def _as_utc(value, default_tz=LOCAL_TZ):
localized, all_day = _localized_ics_datetime(value, default_tz)
return localized.astimezone(UTC), all_day
def _event_duration(event, starts_at: datetime, all_day: bool):
if event.get("dtend"):
ends_at, _ = _as_utc(event.decoded("dtend"))
return max(ends_at - starts_at, timedelta())
if event.get("duration"):
return event.decoded("duration")
return timedelta(days=1) if all_day else timedelta(hours=1)
def _excluded_starts(event):
excluded = set()
exdates = event.get("exdate")
if not exdates:
return excluded
if not isinstance(exdates, list):
exdates = [exdates]
for exdate in exdates:
for item in getattr(exdate, "dts", []):
excluded.add(_as_utc(item.dt)[0])
return excluded
def _append_calendar_event(events, source_name, color, event, starts_at, ends_at, all_day):
uid = str(event.get("uid") or "")
title = str(event.get("summary") or "未命名事件").strip() or "未命名事件"
events.append({
"id": uid or f"{source_name}-{starts_at.isoformat()}-{title}",
"title": title,
"starts_at": starts_at,
"ends_at": ends_at,
"all_day": all_day,
"source_name": source_name,
"color": color,
})
def _overlaps(starts_at: datetime, ends_at: datetime | None, window_start: datetime | None, window_end: datetime | None) -> bool:
if not window_start or not window_end:
return True
return starts_at < window_end and (ends_at or starts_at) > window_start
def parse_ics_events(content: str, source_name: str, color: str, window_start: datetime | None = None, window_end: datetime | None = None):
calendar = Calendar.from_ical(content)
components = list(calendar.walk("VEVENT"))
overrides = {}
for event in components:
recurrence_id = event.get("recurrence-id")
if recurrence_id:
overrides[(str(event.get("uid") or ""), _as_utc(event.decoded("recurrence-id"))[0])] = event
events = []
for event in components:
if not event.get("dtstart") or event.get("recurrence-id") or str(event.get("status") or "").upper() == "CANCELLED":
continue
localized_start, all_day = _localized_ics_datetime(event.decoded("dtstart"))
starts_at = localized_start.astimezone(UTC)
duration = _event_duration(event, starts_at, all_day)
excluded = _excluded_starts(event)
uid = str(event.get("uid") or "")
if event.get("rrule") and window_start and window_end:
rule_text = event.get("rrule").to_ical().decode()
rule = rrulestr(rule_text, dtstart=localized_start)
for occurrence in rule.between(window_start - duration, window_end, inc=True):
occurrence = occurrence.astimezone(UTC) if occurrence.tzinfo else occurrence.replace(tzinfo=LOCAL_TZ).astimezone(UTC)
if occurrence in excluded or (uid, occurrence) in overrides:
continue
ends_at = occurrence + duration
if _overlaps(occurrence, ends_at, window_start, window_end):
_append_calendar_event(events, source_name, color, event, occurrence, ends_at, all_day)
continue
ends_at = starts_at + duration
if _overlaps(starts_at, ends_at, window_start, window_end):
_append_calendar_event(events, source_name, color, event, starts_at, ends_at, all_day)
for event in overrides.values():
if not event.get("dtstart") or str(event.get("status") or "").upper() == "CANCELLED":
continue
starts_at, all_day = _as_utc(event.decoded("dtstart"))
duration = _event_duration(event, starts_at, all_day)
ends_at = starts_at + duration
if _overlaps(starts_at, ends_at, window_start, window_end):
_append_calendar_event(events, source_name, color, event, starts_at, ends_at, all_day)
return events
def _validated_calendar_target(url: str):
parsed = urllib.parse.urlparse(url)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise HTTPException(422, "日历订阅只支持 http/https 链接")
if parsed.username or parsed.password:
raise HTTPException(422, "日历订阅链接不能包含账号密码")
port = parsed.port or (443 if parsed.scheme == "https" else 80)
try:
addresses = socket.getaddrinfo(parsed.hostname, port, type=socket.SOCK_STREAM)
except socket.gaierror as exc:
raise HTTPException(422, "日历订阅域名无法解析") from exc
for item in addresses:
ip = item[4][0]
if not _is_forbidden_ip(ip):
return parsed, ip, port
raise HTTPException(422, "日历订阅不能指向内网地址")
def _validate_public_calendar_url(url: str):
_validated_calendar_target(url)
def _request_pinned_calendar_url(url: str):
parsed, ip, port = _validated_calendar_target(url)
target_host = f"[{ip}]" if ":" in ip else ip
path = urllib.parse.urlunparse(("", "", parsed.path or "/", parsed.params, parsed.query, ""))
headers = {"Host": parsed.hostname or "", "User-Agent": "dodo-calendar-fetch/1.0"}
if parsed.port:
headers["Host"] = f"{headers['Host']}:{parsed.port}"
if parsed.scheme == "https":
connection = http.client.HTTPSConnection(target_host, port=port, timeout=15, context=ssl.create_default_context())
else:
connection = http.client.HTTPConnection(target_host, port=port, timeout=15)
try:
if parsed.scheme == "https":
raw = socket.create_connection((ip, port), timeout=15)
sock = ssl.create_default_context().wrap_socket(raw, server_hostname=parsed.hostname)
connection.sock = sock
connection.request("GET", path, headers=headers)
response = connection.getresponse()
body = response.read(2_000_001)
return response.status, response.getheaders(), response.getheader("Content-Type") or "", body
except OSError as exc:
raise HTTPException(502, f"订阅拉取失败: {exc}") from exc
finally:
connection.close()
_CAL_CACHE: dict[str, tuple[float, list[dict]]] = {}
_CAL_CACHE_TTL = 300.0
def fetch_calendar_events(url: str, source_name: str, color: str, window_start: datetime | None = None, window_end: datetime | None = None):
cache_key = f"{source_name}|{url}|{window_start.isoformat() if window_start else ''}|{window_end.isoformat() if window_end else ''}"
now = datetime.now(UTC).timestamp()
hit = _CAL_CACHE.get(cache_key)
if hit and now - hit[0] < _CAL_CACHE_TTL:
return [dict(item) for item in hit[1]]
current_url = url
for _ in range(4):
status, headers, content_type, body = _request_pinned_calendar_url(current_url)
if status in {301, 302, 303, 307, 308}:
location = dict(headers).get("Location")
if not location:
raise HTTPException(502, "订阅重定向缺少目标地址")
current_url = urllib.parse.urljoin(current_url, location)
continue
if status >= 400:
raise HTTPException(502, f"订阅拉取失败: HTTP {status}")
if content_type.split(";", 1)[0].lower() not in {"text/calendar", "text/plain", "application/octet-stream"}:
raise HTTPException(422, "订阅链接没有返回 ICS 日历内容")
if len(body) > 2_000_000:
raise HTTPException(413, "日历订阅内容超过 2MB")
parsed = parse_ics_events(body.decode("utf-8", errors="ignore"), source_name, color, window_start, window_end)
if len(_CAL_CACHE) >= 64:
_CAL_CACHE.clear()
_CAL_CACHE[cache_key] = (now, parsed)
return [dict(item) for item in parsed]
raise HTTPException(502, "日历订阅重定向次数过多")
@router.get("/calendar-subscriptions")
async def list_calendar_subscriptions(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
rows = (await db.scalars(select(CalendarSubscription).where(CalendarSubscription.user_id == user.id).order_by(CalendarSubscription.created_at))).all()
return [calendar_subscription_dict(row) for row in rows]
@router.post("/calendar-subscriptions", status_code=201)
async def create_calendar_subscription(payload: CalendarSubscriptionCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
_validate_public_calendar_url(payload.url)
row = CalendarSubscription(user_id=user.id, **payload.model_dump())
db.add(row)
await db.commit()
await db.refresh(row)
return calendar_subscription_dict(row)
@router.get("/calendar-subscriptions/today")
async def today_calendar_events(
day: date,
user: User = Depends(current_user),
db: AsyncSession = Depends(get_db),
):
rows = list((await db.scalars(select(CalendarSubscription).where(CalendarSubscription.user_id == user.id, CalendarSubscription.enabled.is_(True)).order_by(CalendarSubscription.created_at))).all())
if not rows:
return []
tomorrow = day + timedelta(days=1)
day_start = datetime.combine(day, time.min, tzinfo=LOCAL_TZ).astimezone(UTC)
day_end = datetime.combine(tomorrow, time.min, tzinfo=LOCAL_TZ).astimezone(UTC)
async def pull(row: CalendarSubscription):
return await asyncio.to_thread(fetch_calendar_events, row.url, row.name, row.color, day_start, day_end)
results = await asyncio.gather(*(pull(row) for row in rows), return_exceptions=True)
events = []
for row, result in zip(rows, results, strict=True):
if isinstance(result, Exception):
continue
for item in result:
events.append({
"id": item["id"],
"title": item["title"],
"starts_at": item["starts_at"].isoformat(),
"ends_at": item["ends_at"].isoformat() if item.get("ends_at") else None,
"all_day": item["all_day"],
"source_name": row.name,
"color": row.color,
})
events.sort(key=lambda item: (item["all_day"] is False, item["starts_at"], item["title"]))
return events
@router.delete("/calendar-subscriptions/{subscription_id}", status_code=204)
async def delete_calendar_subscription(subscription_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
row = await db.scalar(select(CalendarSubscription).where(CalendarSubscription.id == subscription_id, CalendarSubscription.user_id == user.id))
if not row:
raise HTTPException(404, "日历订阅不存在")
await db.delete(row)
await db.commit()
return Response(status_code=204)
class HabitCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
@@ -763,25 +492,13 @@ async def import_ticktick(file: UploadFile = File(...), user: User = Depends(cur
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())
task_tags = [
{"task_id": str(x.task_id), "tag_id": str(x.tag_id)}
for x in (
await db.scalars(
select(TaskTag)
.join(Task, Task.id == TaskTag.task_id)
.where(Task.user_id == user.id)
)
).all()
]
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())
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],
"task_tags": task_tags,
"habits": [serialize(x, ["id", "name", "kind", "target", "max_value", "schedule_type", "weekdays", "month_days", "interval_days", "start_date", "archived_at"]) for x in habits],
}
@@ -791,15 +508,12 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg
if payload.get("version") != 1:
raise HTTPException(422, "不支持的备份版本")
if mode == "replace":
await db.execute(delete(TaskTag).where(TaskTag.task_id.in_(select(Task.id).where(Task.user_id == user.id))))
await db.execute(delete(Task).where(Task.user_id == user.id))
await db.execute(delete(Tag).where(Tag.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 = {}
tag_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))
@@ -824,11 +538,6 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg
inbox = TaskList(user_id=user.id, name="收集箱", is_inbox=True)
db.add(inbox)
await db.flush()
for raw in payload.get("tags", []):
row = Tag(user_id=user.id, name=raw["name"], color=raw.get("color", "#f15a29"))
db.add(row)
await db.flush()
tag_id_map[raw["id"]] = row.id
restored = 0
pending_tasks = []
for raw in payload.get("tasks", []):
@@ -855,14 +564,6 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg
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]))
task_tag_rows = []
for raw in payload.get("task_tags", []):
task_id = task_id_map.get(raw.get("task_id"))
tag_id = tag_id_map.get(raw.get("tag_id"))
if task_id and tag_id:
task_tag_rows.append(TaskTag(task_id=task_id, tag_id=tag_id))
if task_tag_rows:
db.add_all(task_tag_rows)
for raw in payload.get("habits", []):
row = Habit(
user_id=user.id,
+1 -16
View File
@@ -58,17 +58,6 @@ class ListOut(BaseModel):
is_inbox: bool
class TagCreate(BaseModel):
name: str = Field(min_length=1, max_length=80)
color: str = Field(default="#f15a29", min_length=1, max_length=32)
class TagOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
name: str
color: str
class TaskCreate(BaseModel):
title: str = Field(min_length=1, max_length=500)
@@ -77,7 +66,6 @@ class TaskCreate(BaseModel):
priority: int = Field(default=0, ge=0, le=3)
due_at: datetime | None = None
parent_id: UUID | None = None
tag_ids: list[UUID] = Field(default_factory=list)
class TaskUpdate(BaseModel):
@@ -87,7 +75,6 @@ class TaskUpdate(BaseModel):
due_at: datetime | None = None
completed: bool | None = None
list_id: UUID | None = None
tag_ids: list[UUID] | None = None
version: int = Field(ge=1)
@model_validator(mode="after")
@@ -112,7 +99,6 @@ class TaskOut(BaseModel):
class TaskDetailOut(TaskOut):
tags: list[TagOut] = Field(default_factory=list)
subtasks: list[TaskOut] = Field(default_factory=list)
@@ -129,12 +115,11 @@ class BatchTaskUpdate(BaseModel):
completed: bool | None = None
list_id: UUID | None = None
due_at: datetime | None = None
tag_ids: list[UUID] | None = None
soft_delete: bool | None = None
@model_validator(mode="after")
def require_operation(self):
operations = {"completed", "list_id", "due_at", "tag_ids", "soft_delete"}
operations = {"completed", "list_id", "due_at", "soft_delete"}
if not self.model_fields_set.intersection(operations):
raise ValueError("at least one batch operation is required")
if self.soft_delete is False:
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
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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-COed2nq9.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dhk-VG-k.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-gRSfwY0T.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DlZ9vwzJ.css">
</head><body><div id="app"></div></body></html>