This commit is contained in:
+92
-13
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user