feat: add repeating tasks
ci / docker (push) Successful in 4m13s

This commit is contained in:
2026-09-08 08:16:57 +08:00
parent fde37de0fc
commit 0a3b959cb8
7 changed files with 179 additions and 10 deletions
+25 -8
View File
@@ -3,7 +3,7 @@ import json
import time
from collections import defaultdict, deque
from contextlib import asynccontextmanager
from datetime import datetime
from datetime import datetime, timedelta
from pathlib import Path
from uuid import UUID
@@ -26,8 +26,8 @@ from .auth import (
verify_password,
)
from .db import create_schema, get_db
from .models import AppState, Folder, Session, Task, TaskList, User, utcnow
from .mvp import audit
from .models import AppState, Folder, RecurrenceTemplate, Session, Task, TaskList, User, utcnow
from .mvp import audit, occurrences
from .mvp import router as mvp_router
from .schemas import (
BatchResult,
@@ -624,16 +624,33 @@ async def update_task(
user: User = Depends(current_user),
db: AsyncSession = Depends(get_db),
):
task = await db.scalar(select(Task).where(Task.id == task_id, Task.user_id == user.id, Task.deleted_at.is_(None)))
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
data = payload.model_dump(exclude_unset=True)
expected_version = data.pop("version")
recurrence = None
if data.get("completed") is True:
recurrence = await db.scalar(select(RecurrenceTemplate).where(
RecurrenceTemplate.task_id == task_id, RecurrenceTemplate.user_id == user.id
))
if recurrence:
next_items = occurrences(
recurrence.rrule,
recurrence.starts_at,
recurrence.starts_at + timedelta(microseconds=1),
recurrence.starts_at + timedelta(days=3660),
recurrence.ends_at,
)
if next_items:
data["completed"] = False
data["due_at"] = next_items[0]
recurrence.starts_at = next_items[0]
if "list_id" in data:
await _owned_list(db, user.id, data["list_id"])
parent_id = await db.scalar(
select(Task.parent_id).where(Task.id == task_id, Task.user_id == user.id)
)
if parent_id:
if task.parent_id:
parent_list = await db.scalar(
select(Task.list_id).where(Task.id == parent_id, Task.user_id == user.id)
select(Task.list_id).where(Task.id == task.parent_id, Task.user_id == user.id)
)
if parent_list != data["list_id"]:
raise HTTPException(status_code=400, detail="子任务必须与父任务属于同一清单")
+24
View File
@@ -186,6 +186,17 @@ def ensure_real_occurrence(template: RecurrenceTemplate, occurrence_at: datetime
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}
@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)
@@ -779,12 +790,14 @@ async def export_json(user: User = Depends(current_user), db: AsyncSession = Dep
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()); 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())
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],
"tasks": [serialize(x, ["id", "list_id", "parent_id", "title", "description", "priority", "completed", "due_at", "external_id", "deleted_at"]) for x in tasks],
"recurrences": [serialize(x, ["id", "task_id", "rrule", "starts_at", "ends_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],
}
@@ -852,6 +865,17 @@ 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]))
for raw in payload.get("recurrences", []):
task_id = task_id_map.get(raw.get("task_id"))
if not task_id:
continue
db.add(RecurrenceTemplate(
user_id=user.id,
task_id=task_id,
rrule=raw["rrule"],
starts_at=datetime.fromisoformat(raw["starts_at"]),
ends_at=datetime.fromisoformat(raw["ends_at"]) if raw.get("ends_at") else None,
))
for raw in payload.get("habits", []):
row = Habit(
user_id=user.id,