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:
@@ -0,0 +1,192 @@
|
||||
"""CalDAV pull sync — imports remote events into the internal event store.
|
||||
|
||||
Runs as a scheduled job (hourly) and is also callable via the API.
|
||||
Only syncs events in a rolling 30-day-past / 180-day-future window.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.event import Event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SYNC_PAST_DAYS = 30
|
||||
_SYNC_FUTURE_DAYS = 180
|
||||
|
||||
|
||||
def _parse_dt(val: Any) -> datetime | None:
|
||||
"""Convert a date or datetime from an iCal component to a UTC-aware datetime."""
|
||||
if val is None:
|
||||
return None
|
||||
import datetime as _dt_mod
|
||||
if isinstance(val, _dt_mod.datetime):
|
||||
if val.tzinfo is None:
|
||||
return val.replace(tzinfo=timezone.utc)
|
||||
return val.astimezone(timezone.utc)
|
||||
if isinstance(val, _dt_mod.date):
|
||||
# All-day date: treat as midnight UTC
|
||||
return datetime(val.year, val.month, val.day, tzinfo=timezone.utc)
|
||||
return None
|
||||
|
||||
|
||||
def _sync_one_user(config: dict[str, str], user_id: int) -> list[dict]:
|
||||
"""Synchronous CalDAV fetch — runs in a thread executor."""
|
||||
import caldav # noqa: PLC0415
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
range_start = now - timedelta(days=_SYNC_PAST_DAYS)
|
||||
range_end = now + timedelta(days=_SYNC_FUTURE_DAYS)
|
||||
|
||||
client = caldav.DAVClient(
|
||||
url=config["caldav_url"],
|
||||
username=config.get("caldav_username") or None,
|
||||
password=config.get("caldav_password") or None,
|
||||
)
|
||||
principal = client.principal()
|
||||
calendars = principal.calendars()
|
||||
if not calendars:
|
||||
return []
|
||||
|
||||
cal_name = config.get("caldav_calendar_name", "")
|
||||
if cal_name:
|
||||
calendars = [c for c in calendars if c.name == cal_name] or calendars
|
||||
|
||||
events: list[dict] = []
|
||||
for calendar in calendars:
|
||||
try:
|
||||
results = calendar.date_search(start=range_start, end=range_end, expand=False)
|
||||
except Exception:
|
||||
logger.warning("CalDAV date_search failed for calendar %s", getattr(calendar, "name", "?"), exc_info=True)
|
||||
continue
|
||||
for vevent_obj in results:
|
||||
try:
|
||||
ical = vevent_obj.icalendar_instance
|
||||
for component in ical.walk():
|
||||
if component.name != "VEVENT":
|
||||
continue
|
||||
dtstart = component.get("DTSTART")
|
||||
dtend = component.get("DTEND")
|
||||
uid = str(component.get("UID", ""))
|
||||
if not uid:
|
||||
continue
|
||||
start_dt = _parse_dt(dtstart.dt if dtstart else None)
|
||||
end_dt = _parse_dt(dtend.dt if dtend else None)
|
||||
if start_dt is None:
|
||||
continue
|
||||
|
||||
import datetime as _dt_mod
|
||||
all_day = dtstart and isinstance(dtstart.dt, _dt_mod.date) and not isinstance(dtstart.dt, _dt_mod.datetime)
|
||||
|
||||
rrule = component.get("RRULE")
|
||||
recurrence = rrule.to_ical().decode("utf-8") if rrule else None
|
||||
|
||||
events.append({
|
||||
"caldav_uid": uid,
|
||||
"title": str(component.get("SUMMARY", "")),
|
||||
"start_dt": start_dt,
|
||||
"end_dt": end_dt,
|
||||
"all_day": bool(all_day),
|
||||
"description": str(component.get("DESCRIPTION", "")),
|
||||
"location": str(component.get("LOCATION", "")),
|
||||
"recurrence": recurrence,
|
||||
})
|
||||
except Exception:
|
||||
logger.debug("Failed to parse CalDAV event", exc_info=True)
|
||||
|
||||
return events
|
||||
|
||||
|
||||
async def sync_user_events(user_id: int) -> dict:
|
||||
"""Pull CalDAV events for one user and upsert into the DB.
|
||||
|
||||
Returns a summary dict: {created, updated, unchanged}.
|
||||
"""
|
||||
from fabledassistant.services.caldav import get_caldav_config, is_caldav_configured # noqa: PLC0415
|
||||
|
||||
if not await is_caldav_configured(user_id):
|
||||
return {"skipped": True, "reason": "CalDAV not configured"}
|
||||
|
||||
config = await get_caldav_config(user_id)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
remote_events: list[dict] = await loop.run_in_executor(
|
||||
None, _sync_one_user, config, user_id
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("CalDAV pull sync failed for user %d", user_id, exc_info=True)
|
||||
return {"error": "CalDAV fetch failed"}
|
||||
|
||||
created = updated = unchanged = 0
|
||||
|
||||
async with async_session() as session:
|
||||
for ev in remote_events:
|
||||
caldav_uid = ev["caldav_uid"]
|
||||
result = await session.execute(
|
||||
select(Event).where(
|
||||
Event.user_id == user_id,
|
||||
Event.caldav_uid == caldav_uid,
|
||||
)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing is None:
|
||||
# Create new event
|
||||
new_ev = Event(
|
||||
user_id=user_id,
|
||||
uid=str(uuid.uuid4()),
|
||||
caldav_uid=caldav_uid,
|
||||
title=ev["title"],
|
||||
start_dt=ev["start_dt"],
|
||||
end_dt=ev["end_dt"],
|
||||
all_day=ev["all_day"],
|
||||
description=ev["description"],
|
||||
location=ev["location"],
|
||||
recurrence=ev["recurrence"],
|
||||
)
|
||||
session.add(new_ev)
|
||||
created += 1
|
||||
else:
|
||||
# Update if anything changed
|
||||
changed = False
|
||||
for field in ("title", "start_dt", "end_dt", "all_day", "description", "location", "recurrence"):
|
||||
if getattr(existing, field) != ev[field]:
|
||||
setattr(existing, field, ev[field])
|
||||
changed = True
|
||||
if changed:
|
||||
updated += 1
|
||||
else:
|
||||
unchanged += 1
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
"CalDAV sync user %d: %d created, %d updated, %d unchanged",
|
||||
user_id, created, updated, unchanged,
|
||||
)
|
||||
return {"created": created, "updated": updated, "unchanged": unchanged}
|
||||
|
||||
|
||||
async def sync_all_users() -> None:
|
||||
"""Pull CalDAV events for all users with CalDAV configured."""
|
||||
from sqlalchemy import select as sa_select # noqa: PLC0415
|
||||
|
||||
from fabledassistant.models.user import User # noqa: PLC0415
|
||||
|
||||
async with async_session() as session:
|
||||
result = await session.execute(sa_select(User.id))
|
||||
user_ids = [row[0] for row in result.all()]
|
||||
|
||||
for user_id in user_ids:
|
||||
try:
|
||||
await sync_user_events(user_id)
|
||||
except Exception:
|
||||
logger.warning("CalDAV sync failed for user %d", user_id, exc_info=True)
|
||||
Reference in New Issue
Block a user