feat: repeat tasks after completion
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 3m31s

This commit is contained in:
2026-09-10 21:21:28 +08:00
parent 84592db098
commit 64b8525720
14 changed files with 849 additions and 114 deletions
+65 -63
View File
@@ -7,7 +7,7 @@ import shutil
import time
from collections import defaultdict, deque
from contextlib import asynccontextmanager
from datetime import UTC, datetime, timedelta
from datetime import UTC, datetime
from pathlib import Path, PureWindowsPath
from uuid import UUID, uuid4
@@ -43,8 +43,9 @@ from .models import (
User,
utcnow,
)
from .mvp import audit, occurrences
from .mvp import audit
from .mvp import router as mvp_router
from .recurrence_service import apply_task_changes, lock_task
from .schemas import (
BatchResult,
BatchTaskUpdate,
@@ -66,6 +67,7 @@ from .schemas import (
TaskReorder,
TaskUpdate,
UserOut,
UserUpdate,
)
@@ -193,6 +195,18 @@ async def me(user: User = Depends(current_user)):
return user
@app.patch("/api/v1/me", response_model=UserOut)
async def update_me(
payload: UserUpdate,
user: User = Depends(current_user),
db: AsyncSession = Depends(get_db),
):
user.timezone = payload.timezone
await db.commit()
await db.refresh(user)
return user
@app.post("/api/v1/auth/change-password", status_code=204)
async def change_password(
payload: ChangePasswordRequest,
@@ -958,12 +972,12 @@ async def create_task(
)
if parent is None:
raise HTTPException(status_code=400, detail="父任务必须是同一清单的顶层任务")
if payload.rrule:
if not payload.due_at:
raise HTTPException(status_code=422, detail="重复任务需要截止时间")
recurrence_requested = payload.rrule or payload.trigger_mode
if recurrence_requested:
from .mvp import parse_rrule
parse_rrule(payload.rrule)
data = payload.model_dump(exclude={"rrule"})
if payload.rrule:
parse_rrule(payload.rrule)
data = payload.model_dump(exclude={"rrule", "trigger_mode", "after_completion_days"})
parent_filter = Task.parent_id == payload.parent_id if payload.parent_id else Task.parent_id.is_(None)
max_position = await db.scalar(select(func.max(Task.position)).where(
Task.user_id == user.id,
@@ -974,8 +988,17 @@ async def create_task(
task = Task(user_id=user.id, position=(max_position if max_position is not None else -1) + 1, **data)
db.add(task)
await db.flush()
if payload.rrule:
db.add(RecurrenceTemplate(user_id=user.id, task_id=task.id, rrule=payload.rrule.upper(), starts_at=task.due_at))
if recurrence_requested:
db.add(
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 or "scheduled",
after_completion_days=payload.after_completion_days,
)
)
audit(db, user.id, "create", "task", task.id)
await db.commit()
await db.refresh(task)
@@ -1142,30 +1165,11 @@ 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)))
task = await lock_task(db, user.id, task_id)
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
data = payload.model_dump(exclude_unset=True)
expected_version = data.pop("version")
recurrence = None
reset_subtasks = False
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]
reset_subtasks = True
if "list_id" in data:
await _owned_list(db, user.id, data["list_id"])
if task.parent_id:
@@ -1174,46 +1178,19 @@ async def update_task(
)
if parent_list != data["list_id"]:
raise HTTPException(status_code=400, detail="子任务必须与父任务属于同一清单")
data["version"] = Task.version + 1
data["updated_at"] = utcnow()
result = await db.execute(
update(Task)
.where(
Task.id == task_id,
Task.user_id == user.id,
Task.deleted_at.is_(None),
Task.version == expected_version,
)
.values(**data)
.returning(Task)
task, changed = await apply_task_changes(
db,
user=user,
task_id=task_id,
expected_version=expected_version,
changes=data,
)
task = result.scalar_one_or_none()
if task is None:
exists_id = await db.scalar(
select(Task.id).where(Task.id == task_id, Task.user_id == user.id, Task.deleted_at.is_(None))
)
if exists_id:
raise HTTPException(status_code=409, detail="任务已被更新,请刷新后重试")
raise HTTPException(status_code=404, detail="任务不存在")
if reset_subtasks:
reset_at = utcnow()
await db.execute(
update(Task)
.where(
Task.parent_id == task.id,
Task.user_id == user.id,
Task.deleted_at.is_(None),
Task.completed.is_(True),
)
.values(completed=False, version=Task.version + 1, updated_at=reset_at)
)
if "list_id" in data and task.parent_id is None:
await db.execute(
update(Task)
.where(Task.parent_id == task.id, Task.user_id == user.id, Task.deleted_at.is_(None))
.values(list_id=task.list_id, version=Task.version + 1, updated_at=utcnow())
)
changed = {k for k in data if k not in {"version", "updated_at"}}
if changed & {"title", "description", "priority", "due_at", "list_id", "completed"}:
action = "complete" if data.get("completed") is True else "update"
audit(db, user.id, action, "task", task.id, fields=sorted(changed))
@@ -1337,7 +1314,32 @@ 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="子任务不能脱离父任务单独移动")
changes = payload.model_dump(exclude_unset=True, exclude={"task_ids", "soft_delete"})
changes = payload.model_dump(exclude_unset=True, exclude={"task_ids", "soft_delete", "versions"})
if payload.completed is True:
versions = payload.versions
other_changes = {key: value for key, value in changes.items() if key != "completed"}
for task_id in task_ids:
await apply_task_changes(
db,
user=user,
task_id=task_id,
expected_version=versions[task_id],
changes={"completed": True, **other_changes},
)
if payload.list_id is not None:
parent_ids = [task.id for task in tasks if task.parent_id is None]
if parent_ids:
await db.execute(
update(Task)
.where(
Task.parent_id.in_(parent_ids),
Task.user_id == user.id,
Task.deleted_at.is_(None),
)
.values(list_id=payload.list_id, version=Task.version + 1, updated_at=utcnow())
)
await db.commit()
return BatchResult(updated=len(task_ids))
if payload.soft_delete:
changes["deleted_at"] = utcnow()
if changes: