[verified] feat: support monthly completion recurrence
This commit is contained in:
@@ -354,6 +354,9 @@ def _validate_recurrence_graph(parsed: ParsedArchive) -> None:
|
||||
"rrule": row.get("rrule"),
|
||||
"trigger_mode": row.get("trigger_mode", "scheduled"),
|
||||
"after_completion_days": row.get("after_completion_days"),
|
||||
"after_completion_unit": row.get("after_completion_unit")
|
||||
if row.get("trigger_mode", "scheduled") == "after_completion"
|
||||
else None,
|
||||
})
|
||||
starts_at = datetime.fromisoformat(row["starts_at"])
|
||||
ends_at = datetime.fromisoformat(row["ends_at"]) if row.get("ends_at") else None
|
||||
@@ -523,6 +526,8 @@ def _coerce(model, raw: dict, mapping: dict[str, dict[str, UUID]], user_id: UUID
|
||||
elif isinstance(effective_type, Date) and isinstance(value, str):
|
||||
value = date.fromisoformat(value)
|
||||
values[name] = value
|
||||
if model is RecurrenceTemplate and "after_completion_unit" not in raw:
|
||||
values["after_completion_unit"] = "days" if raw.get("trigger_mode") == "after_completion" else None
|
||||
return values
|
||||
|
||||
|
||||
|
||||
+4
-1
@@ -999,7 +999,7 @@ async def create_task(
|
||||
from .mvp import parse_rrule
|
||||
if payload.rrule:
|
||||
parse_rrule(payload.rrule)
|
||||
data = payload.model_dump(exclude={"rrule", "trigger_mode", "after_completion_days"})
|
||||
data = payload.model_dump(exclude={"rrule", "trigger_mode", "after_completion_days", "after_completion_unit"})
|
||||
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,
|
||||
@@ -1019,6 +1019,9 @@ async def create_task(
|
||||
starts_at=task.due_at,
|
||||
trigger_mode=payload.trigger_mode or "scheduled",
|
||||
after_completion_days=payload.after_completion_days,
|
||||
after_completion_unit=(payload.after_completion_unit or "days")
|
||||
if payload.trigger_mode == "after_completion"
|
||||
else None,
|
||||
)
|
||||
)
|
||||
audit(db, user.id, "create", "task", task.id)
|
||||
|
||||
@@ -154,6 +154,7 @@ class RecurrenceTemplate(Base):
|
||||
ends_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
|
||||
trigger_mode: Mapped[str] = mapped_column(String(32), default="scheduled")
|
||||
after_completion_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
after_completion_unit: Mapped[str | None] = mapped_column(String(8), nullable=True)
|
||||
last_completed_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow)
|
||||
|
||||
|
||||
+30
-4
@@ -251,17 +251,22 @@ class RecurrenceCreate(BaseModel):
|
||||
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)
|
||||
after_completion_unit: str | None = Field(default=None, pattern="^(days|months)$")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_mode(self):
|
||||
if self.trigger_mode == "scheduled" and (
|
||||
self.rrule is None or self.after_completion_days is not None
|
||||
self.rrule is None
|
||||
or self.after_completion_days is not None
|
||||
or self.after_completion_unit is not None
|
||||
):
|
||||
raise ValueError("scheduled recurrence requires rrule and no completion interval")
|
||||
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")
|
||||
if self.trigger_mode == "after_completion" and self.after_completion_unit is None:
|
||||
self.after_completion_unit = "days"
|
||||
return self
|
||||
|
||||
|
||||
@@ -271,6 +276,7 @@ class RecurrenceChange(BaseModel):
|
||||
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)
|
||||
after_completion_unit: str | None = Field(default=None, pattern="^(days|months)$")
|
||||
|
||||
|
||||
class OccurrenceComplete(BaseModel):
|
||||
@@ -430,6 +436,7 @@ async def get_task_recurrence(task_id: UUID, user: User = Depends(current_user),
|
||||
"ends_at": row.ends_at,
|
||||
"trigger_mode": row.trigger_mode,
|
||||
"after_completion_days": row.after_completion_days,
|
||||
"after_completion_unit": row.after_completion_unit,
|
||||
"last_completed_at": row.last_completed_at,
|
||||
}
|
||||
|
||||
@@ -452,6 +459,9 @@ async def create_recurrence(payload: RecurrenceCreate, user: User = Depends(curr
|
||||
starts_at=task.due_at,
|
||||
trigger_mode=payload.trigger_mode,
|
||||
after_completion_days=payload.after_completion_days,
|
||||
after_completion_unit=(payload.after_completion_unit or "days")
|
||||
if payload.trigger_mode == "after_completion"
|
||||
else None,
|
||||
)
|
||||
db.add(row)
|
||||
await db.commit(); await db.refresh(row)
|
||||
@@ -463,6 +473,7 @@ async def create_recurrence(payload: RecurrenceCreate, user: User = Depends(curr
|
||||
"ends_at": row.ends_at,
|
||||
"trigger_mode": row.trigger_mode,
|
||||
"after_completion_days": row.after_completion_days,
|
||||
"after_completion_unit": row.after_completion_unit,
|
||||
"last_completed_at": row.last_completed_at,
|
||||
}
|
||||
|
||||
@@ -504,12 +515,18 @@ async def edit_recurrence(recurrence_id: UUID, payload: RecurrenceChange, scope:
|
||||
if "after_completion_days" in payload.model_fields_set
|
||||
else template.after_completion_days
|
||||
)
|
||||
requested_unit = (
|
||||
payload.after_completion_unit
|
||||
if "after_completion_unit" in payload.model_fields_set
|
||||
else template.after_completion_unit
|
||||
)
|
||||
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.after_completion_unit = requested_unit or "days"
|
||||
template.rrule = None
|
||||
else:
|
||||
if requested_rrule is None:
|
||||
@@ -517,6 +534,7 @@ async def edit_recurrence(recurrence_id: UUID, payload: RecurrenceChange, scope:
|
||||
parse_rrule(requested_rrule)
|
||||
template.trigger_mode = requested_mode
|
||||
template.after_completion_days = None
|
||||
template.after_completion_unit = None
|
||||
template.rrule = requested_rrule.upper()
|
||||
if payload.title is not None:
|
||||
task.title = payload.title
|
||||
@@ -533,6 +551,7 @@ async def edit_recurrence(recurrence_id: UUID, payload: RecurrenceChange, scope:
|
||||
"ends_at": template.ends_at,
|
||||
"trigger_mode": template.trigger_mode,
|
||||
"after_completion_days": template.after_completion_days,
|
||||
"after_completion_unit": template.after_completion_unit,
|
||||
"last_completed_at": template.last_completed_at,
|
||||
}
|
||||
|
||||
@@ -1358,7 +1377,7 @@ def _export_payload(folders, lists, tasks, recurrences, habits, countdowns, memo
|
||||
"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", "completed_at", "due_at", "due_has_time", "external_id", "deleted_at"]) for x in tasks],
|
||||
"recurrences": [serialize(x, ["id", "task_id", "rrule", "starts_at", "ends_at", "trigger_mode", "after_completion_days", "last_completed_at"]) for x in recurrences],
|
||||
"recurrences": [serialize(x, ["id", "task_id", "rrule", "starts_at", "ends_at", "trigger_mode", "after_completion_days", "after_completion_unit", "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],
|
||||
"memos": [serialize(x, ["id", "title", "content", "version", "created_at", "updated_at", "deleted_at"]) for x in memos],
|
||||
@@ -1570,11 +1589,17 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg
|
||||
rrule = raw.get("rrule")
|
||||
if trigger_mode not in {"scheduled", "after_completion"}:
|
||||
raise HTTPException(422, "无效的重复触发模式")
|
||||
unit = raw.get("after_completion_unit")
|
||||
if unit is not None and unit not in {"days", "months"}:
|
||||
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")
|
||||
unit = unit or "days"
|
||||
else:
|
||||
if not isinstance(rrule, str) or days is not None or unit is not None:
|
||||
raise HTTPException(422, "定期重复备份包含无效字段")
|
||||
unit = None
|
||||
db.add(RecurrenceTemplate(
|
||||
user_id=user.id,
|
||||
task_id=task_id,
|
||||
@@ -1583,6 +1608,7 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg
|
||||
ends_at=datetime.fromisoformat(raw["ends_at"]) if raw.get("ends_at") else None,
|
||||
trigger_mode=trigger_mode,
|
||||
after_completion_days=days,
|
||||
after_completion_unit=unit,
|
||||
last_completed_at=datetime.fromisoformat(raw["last_completed_at"])
|
||||
if raw.get("last_completed_at") else None,
|
||||
))
|
||||
|
||||
@@ -2,6 +2,7 @@ from datetime import UTC, datetime, time, timedelta
|
||||
from uuid import UUID
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -25,10 +26,15 @@ def _user_zone(user: User) -> ZoneInfo:
|
||||
raise HTTPException(422, "用户时区无效") from exc
|
||||
|
||||
|
||||
def _after_completion_due(task: Task, completed_at: datetime, days: int, user: User) -> datetime:
|
||||
def _after_completion_due(
|
||||
task: Task, completed_at: datetime, value: int, unit: str | None, user: User
|
||||
) -> datetime:
|
||||
zone = _user_zone(user)
|
||||
completed_local = completed_at.astimezone(zone)
|
||||
target_date = completed_local.date() + timedelta(days=days)
|
||||
if unit == "months":
|
||||
target_date = completed_local.date() + relativedelta(months=value)
|
||||
else:
|
||||
target_date = completed_local.date() + timedelta(days=value)
|
||||
if task.due_has_time:
|
||||
due_local = task.due_at.astimezone(zone)
|
||||
wall_time = due_local.timetz().replace(tzinfo=None)
|
||||
@@ -67,7 +73,11 @@ async def apply_task_changes(
|
||||
if recurrence.trigger_mode == "after_completion":
|
||||
completed_at = utcnow()
|
||||
next_due = _after_completion_due(
|
||||
task, completed_at, recurrence.after_completion_days, user
|
||||
task,
|
||||
completed_at,
|
||||
recurrence.after_completion_days,
|
||||
recurrence.after_completion_unit,
|
||||
user,
|
||||
)
|
||||
changes["completed"] = False
|
||||
changes["due_at"] = next_due
|
||||
|
||||
+16
-5
@@ -125,6 +125,7 @@ class TaskCreate(BaseModel):
|
||||
rrule: str | None = Field(default=None, min_length=5, max_length=1000)
|
||||
trigger_mode: str | None = Field(default=None, pattern="^(scheduled|after_completion)$")
|
||||
after_completion_days: int | None = Field(default=None, ge=1, le=3650)
|
||||
after_completion_unit: str | None = Field(default=None, pattern="^(days|months)$")
|
||||
|
||||
@field_validator("title")
|
||||
@classmethod
|
||||
@@ -136,7 +137,12 @@ class TaskCreate(BaseModel):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_recurrence(self):
|
||||
has_recurrence = self.rrule is not None or self.trigger_mode is not None or self.after_completion_days is not None
|
||||
has_recurrence = (
|
||||
self.rrule is not None
|
||||
or self.trigger_mode is not None
|
||||
or self.after_completion_days is not None
|
||||
or self.after_completion_unit is not None
|
||||
)
|
||||
if has_recurrence and self.due_at is None:
|
||||
raise ValueError("recurrence requires due_at")
|
||||
if has_recurrence and self.parent_id is not None:
|
||||
@@ -144,10 +150,15 @@ class TaskCreate(BaseModel):
|
||||
if self.trigger_mode == "after_completion":
|
||||
if self.after_completion_days is None or self.rrule is not None:
|
||||
raise ValueError("after_completion requires days and no rrule")
|
||||
elif self.trigger_mode == "scheduled" and self.rrule is None:
|
||||
raise ValueError("scheduled recurrence requires rrule")
|
||||
elif self.trigger_mode is None and self.after_completion_days is not None:
|
||||
raise ValueError("after_completion_days requires after_completion mode")
|
||||
elif self.trigger_mode == "scheduled":
|
||||
if (
|
||||
self.rrule is None
|
||||
or self.after_completion_days is not None
|
||||
or self.after_completion_unit is not None
|
||||
):
|
||||
raise ValueError("scheduled recurrence requires rrule and no completion interval")
|
||||
elif self.after_completion_days is not None or self.after_completion_unit is not None:
|
||||
raise ValueError("completion interval requires after_completion mode")
|
||||
return self
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user