fix: DST-aware calendar and ghost recurrence guard
ci / docker (push) Successful in 6m8s

This commit is contained in:
2026-09-05 20:47:07 +08:00
parent 27432fa2e2
commit 32d6fe9424
3 changed files with 127 additions and 16 deletions
+92 -13
View File
@@ -4,6 +4,7 @@ import re
from datetime import UTC, date, datetime, time, timedelta from datetime import UTC, date, datetime, time, timedelta
from pathlib import Path from pathlib import Path
from uuid import UUID from uuid import UUID
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
@@ -120,6 +121,69 @@ def occurrences(rule: str, starts: datetime, start: datetime, end: datetime, cut
return result return result
def is_occurrence(rule: str, starts: datetime, at: datetime) -> bool:
"""Return True when `at` is an occurrence this rule actually generates."""
parts = parse_rrule(rule)
interval = int(parts.get("INTERVAL", 1))
if starts.tzinfo is None:
starts = starts.replace(tzinfo=UTC)
if at.tzinfo is None:
at = at.replace(tzinfo=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
if at > until:
return False
if "COUNT" in parts:
count = int(parts["COUNT"])
match = 0
cursor = starts
guard = 0
while cursor <= at and guard < 40000:
if parts["FREQ"] == "DAILY":
include = (cursor.date() - starts.date()).days % interval == 0
elif parts["FREQ"] == "WEEKLY":
days = {_WEEKDAYS[x] for x in parts.get("BYDAY", list(_WEEKDAYS)[starts.weekday()]).split(",")}
include = cursor.weekday() in days and ((cursor.date() - starts.date()).days // 7) % interval == 0
elif parts["FREQ"] == "MONTHLY":
month_delta = (cursor.year - starts.year) * 12 + cursor.month - starts.month
month_days = {int(x) for x in parts.get("BYMONTHDAY", str(starts.day)).split(",")}
include = month_delta % interval == 0 and cursor.day in month_days
else:
years = cursor.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(",")}
include = years % interval == 0 and cursor.month in months and cursor.day in month_days
if include and cursor >= starts:
match += 1
if cursor == at:
return True
if match >= count:
return False
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
def is_occurrence_utc(rule: str, starts: datetime, at: datetime) -> bool:
return is_occurrence(rule, starts, at)
async def owned_task(db, user_id, task_id): async def owned_task(db, user_id, task_id):
task = await db.scalar(select(Task).where(Task.id == task_id, Task.user_id == user_id, Task.deleted_at.is_(None))) task = await db.scalar(select(Task).where(Task.id == task_id, Task.user_id == user_id, Task.deleted_at.is_(None)))
if not task: if not task:
@@ -134,6 +198,12 @@ async def owned_recurrence(db, user_id, recurrence_id):
return row return row
def ensure_real_occurrence(template: RecurrenceTemplate, occurrence_at: datetime) -> None:
"""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 不是该重复规则的有效发生时刻")
@router.post("/recurrences", status_code=201) @router.post("/recurrences", status_code=201)
async def create_recurrence(payload: RecurrenceCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): 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) task = await owned_task(db, user.id, payload.task_id)
@@ -149,12 +219,15 @@ async def create_recurrence(payload: RecurrenceCreate, user: User = Depends(curr
@router.get("/calendar") @router.get("/calendar")
async def calendar(start: date, end: date, timezone_offset: int = Query(default=0, ge=-840, le=840), 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-z_+-]+(/[A-Za-z_+-]+)*$"), user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
if end < start or (end - start).days > 366: if end < start or (end - start).days > 366:
raise HTTPException(422, "日期范围无效或超过一年") raise HTTPException(422, "日期范围无效或超过一年")
offset = timedelta(minutes=timezone_offset) try:
start_dt = datetime.combine(start, time.min, tzinfo=UTC) - offset zone = ZoneInfo(timezone)
end_dt = datetime.combine(end + timedelta(days=1), time.min, tzinfo=UTC) - offset - timedelta(microseconds=1) except ZoneInfoNotFoundError:
raise HTTPException(422, "未知时区")
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)
recurrence_rows = (await db.execute(select(RecurrenceTemplate, Task).join(Task).where(RecurrenceTemplate.user_id == user.id, Task.deleted_at.is_(None)))).all() 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] 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()) exception_rows = [] if not template_ids else list((await db.scalars(select(RecurrenceException).where(RecurrenceException.template_id.in_(template_ids)))).all())
@@ -189,23 +262,24 @@ async def calendar(start: date, end: date, timezone_offset: int = Query(default=
} for task in normal_tasks] } for task in normal_tasks]
for template, task in recurrence_rows: for template, task in recurrence_rows:
exceptions = exceptions_by_template.get(template.id, {}) exceptions = exceptions_by_template.get(template.id, {})
generation_start = min(start_dt, *(exceptions.keys() or [start_dt])) # Render events that this recurrence generates: real occurrences
generated = occurrences(template.rrule, template.starts_at, generation_start, end_dt, template.ends_at) # whose displayed (possibly moved) due_at falls inside the window.
occurrence_keys = {as_utc(at) for at in generated} for at in occurrences(template.rrule, template.starts_at, template.starts_at, end_dt, template.ends_at):
for at in generated: exception = exceptions.get(as_utc(at))
exception = exceptions.get(at)
if exception and exception.deleted: if exception and exception.deleted:
continue continue
due_at = as_utc(exception.due_at) if exception and exception.due_at else at due_at = as_utc(exception.due_at) if exception and exception.due_at else at
if due_at < start_dt or due_at > end_dt: if due_at < start_dt or due_at > end_dt:
continue 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)}) 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.
for occurrence_at, exception in exceptions.items(): for occurrence_at, exception in exceptions.items():
if occurrence_at in occurrence_keys or exception.deleted or exception.due_at is None: if exception.deleted or exception.due_at is None:
continue continue
due_at = as_utc(exception.due_at) if as_utc(occurrence_at) >= as_utc(template.starts_at) and start_dt <= as_utc(exception.due_at) <= end_dt:
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": as_utc(exception.due_at), "completed": bool(exception.completed)})
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": 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"]) return sorted(output, key=lambda item: item["occurrence_at"].replace(tzinfo=UTC) if item["occurrence_at"].tzinfo is None else item["occurrence_at"])
@@ -224,11 +298,13 @@ async def edit_recurrence(recurrence_id: UUID, payload: RecurrenceChange, scope:
task = await owned_task(db, user.id, template.task_id) task = await owned_task(db, user.id, template.task_id)
if scope == "this": if scope == "this":
if not occurrence_at: raise HTTPException(422, "需要 occurrence_at") if not occurrence_at: raise HTTPException(422, "需要 occurrence_at")
ensure_real_occurrence(template, occurrence_at)
row = await upsert_exception(db, template.id, occurrence_at) row = await upsert_exception(db, template.id, occurrence_at)
if payload.title is not None: row.title = payload.title if payload.title is not None: row.title = payload.title
if payload.due_at is not None: row.due_at = payload.due_at if payload.due_at is not None: row.due_at = payload.due_at
elif scope == "this-and-future": elif scope == "this-and-future":
if not occurrence_at: raise HTTPException(422, "需要 occurrence_at") if not occurrence_at: raise HTTPException(422, "需要 occurrence_at")
ensure_real_occurrence(template, occurrence_at)
template.ends_at = occurrence_at - timedelta(microseconds=1) template.ends_at = occurrence_at - timedelta(microseconds=1)
if payload.rrule: if payload.rrule:
parse_rrule(payload.rrule) parse_rrule(payload.rrule)
@@ -246,6 +322,7 @@ async def edit_recurrence(recurrence_id: UUID, payload: RecurrenceChange, scope:
@router.post("/recurrences/{recurrence_id}/complete") @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)): 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) template = await owned_recurrence(db, user.id, recurrence_id)
ensure_real_occurrence(template, payload.occurrence_at)
row = await upsert_exception(db, template.id, payload.occurrence_at); row.completed = True 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()) audit(db, user.id, "complete", "task", template.task_id, occurrence_at=payload.occurrence_at.isoformat())
await db.commit(); return {"completed": True} await db.commit(); return {"completed": True}
@@ -256,9 +333,11 @@ async def delete_recurrence(recurrence_id: UUID, scope: str = Query("all", patte
template = await owned_recurrence(db, user.id, recurrence_id) template = await owned_recurrence(db, user.id, recurrence_id)
if scope == "this": if scope == "this":
if not occurrence_at: raise HTTPException(422, "需要 occurrence_at") if not occurrence_at: raise HTTPException(422, "需要 occurrence_at")
ensure_real_occurrence(template, occurrence_at)
row = await upsert_exception(db, template.id, occurrence_at); row.deleted = True row = await upsert_exception(db, template.id, occurrence_at); row.deleted = True
elif scope == "this-and-future": elif scope == "this-and-future":
if not occurrence_at: raise HTTPException(422, "需要 occurrence_at") if not occurrence_at: raise HTTPException(422, "需要 occurrence_at")
ensure_real_occurrence(template, occurrence_at)
template.ends_at = occurrence_at - timedelta(microseconds=1) template.ends_at = occurrence_at - timedelta(microseconds=1)
else: await db.delete(template) else: await db.delete(template)
await db.commit(); return Response(status_code=204) await db.commit(); return Response(status_code=204)
+2 -2
View File
@@ -65,8 +65,8 @@ async function loadCalendar(range?: { startStr?: string; endStr?: string }) {
endExclusive.setDate(endExclusive.getDate() - 1) endExclusive.setDate(endExclusive.getDate() - 1)
const end = dateKey(endExclusive) const end = dateKey(endExclusive)
await safe(async()=>{ await safe(async()=>{
const tzOffset = -new Date().getTimezoneOffset() const tzName = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
const rows = await request(`/calendar?start=${start}&end=${end}&timezone_offset=${tzOffset}`) as Array<{id?:string;task_id:string;recurrence_id?:string|null;title:string;due_at?:string;occurrence_at:string;version?:number}> const rows = await request(`/calendar?start=${start}&end=${end}&timezone=${encodeURIComponent(tzName)}`) as Array<{id?:string;task_id:string;recurrence_id?:string|null;title:string;due_at?:string;occurrence_at:string;version?:number}>
calendarTasks.value = rows.map(row=>({ calendarTasks.value = rows.map(row=>({
id: row.recurrence_id ? `${row.task_id}:${row.occurrence_at}` : (row.id ?? row.task_id), id: row.recurrence_id ? `${row.task_id}:${row.occurrence_at}` : (row.id ?? row.task_id),
title: row.title, title: row.title,
+33 -1
View File
@@ -114,7 +114,7 @@ def test_calendar_respects_timezone_boundaries_and_moved_occurrences(client):
calendar = client.get( calendar = client.get(
"/api/v1/calendar", "/api/v1/calendar",
params={"start": "2026-09-01", "end": "2026-09-30", "timezone_offset": 480}, params={"start": "2026-09-01", "end": "2026-09-30", "timezone": "Asia/Shanghai"},
).json() ).json()
assert any(row.get("id") == inside["id"] for row in calendar) assert any(row.get("id") == inside["id"] for row in calendar)
@@ -122,6 +122,38 @@ def test_calendar_respects_timezone_boundaries_and_moved_occurrences(client):
assert any(row.get("recurrence_id") == recurrence["id"] and row["due_at"].startswith("2026-09-20") for row in calendar) assert any(row.get("recurrence_id") == recurrence["id"] and row["due_at"].startswith("2026-09-20") for row in calendar)
def test_calendar_rejects_unknown_timezone(client):
boot(client)
response = client.get("/api/v1/calendar", params={"start": "2026-09-01", "end": "2026-09-30", "timezone": "Mars/Olympus"})
assert response.status_code == 422
def test_recurrence_mutations_reject_ghost_occurrences(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;COUNT=3"}
).json()
# 9/7 and 9/14 are real occurrences; 9/9 is NOT part of this series.
ghost_patch = client.patch(
f"/api/v1/recurrences/{recurrence['id']}",
params={"scope": "this", "occurrence_at": "2026-09-09T09:00:00Z"},
json={"due_at": "2026-09-20T09:00:00Z"},
)
assert ghost_patch.status_code == 422
calendar = client.get(
"/api/v1/calendar",
params={"start": "2026-09-01", "end": "2026-09-30", "timezone": "UTC"},
).json()
assert not any(row.get("recurrence_id") == recurrence["id"] and row["due_at"].startswith("2026-09-20") for row in calendar)
assert len([row for row in calendar if row.get("recurrence_id") == recurrence["id"]]) == 3
def test_habit_logs_support_date_range_filter(client): def test_habit_logs_support_date_range_filter(client):
boot(client) boot(client)
habit = client.post("/api/v1/habits", json={"name": "跑步", "kind": "boolean", "schedule_type": "daily"}).json() habit = client.post("/api/v1/habits", json={"name": "跑步", "kind": "boolean", "schedule_type": "daily"}).json()