[verified] feat: support monthly completion recurrence
ci / gitleaks (push) Successful in 23s
ci / docker (push) Successful in 7m16s

This commit is contained in:
2026-09-20 21:04:31 +08:00
parent e6d720b85c
commit 3155155f53
13 changed files with 193 additions and 39 deletions
+5
View File
@@ -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
View File
@@ -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)
+1
View File
@@ -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
View File
@@ -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,
))
+13 -3
View File
@@ -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
View File
@@ -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