feat: repeat tasks after completion
This commit is contained in:
+105
-11
@@ -50,13 +50,27 @@ def audit(db: AsyncSession, user_id: UUID, action: str, entity_type: str, entity
|
||||
|
||||
class RecurrenceCreate(BaseModel):
|
||||
task_id: UUID
|
||||
rrule: str = Field(min_length=5, max_length=1000)
|
||||
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):
|
||||
@@ -208,21 +222,49 @@ async def get_task_recurrence(task_id: UUID, user: User = Depends(current_user),
|
||||
))
|
||||
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}
|
||||
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, "重复任务需要截止时间")
|
||||
parse_rrule(payload.rrule)
|
||||
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(), starts_at=task.due_at)
|
||||
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}
|
||||
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):
|
||||
@@ -238,6 +280,9 @@ async def upsert_exception(db, template_id, at):
|
||||
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)
|
||||
@@ -254,16 +299,49 @@ async def edit_recurrence(recurrence_id: UUID, payload: RecurrenceChange, scope:
|
||||
elif payload.title:
|
||||
task.title = payload.title
|
||||
else:
|
||||
if payload.rrule: parse_rrule(payload.rrule); template.rrule = payload.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
|
||||
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()
|
||||
return {"id": template.id, "scope": scope}
|
||||
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())
|
||||
@@ -273,6 +351,8 @@ async def complete_occurrence(recurrence_id: UUID, payload: OccurrenceComplete,
|
||||
@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)
|
||||
@@ -1031,7 +1111,7 @@ def _export_payload(folders, lists, tasks, recurrences, habits, countdowns):
|
||||
"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"]) for x in recurrences],
|
||||
"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],
|
||||
}
|
||||
@@ -1228,12 +1308,26 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg
|
||||
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=raw["rrule"],
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user