feat(events): recurring expansion, CalDAV pull sync, past search, reminders
- RRULE expansion: list_events now expands recurring events into individual occurrences within the query window using python-dateutil - CalDAV pull sync: new caldav_sync.py + POST /api/events/sync route; imports remote events into the internal store by caldav_uid - Past event search: search_events accepts include_past=true to search historical events; exposed in the LLM tool definition - Internal reminders: migration 0037 adds reminder_minutes + reminder_sent_at columns; event_scheduler.py checks every 5 min and fires push notifications; CalDAV sync job runs hourly - reminder_minutes now stored and returned in create/update routes + tools Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from dateutil.rrule import rrulestr
|
||||
from sqlalchemy import or_, select
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
@@ -25,9 +26,9 @@ async def create_event(
|
||||
color: str = "",
|
||||
recurrence: str | None = None,
|
||||
project_id: int | None = None,
|
||||
reminder_minutes: int | None = None,
|
||||
# CalDAV-only fields (not stored in DB, forwarded to push)
|
||||
duration: int | None = None,
|
||||
reminder_minutes: int | None = None,
|
||||
attendees: list[str] | None = None,
|
||||
calendar_name: str | None = None,
|
||||
) -> Event:
|
||||
@@ -46,6 +47,7 @@ async def create_event(
|
||||
color=color,
|
||||
recurrence=recurrence,
|
||||
project_id=project_id,
|
||||
reminder_minutes=reminder_minutes,
|
||||
)
|
||||
session.add(event)
|
||||
await session.commit()
|
||||
@@ -74,48 +76,87 @@ async def list_events(
|
||||
user_id: int,
|
||||
date_from: datetime,
|
||||
date_to: datetime,
|
||||
) -> list[Event]:
|
||||
) -> list[dict]:
|
||||
"""List events for user_id that overlap [date_from, date_to].
|
||||
|
||||
An event overlaps the range if it starts before date_to AND either
|
||||
has no end_dt (point event) or its end_dt is after date_from.
|
||||
This ensures multi-day events that span the query boundary are included.
|
||||
Recurring events (with an RRULE recurrence string) are expanded into
|
||||
individual occurrences within the range. Non-recurring events are
|
||||
returned as-is. All results are sorted by start time and returned as
|
||||
dicts (same shape as Event.to_dict()).
|
||||
"""
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Event).where(
|
||||
Event.user_id == user_id,
|
||||
Event.start_dt <= date_to,
|
||||
# Base window: non-recurring events must overlap range;
|
||||
# recurring events always need to be fetched so they can be expanded.
|
||||
or_(
|
||||
Event.recurrence.isnot(None),
|
||||
Event.start_dt <= date_to,
|
||||
),
|
||||
or_(
|
||||
Event.end_dt.is_(None),
|
||||
Event.end_dt >= date_from,
|
||||
Event.recurrence.isnot(None),
|
||||
),
|
||||
).order_by(Event.start_dt)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
events = list(result.scalars().all())
|
||||
|
||||
items: list[dict] = []
|
||||
for event in events:
|
||||
if not event.recurrence:
|
||||
items.append(event.to_dict())
|
||||
continue
|
||||
|
||||
# Expand recurring event occurrences within [date_from, date_to]
|
||||
duration = (event.end_dt - event.start_dt) if event.end_dt else None
|
||||
try:
|
||||
rule = rrulestr(event.recurrence, dtstart=event.start_dt, ignoretz=False)
|
||||
occurrences = rule.between(date_from, date_to, inc=True)
|
||||
except Exception:
|
||||
logger.warning("Failed to expand RRULE for event %d: %r", event.id, event.recurrence)
|
||||
items.append(event.to_dict())
|
||||
continue
|
||||
|
||||
base = event.to_dict()
|
||||
for occ in occurrences:
|
||||
# Ensure occurrence is UTC-aware
|
||||
if occ.tzinfo is None:
|
||||
occ = occ.replace(tzinfo=timezone.utc)
|
||||
occurrence_dict = dict(base)
|
||||
occurrence_dict["start_dt"] = occ.isoformat()
|
||||
if duration is not None:
|
||||
occurrence_dict["end_dt"] = (occ + duration).isoformat()
|
||||
items.append(occurrence_dict)
|
||||
|
||||
items.sort(key=lambda x: x["start_dt"])
|
||||
return items
|
||||
|
||||
|
||||
async def search_events(
|
||||
user_id: int,
|
||||
query: str,
|
||||
days_ahead: int = 90,
|
||||
include_past: bool = False,
|
||||
) -> list[Event]:
|
||||
"""Search events by keyword in title, description, or location."""
|
||||
now = datetime.now(timezone.utc)
|
||||
date_to = now + timedelta(days=days_ahead)
|
||||
q = f"%{query}%"
|
||||
async with async_session() as session:
|
||||
where = [
|
||||
Event.user_id == user_id,
|
||||
or_(
|
||||
Event.title.ilike(q),
|
||||
Event.description.ilike(q),
|
||||
Event.location.ilike(q),
|
||||
),
|
||||
]
|
||||
if not include_past:
|
||||
date_to = now + timedelta(days=days_ahead)
|
||||
where.extend([Event.start_dt >= now, Event.start_dt <= date_to])
|
||||
result = await session.execute(
|
||||
select(Event).where(
|
||||
Event.user_id == user_id,
|
||||
Event.start_dt >= now,
|
||||
Event.start_dt <= date_to,
|
||||
or_(
|
||||
Event.title.ilike(q),
|
||||
Event.description.ilike(q),
|
||||
Event.location.ilike(q),
|
||||
),
|
||||
).order_by(Event.start_dt)
|
||||
select(Event).where(*where).order_by(Event.start_dt)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
@@ -131,9 +172,9 @@ async def update_event(user_id: int, event_id: int, **fields) -> Event | None:
|
||||
return None
|
||||
old_title = event.title # capture before mutation for CalDAV lookup
|
||||
allowed = {"title", "start_dt", "end_dt", "all_day", "description",
|
||||
"location", "color", "recurrence", "project_id"}
|
||||
"location", "color", "recurrence", "project_id", "reminder_minutes"}
|
||||
# Nullable fields that callers can explicitly set to None to clear
|
||||
nullable = {"end_dt", "recurrence", "project_id"}
|
||||
nullable = {"end_dt", "recurrence", "project_id", "reminder_minutes"}
|
||||
for key, value in fields.items():
|
||||
if key in allowed and (value is not None or key in nullable):
|
||||
setattr(event, key, value)
|
||||
|
||||
Reference in New Issue
Block a user