diff --git a/src/fabledassistant/services/events.py b/src/fabledassistant/services/events.py new file mode 100644 index 0000000..e399e75 --- /dev/null +++ b/src/fabledassistant/services/events.py @@ -0,0 +1,245 @@ +"""Internal event store service with CalDAV push sync.""" +from __future__ import annotations + +import asyncio +import logging +import uuid +from datetime import datetime, timedelta, timezone + +from sqlalchemy import or_, select + +from fabledassistant.models import async_session +from fabledassistant.models.event import Event + +logger = logging.getLogger(__name__) + + +async def create_event( + user_id: int, + title: str, + start_dt: datetime, + end_dt: datetime | None = None, + all_day: bool = False, + description: str = "", + location: str = "", + color: str = "", + recurrence: str | None = None, + project_id: 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: + """Create an event in the DB, then fire a CalDAV push task.""" + uid = str(uuid.uuid4()) + async with async_session() as session: + event = Event( + user_id=user_id, + uid=uid, + title=title, + start_dt=start_dt, + end_dt=end_dt, + all_day=all_day, + description=description, + location=location, + color=color, + recurrence=recurrence, + project_id=project_id, + ) + session.add(event) + await session.commit() + await session.refresh(event) + + extra_fields = { + "duration": duration, + "reminder_minutes": reminder_minutes, + "attendees": attendees, + "calendar_name": calendar_name, + } + asyncio.create_task(_push_create(event, user_id, extra_fields)) + return event + + +async def get_event(user_id: int, event_id: int) -> Event | None: + """Return event owned by user_id, or None.""" + async with async_session() as session: + result = await session.execute( + select(Event).where(Event.id == event_id, Event.user_id == user_id) + ) + return result.scalar_one_or_none() + + +async def list_events( + user_id: int, + date_from: datetime, + date_to: datetime, +) -> list[Event]: + """List events for user_id within [date_from, date_to].""" + async with async_session() as session: + result = await session.execute( + select(Event).where( + Event.user_id == user_id, + Event.start_dt >= date_from, + Event.start_dt <= date_to, + ).order_by(Event.start_dt) + ) + return result.scalars().all() + + +async def search_events( + user_id: int, + query: str, + days_ahead: int = 90, +) -> 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: + 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) + ) + return result.scalars().all() + + +async def update_event(user_id: int, event_id: int, **fields) -> Event | None: + """Partial update. Returns updated event or None if not found.""" + async with async_session() as session: + result = await session.execute( + select(Event).where(Event.id == event_id, Event.user_id == user_id) + ) + event = result.scalar_one_or_none() + if event is 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"} + for key, value in fields.items(): + if key in allowed and value is not None: + setattr(event, key, value) + await session.commit() + await session.refresh(event) + + asyncio.create_task(_push_update(event, user_id, old_title=old_title)) + return event + + +async def delete_event(user_id: int, event_id: int) -> None: + """Delete event. Fires CalDAV delete push if caldav_uid is set.""" + async with async_session() as session: + result = await session.execute( + select(Event).where(Event.id == event_id, Event.user_id == user_id) + ) + event = result.scalar_one_or_none() + if event is None: + return + caldav_uid = event.caldav_uid + event_title = event.title # needed to find the event on CalDAV by title + await session.delete(event) + await session.commit() + + if caldav_uid: + asyncio.create_task(_push_delete(caldav_uid, event_title, user_id)) + + +async def find_events_by_query(user_id: int, query: str) -> list[Event]: + """ILIKE search on title — used by AI update/delete tools.""" + q = f"%{query}%" + async with async_session() as session: + result = await session.execute( + select(Event).where( + Event.user_id == user_id, + Event.title.ilike(q), + ).order_by(Event.start_dt) + ) + return result.scalars().all() + + +# --------------------------------------------------------------------------- +# CalDAV push helpers (fire-and-forget) +# --------------------------------------------------------------------------- + +async def _push_create(event: Event, user_id: int, extra: dict) -> None: + try: + from fabledassistant.services.caldav import ( + create_event as caldav_create, + is_caldav_configured, + ) + if not await is_caldav_configured(user_id): + return + await caldav_create( + user_id=user_id, + title=event.title, + start=event.start_dt.isoformat(), + end=event.end_dt.isoformat() if event.end_dt else None, + description=event.description or None, + location=event.location or None, + all_day=event.all_day, + recurrence=event.recurrence, + uid=event.uid, + duration=extra.get("duration"), + reminder_minutes=extra.get("reminder_minutes"), + attendees=extra.get("attendees"), + calendar_name=extra.get("calendar_name"), + ) + # Mark as synced + async with async_session() as session: + result = await session.execute( + select(Event).where(Event.id == event.id) + ) + ev = result.scalar_one_or_none() + if ev: + ev.caldav_uid = event.uid + await session.commit() + except Exception: + logger.warning("CalDAV push (create) failed for event %d", event.id, exc_info=True) + + +async def _push_update(event: Event, user_id: int, old_title: str = "") -> None: + """Push an update to CalDAV. Uses old_title to locate the event by its pre-rename SUMMARY.""" + if not event.caldav_uid: + return + try: + from fabledassistant.services.caldav import ( + update_event as caldav_update, + is_caldav_configured, + ) + if not await is_caldav_configured(user_id): + return + # Use old_title so CalDAV can find the event even if the title was changed + query_title = old_title or event.title + await caldav_update( + user_id=user_id, + query=query_title, + title=event.title, + start=event.start_dt.isoformat(), + end=event.end_dt.isoformat() if event.end_dt else None, + description=event.description or None, + location=event.location or None, + ) + except Exception: + logger.warning("CalDAV push (update) failed for event %d", event.id, exc_info=True) + + +async def _push_delete(caldav_uid: str, event_title: str, user_id: int) -> None: + """Push a delete to CalDAV. Uses event_title to locate the event by SUMMARY.""" + try: + from fabledassistant.services.caldav import ( + delete_event as caldav_delete, + is_caldav_configured, + ) + if not await is_caldav_configured(user_id): + return + await caldav_delete(user_id=user_id, query=event_title) + except Exception: + logger.warning("CalDAV push (delete) failed for uid %s", caldav_uid, exc_info=True) diff --git a/tests/test_events_service.py b/tests/test_events_service.py new file mode 100644 index 0000000..36d5ebb --- /dev/null +++ b/tests/test_events_service.py @@ -0,0 +1,137 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from datetime import datetime, timezone + + +def _make_mock_session(): + mock_session = AsyncMock() + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + mock_session.add = MagicMock() + mock_session.commit = AsyncMock() + mock_session.refresh = AsyncMock() + return mock_session + + +def _make_mock_event(id=1, user_id=1, uid="uid-abc", title="Meeting", + caldav_uid="", color=""): + e = MagicMock() + e.id = id + e.user_id = user_id + e.uid = uid + e.title = title + e.caldav_uid = caldav_uid + e.color = color + e.start_dt = datetime(2026, 3, 25, 10, 0, tzinfo=timezone.utc) + e.end_dt = datetime(2026, 3, 25, 11, 0, tzinfo=timezone.utc) + e.all_day = False + e.description = "" + e.location = "" + e.recurrence = None + e.project_id = None + e.to_dict.return_value = { + "id": id, "uid": uid, "title": title, + "caldav_uid": caldav_uid, "color": color, + } + return e + + +@pytest.mark.asyncio +async def test_create_event_stores_to_db(): + mock_session = _make_mock_session() + with patch("fabledassistant.services.events.async_session") as mock_cls, \ + patch("fabledassistant.services.events.asyncio.create_task") as mock_task: + mock_cls.return_value = mock_session + from fabledassistant.services.events import create_event + result = await create_event( + user_id=1, + title="Dentist", + start_dt=datetime(2026, 3, 25, 10, 0, tzinfo=timezone.utc), + ) + assert mock_session.add.called + assert mock_session.commit.called + # CalDAV push background task should be scheduled + assert mock_task.called + + +@pytest.mark.asyncio +async def test_find_events_by_query_returns_ilike_results(): + mock_event = _make_mock_event(title="Team Meeting") + mock_session = _make_mock_session() + mock_result = MagicMock() + mock_result.scalars.return_value.all.return_value = [mock_event] + mock_session.execute = AsyncMock(return_value=mock_result) + + with patch("fabledassistant.services.events.async_session") as mock_cls: + mock_cls.return_value = mock_session + from fabledassistant.services.events import find_events_by_query + results = await find_events_by_query(user_id=1, query="meeting") + assert len(results) == 1 + assert results[0].title == "Team Meeting" + + +@pytest.mark.asyncio +async def test_list_events_returns_events_in_range(): + mock_event = _make_mock_event() + mock_session = _make_mock_session() + mock_result = MagicMock() + mock_result.scalars.return_value.all.return_value = [mock_event] + mock_session.execute = AsyncMock(return_value=mock_result) + + with patch("fabledassistant.services.events.async_session") as mock_cls: + mock_cls.return_value = mock_session + from fabledassistant.services.events import list_events + results = await list_events( + user_id=1, + date_from=datetime(2026, 3, 1, tzinfo=timezone.utc), + date_to=datetime(2026, 3, 31, tzinfo=timezone.utc), + ) + assert len(results) == 1 + + +@pytest.mark.asyncio +async def test_delete_event_fires_caldav_push_when_uid_set(): + mock_event = _make_mock_event(caldav_uid="sync-uid") + mock_session = _make_mock_session() + mock_result = MagicMock() + mock_result.scalar_one_or_none.return_value = mock_event + mock_session.execute = AsyncMock(return_value=mock_result) + + with patch("fabledassistant.services.events.async_session") as mock_cls, \ + patch("fabledassistant.services.events.asyncio.create_task") as mock_task: + mock_cls.return_value = mock_session + from fabledassistant.services.events import delete_event + await delete_event(user_id=1, event_id=1) + # Push task fired because caldav_uid is set + assert mock_task.called + + +@pytest.mark.asyncio +async def test_update_event_fires_caldav_push(): + mock_event = _make_mock_event(caldav_uid="sync-uid") + mock_session = _make_mock_session() + mock_result = MagicMock() + mock_result.scalar_one_or_none.return_value = mock_event + mock_session.execute = AsyncMock(return_value=mock_result) + + with patch("fabledassistant.services.events.async_session") as mock_cls, \ + patch("fabledassistant.services.events.asyncio.create_task") as mock_task: + mock_cls.return_value = mock_session + from fabledassistant.services.events import update_event + await update_event(user_id=1, event_id=1, title="Updated Title") + assert mock_task.called + + +@pytest.mark.asyncio +async def test_tools_calendar_always_available(): + """Calendar tools must appear in get_tools_for_user even without CalDAV.""" + with patch("fabledassistant.services.tools.is_caldav_configured", new_callable=AsyncMock) as mock_configured: + mock_configured.return_value = False + from fabledassistant.services.tools import get_tools_for_user + tools = await get_tools_for_user(user_id=1) + tool_names = {t["function"]["name"] for t in tools} + assert "create_event" in tool_names + assert "list_events" in tool_names + assert "search_events" in tool_names + assert "update_event" in tool_names + assert "delete_event" in tool_names