fix: recurrence ghost exactness and calendar dedup
ci / docker (push) Successful in 3m38s

This commit is contained in:
2026-09-05 21:04:56 +08:00
parent 32d6fe9424
commit 7ab5896dc0
2 changed files with 168 additions and 30 deletions
+53 -29
View File
@@ -122,18 +122,22 @@ def occurrences(rule: str, starts: datetime, start: datetime, end: datetime, cut
def is_occurrence(rule: str, starts: datetime, at: datetime) -> bool:
"""Return True when `at` is an occurrence this rule actually generates."""
"""Return True when `at` is the exact timestamp of an occurrence this rule generates."""
parts = parse_rrule(rule)
interval = int(parts.get("INTERVAL", 1))
if starts.tzinfo is None:
starts = starts.replace(tzinfo=UTC)
else:
starts = starts.astimezone(UTC)
if at.tzinfo is None:
at = at.replace(tzinfo=UTC)
else:
at = at.astimezone(UTC)
if at < starts:
return False
if "UNTIL" in parts:
until = datetime.fromisoformat(parts["UNTIL"])
until = until.replace(tzinfo=UTC) if until.tzinfo is None else until
until = until.replace(tzinfo=UTC) if until.tzinfo is None else until.astimezone(UTC)
if at > until:
return False
if "COUNT" in parts:
@@ -165,19 +169,11 @@ def is_occurrence(rule: str, starts: datetime, at: datetime) -> bool:
guard += 1
cursor += timedelta(days=1)
return False
if parts["FREQ"] == "DAILY":
return (at.date() - starts.date()).days % interval == 0
if parts["FREQ"] == "WEEKLY":
days = {_WEEKDAYS[x] for x in parts.get("BYDAY", list(_WEEKDAYS)[starts.weekday()]).split(",")}
return at.weekday() in days and ((at.date() - starts.date()).days // 7) % interval == 0
if parts["FREQ"] == "MONTHLY":
month_delta = (at.year - starts.year) * 12 + at.month - starts.month
month_days = {int(x) for x in parts.get("BYMONTHDAY", str(starts.day)).split(",")}
return month_delta % interval == 0 and at.day in month_days
years = at.year - starts.year
months = {int(x) for x in parts.get("BYMONTH", str(starts.month)).split(",")}
month_days = {int(x) for x in parts.get("BYMONTHDAY", str(starts.day)).split(",")}
return years % interval == 0 and at.month in months and at.day in month_days
# Exact-match validation via the same canonical day generator used for
# rendering, so time-of-day is preserved and nothing outside the rule is
# accepted as a valid occurrence.
candidates = occurrences(rule, starts, starts, at, None)
return at in candidates
def is_occurrence_utc(rule: str, starts: datetime, at: datetime) -> bool:
@@ -202,6 +198,11 @@ def ensure_real_occurrence(template: RecurrenceTemplate, occurrence_at: datetime
"""Reject occurrence_at values that are not valid occurrences of this recurrence rule."""
if not is_occurrence(template.rrule, template.starts_at, occurrence_at):
raise HTTPException(422, "occurrence_at 不是该重复规则的有效发生时刻")
if template.ends_at:
at = occurrence_at.replace(tzinfo=UTC) if occurrence_at.tzinfo is None else occurrence_at.astimezone(UTC)
ends = template.ends_at.replace(tzinfo=UTC) if template.ends_at.tzinfo is None else template.ends_at.astimezone(UTC)
if at > ends:
raise HTTPException(422, "occurrence_at 晚于该重复规则的有效截止时间")
@router.post("/recurrences", status_code=201)
@@ -219,26 +220,35 @@ async def create_recurrence(payload: RecurrenceCreate, user: User = Depends(curr
@router.get("/calendar")
async def calendar(start: date, end: date, timezone: str = Query(default="UTC", pattern=r"^[A-Za-z_+-]+(/[A-Za-z_+-]+)*$"), user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
async def calendar(start: date, end: date, timezone: str = Query(default="UTC", pattern=r"^[A-Za-z0-9_+\-]+(/[A-Za-z0-9_+\-]+)*$"), timezone_offset: int | None = Query(default=None, ge=-840, le=840), user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
if end < start or (end - start).days > 366:
raise HTTPException(422, "日期范围无效或超过一年")
try:
zone = ZoneInfo(timezone)
except ZoneInfoNotFoundError:
raise HTTPException(422, "未知时区")
if timezone_offset is not None and timezone == "UTC":
# Legacy clients pass an offset instead of an IANA name.
zone = ZoneInfo("UTC")
start_dt = datetime.combine(start, time.min, tzinfo=UTC) - timedelta(minutes=timezone_offset)
end_dt = datetime.combine(end + timedelta(days=1), time.min, tzinfo=UTC) - timedelta(minutes=timezone_offset) - timedelta(microseconds=1)
else:
start_dt = datetime.combine(start, time.min, tzinfo=zone).astimezone(UTC)
end_dt = (datetime.combine(end + timedelta(days=1), time.min, tzinfo=zone)).astimezone(UTC) - timedelta(microseconds=1)
end_dt = datetime.combine(end + timedelta(days=1), time.min, tzinfo=zone).astimezone(UTC) - timedelta(microseconds=1)
def as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
recurrence_rows = (await db.execute(select(RecurrenceTemplate, Task).join(Task).where(RecurrenceTemplate.user_id == user.id, Task.deleted_at.is_(None)))).all()
template_ids = [template.id for template, _ in recurrence_rows]
exception_rows = [] if not template_ids else list((await db.scalars(select(RecurrenceException).where(RecurrenceException.template_id.in_(template_ids)))).all())
exceptions_by_template = {}
for exception in exception_rows:
key = exception.occurrence_at.replace(tzinfo=UTC) if exception.occurrence_at.tzinfo is None else exception.occurrence_at
key = as_utc(exception.occurrence_at)
exceptions_by_template.setdefault(exception.template_id, {})[key] = exception
def as_utc(value: datetime) -> datetime:
return value.replace(tzinfo=UTC) if value.tzinfo is None else value
recurring_task_ids = {task.id for _, task in recurrence_rows}
normal_query = select(Task).where(
Task.user_id == user.id,
@@ -260,11 +270,17 @@ async def calendar(start: date, end: date, timezone: str = Query(default="UTC",
"completed": task.completed,
"version": task.version,
} for task in normal_tasks]
emitted = set()
for template, task in recurrence_rows:
exceptions = exceptions_by_template.get(template.id, {})
# Render events that this recurrence generates: real occurrences
# whose displayed (possibly moved) due_at falls inside the window.
for at in occurrences(template.rrule, template.starts_at, template.starts_at, end_dt, template.ends_at):
# Canonical series from the template's true start so every exception
# whose occurrence is real and inside this series is considered.
series_start = template.starts_at
if exceptions:
first_exception = min(as_utc(at) for at in exceptions)
series_start = min(as_utc(series_start), first_exception)
generated = occurrences(template.rrule, template.starts_at, as_utc(series_start), end_dt, template.ends_at)
for at in generated:
exception = exceptions.get(as_utc(at))
if exception and exception.deleted:
continue
@@ -272,14 +288,22 @@ async def calendar(start: date, end: date, timezone: str = Query(default="UTC",
if due_at < start_dt or due_at > end_dt:
continue
output.append({"recurrence_id": template.id, "task_id": task.id, "occurrence_at": at, "title": exception.title if exception and exception.title else task.title, "due_at": due_at, "completed": bool(exception and exception.completed)})
# Occurrences moved far enough that their original slot falls before
# the start of generation: show them at their moved due_at. Ghost
# occurrence_at values are already rejected at write time.
emitted.add(as_utc(at))
# Fall back for stored exceptions whose original slot is real but which
# the canonical generation skipped only because their original date is
# outside the requested window (e.g. moved backwards across the month).
for occurrence_at, exception in exceptions.items():
if exception.deleted or exception.due_at is None:
continue
if as_utc(occurrence_at) >= as_utc(template.starts_at) and start_dt <= as_utc(exception.due_at) <= end_dt:
output.append({"recurrence_id": template.id, "task_id": task.id, "occurrence_at": occurrence_at, "title": exception.title or task.title, "due_at": as_utc(exception.due_at), "completed": bool(exception.completed)})
if occurrence_at in emitted:
continue
if not is_occurrence(template.rrule, template.starts_at, occurrence_at):
continue
if template.ends_at and occurrence_at > as_utc(template.ends_at):
continue
due_at = as_utc(exception.due_at)
if start_dt <= due_at <= end_dt:
output.append({"recurrence_id": template.id, "task_id": task.id, "occurrence_at": occurrence_at, "title": exception.title or task.title, "due_at": due_at, "completed": bool(exception.completed)})
return sorted(output, key=lambda item: item["occurrence_at"].replace(tzinfo=UTC) if item["occurrence_at"].tzinfo is None else item["occurrence_at"])
+114
View File
@@ -154,6 +154,120 @@ def test_recurrence_mutations_reject_ghost_occurrences(client):
assert len([row for row in calendar if row.get("recurrence_id") == recurrence["id"]]) == 3
def test_calendar_does_not_duplicate_moved_exceptions_when_both_in_range(client):
inbox = boot(client)
task = client.post(
"/api/v1/tasks",
json={"title": "每周一", "list_id": inbox["id"], "due_at": "2026-09-14T09:00:00Z"},
).json()
recurrence = client.post(
"/api/v1/recurrences", json={"task_id": task["id"], "rrule": "FREQ=WEEKLY;BYDAY=MO;COUNT=4"}
).json()
# Series: 9/14, 9/21, 9/28, 10/5. Move the 9/21 occurrence to 9/23.
client.patch(
f"/api/v1/recurrences/{recurrence['id']}",
params={"scope": "this", "occurrence_at": "2026-09-21T09:00:00Z"},
json={"due_at": "2026-09-23T09:00:00Z"},
)
calendar = client.get(
"/api/v1/calendar",
params={"start": "2026-09-01", "end": "2026-09-30", "timezone": "UTC"},
).json()
matches = [row for row in calendar if row.get("recurrence_id") == recurrence["id"]]
keys = [(row["occurrence_at"], row["due_at"]) for row in matches]
assert len(keys) == len(set(keys))
assert len(matches) == 3
due_days = [row["due_at"][:10] for row in matches]
assert due_days == ["2026-09-14", "2026-09-23", "2026-09-28"]
assert not any(row["due_at"].startswith("2026-09-21") for row in matches)
def test_recurrence_rejects_wrong_time_of_day_and_alternate_offset(client):
inbox = boot(client)
task = client.post(
"/api/v1/tasks",
json={"title": "九点站会", "list_id": inbox["id"], "due_at": "2026-09-07T09:00:00Z"},
).json()
recurrence = client.post(
"/api/v1/recurrences", json={"task_id": task["id"], "rrule": "FREQ=WEEKLY;BYDAY=MO;COUNT=3"}
).json()
wrong_hour = client.patch(
f"/api/v1/recurrences/{recurrence['id']}",
params={"scope": "this", "occurrence_at": "2026-09-07T10:00:00Z"},
json={"title": "幽灵"},
)
assert wrong_hour.status_code == 422
same_instant = client.patch(
f"/api/v1/recurrences/{recurrence['id']}",
params={"scope": "this", "occurrence_at": "2026-09-07T17:00:00+08:00"},
json={"title": "等价时刻"},
)
assert same_instant.status_code == 200
def test_recurrence_rejects_occurrence_after_cutoff(client):
inbox = boot(client)
task = client.post(
"/api/v1/tasks",
json={"title": "每天重复", "list_id": inbox["id"], "due_at": "2026-09-01T09:00:00Z"},
).json()
recurrence = client.post(
"/api/v1/recurrences", json={"task_id": task["id"], "rrule": "FREQ=DAILY;COUNT=10"}
).json()
# Cut the series off at 9/3 (ends_at = 9/3T08:59:59.999999Z)
client.patch(
f"/api/v1/recurrences/{recurrence['id']}",
params={"scope": "this-and-future", "occurrence_at": "2026-09-03T09:00:00Z"},
json={},
)
after_cutoff = client.patch(
f"/api/v1/recurrences/{recurrence['id']}",
params={"scope": "this", "occurrence_at": "2026-09-05T09:00:00Z"},
json={"title": "晚于截止"},
)
assert after_cutoff.status_code == 422
def test_calendar_accepts_legacy_timezone_offset(client):
inbox = boot(client)
client.post(
"/api/v1/tasks",
json={"title": "上海边界任务", "list_id": inbox["id"], "due_at": "2026-09-01T16:00:00Z"},
)
calendar = client.get(
"/api/v1/calendar",
params={"start": "2026-09-01", "end": "2026-09-30", "timezone_offset": 480},
).json()
assert any(row["title"] == "上海边界任务" for row in calendar)
def test_calendar_does_not_duplicate_moved_exception_when_original_and_moved_in_range(client):
inbox = boot(client)
task = client.post(
"/api/v1/tasks",
json={"title": "每周一", "list_id": inbox["id"], "due_at": "2026-09-14T09:00:00Z"},
).json()
recurrence = client.post(
"/api/v1/recurrences", json={"task_id": task["id"], "rrule": "FREQ=WEEKLY;BYDAY=MO;COUNT=3"}
).json()
# Series: 9/14, 9/21, 9/28 — all inside the requested month.
# Move the 9/21 occurrence to 9/23 (also inside). Regression: this used to render twice.
client.patch(
f"/api/v1/recurrences/{recurrence['id']}",
params={"scope": "this", "occurrence_at": "2026-09-21T09:00:00Z"},
json={"due_at": "2026-09-23T09:00:00Z"},
)
calendar = client.get(
"/api/v1/calendar",
params={"start": "2026-09-01", "end": "2026-09-30", "timezone": "UTC"},
).json()
matches = [row for row in calendar if row.get("recurrence_id") == recurrence["id"]]
due_days = sorted(row["due_at"][:10] for row in matches)
assert due_days == ["2026-09-14", "2026-09-23", "2026-09-28"]
def test_habit_logs_support_date_range_filter(client):
boot(client)
habit = client.post("/api/v1/habits", json={"name": "跑步", "kind": "boolean", "schedule_type": "daily"}).json()