feat: repeat tasks after completion
This commit is contained in:
+65
-63
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user