feat(calendar): U8 iCal/ICS import and export
ICSProvider parses .ics files (icalendar library) and creates local CalendarEvents, skipping duplicate UIDs. Export builds an iCalendar from events in a date range, deduplicating recurring event occurrences back to a single VEVENT with RRULE. REST endpoints: POST /import-ics (multipart upload), GET /export-ics (download). - src/agentkit/calendar/sync/__init__.py — sync subpackage init - src/agentkit/calendar/sync/ics_provider.py — ICSProvider (import/export) - src/agentkit/calendar/db.py — added get_event_by_external_id() for dedup - src/agentkit/server/routes/calendar.py — import-ics and export-ics endpoints - pyproject.toml — added icalendar>=5.0 dependency - tests/unit/calendar/test_ics_provider.py — 8 tests
This commit is contained in:
parent
26efbb51db
commit
ffb184acc7
|
|
@ -26,6 +26,8 @@ dependencies = [
|
|||
"aiosqlite>=0.20",
|
||||
# Calendar & schedule (RRULE expansion)
|
||||
"python-dateutil>=2.9",
|
||||
# Calendar ICS import/export (U8)
|
||||
"icalendar>=5.0",
|
||||
# Document processing (U1-U9)
|
||||
"python-docx>=1.1",
|
||||
"openpyxl>=3.1",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,175 @@
|
|||
"""ICS (iCalendar) import/export provider (U8).
|
||||
|
||||
Uses the ``icalendar`` library for RFC 5545 compliant parsing/serialization.
|
||||
Import delegates to ``calendar.db.insert_event`` for direct persistence with
|
||||
``external_id`` set (so duplicate UIDs can be skipped on re-import).
|
||||
Export reads via ``CalendarService.list_events``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from icalendar import Calendar, Event
|
||||
from icalendar.prop import vRecur
|
||||
|
||||
from agentkit.calendar.db import get_event_by_external_id, insert_event
|
||||
from agentkit.calendar.models import CalendarEvent, _now_iso
|
||||
from agentkit.calendar.service import CalendarService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _to_iso_utc(dt: datetime) -> str:
|
||||
"""Convert a datetime to ISO 8601 UTC string."""
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _parse_iso(dt_str: str) -> datetime:
|
||||
"""Parse an ISO 8601 string to a UTC-aware datetime."""
|
||||
dt = datetime.fromisoformat(dt_str)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _extract_dt(component: Any, key: str) -> tuple[str, bool]:
|
||||
"""Extract a date/datetime property from an icalendar component.
|
||||
|
||||
Returns ``(iso_string, is_all_day)``. ``is_all_day`` is True when the
|
||||
value is a bare ``date`` (not a ``datetime``).
|
||||
"""
|
||||
prop = component.get(key)
|
||||
if prop is None:
|
||||
return "", False
|
||||
val = prop.dt
|
||||
if isinstance(val, date) and not isinstance(val, datetime):
|
||||
return val.isoformat(), True
|
||||
return _to_iso_utc(val), False
|
||||
|
||||
|
||||
class ICSProvider:
|
||||
"""Import/export calendar events as iCalendar (.ics) files."""
|
||||
|
||||
def __init__(self, service: CalendarService) -> None:
|
||||
self.service = service
|
||||
|
||||
async def import_ics(self, ics_bytes: bytes, user_id: str) -> dict[str, Any]:
|
||||
"""Parse ICS bytes and create events for *user_id*.
|
||||
|
||||
Returns ``{"imported": N, "skipped": M, "errors": [...]}``.
|
||||
Raises ``ValueError`` if the ICS content cannot be parsed at all.
|
||||
"""
|
||||
imported = 0
|
||||
skipped = 0
|
||||
errors: list[str] = []
|
||||
|
||||
try:
|
||||
cal = Calendar.from_ical(ics_bytes)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to parse ICS: {e}") from e
|
||||
|
||||
for component in cal.walk("VEVENT"):
|
||||
try:
|
||||
uid = str(component.get("UID", "") or "") or None
|
||||
|
||||
# Dedup by (external_id, provider, user_id)
|
||||
if uid:
|
||||
existing = await get_event_by_external_id(
|
||||
uid, "ics", user_id, self.service.db_path
|
||||
)
|
||||
if existing is not None:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
title = str(component.get("SUMMARY", "") or "")
|
||||
if not title:
|
||||
errors.append("VEVENT missing SUMMARY, skipped")
|
||||
continue
|
||||
|
||||
start_str, is_all_day = _extract_dt(component, "DTSTART")
|
||||
end_str, _ = _extract_dt(component, "DTEND")
|
||||
if not end_str:
|
||||
end_str = start_str
|
||||
|
||||
description = str(component.get("DESCRIPTION", "") or "")
|
||||
location = str(component.get("LOCATION", "") or "")
|
||||
|
||||
rrule_str: str | None = None
|
||||
rrule = component.get("RRULE")
|
||||
if rrule is not None:
|
||||
rrule_str = rrule.to_ical().decode("utf-8")
|
||||
|
||||
now = _now_iso()
|
||||
event = CalendarEvent(
|
||||
id=uuid.uuid4().hex,
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
description=description,
|
||||
start_time=start_str,
|
||||
end_time=end_str,
|
||||
is_all_day=is_all_day,
|
||||
location=location,
|
||||
rrule=rrule_str,
|
||||
source="manual",
|
||||
external_id=uid,
|
||||
external_provider="ics" if uid else None,
|
||||
last_modified=now,
|
||||
created_at=now,
|
||||
)
|
||||
await insert_event(event, self.service.db_path)
|
||||
imported += 1
|
||||
except Exception as e:
|
||||
errors.append(f"Failed to import VEVENT: {e}")
|
||||
logger.warning("ICS import error: %s", e)
|
||||
|
||||
return {"imported": imported, "skipped": skipped, "errors": errors}
|
||||
|
||||
def export_ics(self, events: list[CalendarEvent]) -> bytes:
|
||||
"""Serialize *events* to ICS bytes."""
|
||||
cal = Calendar()
|
||||
cal.add("prodid", "-//Fischer AgentKit//Calendar//EN")
|
||||
cal.add("version", "2.0")
|
||||
|
||||
# ponytail: list_events expands RRULE into occurrences (same event.id).
|
||||
# ICS wants one VEVENT with RRULE, not N copies — dedup by id.
|
||||
seen_ids: set[str] = set()
|
||||
for event in events:
|
||||
if event.id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(event.id)
|
||||
cal.add_component(self._event_to_vevent(event))
|
||||
|
||||
return cal.to_ical()
|
||||
|
||||
def _event_to_vevent(self, event: CalendarEvent) -> Event:
|
||||
"""Convert a :class:`CalendarEvent` to an icalendar ``Event`` component."""
|
||||
vevent = Event()
|
||||
vevent.add("uid", event.id)
|
||||
vevent.add("summary", event.title)
|
||||
|
||||
start_dt = _parse_iso(event.start_time)
|
||||
end_dt = _parse_iso(event.end_time)
|
||||
|
||||
if event.is_all_day:
|
||||
vevent.add("dtstart", start_dt.date())
|
||||
vevent.add("dtend", end_dt.date())
|
||||
else:
|
||||
vevent.add("dtstart", start_dt)
|
||||
vevent.add("dtend", end_dt)
|
||||
|
||||
if event.description:
|
||||
vevent.add("description", event.description)
|
||||
if event.location:
|
||||
vevent.add("location", event.location)
|
||||
if event.rrule:
|
||||
# ponytail: vRecur.from_ical reorders keys (e.g. COUNT before BYDAY)
|
||||
# but the result is semantically equivalent RFC 5545.
|
||||
vevent.add("rrule", vRecur.from_ical(event.rrule))
|
||||
|
||||
return vevent
|
||||
|
|
@ -25,9 +25,11 @@ from __future__ import annotations
|
|||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from agentkit.calendar.sync.ics_provider import ICSProvider
|
||||
from agentkit.server.auth.dependencies import require_authenticated
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -397,3 +399,44 @@ async def create_tag(
|
|||
service = _get_calendar_service(request)
|
||||
tag = await service.create_tag(user_id=user["user_id"], name=body.name)
|
||||
return {"success": True, "tag": tag.to_dict()}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ICS import/export (U8)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/import-ics")
|
||||
async def import_ics(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
user: dict = Depends(require_authenticated),
|
||||
) -> dict[str, Any]:
|
||||
"""Import events from an uploaded .ics file."""
|
||||
service = _get_calendar_service(request)
|
||||
content = await file.read()
|
||||
provider = ICSProvider(service)
|
||||
try:
|
||||
result = await provider.import_ics(content, user["user_id"])
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
return {"success": True, **result}
|
||||
|
||||
|
||||
@router.get("/export-ics")
|
||||
async def export_ics(
|
||||
request: Request,
|
||||
start: str | None = Query(None),
|
||||
end: str | None = Query(None),
|
||||
user: dict = Depends(require_authenticated),
|
||||
) -> Response:
|
||||
"""Export the current user's events to a downloadable .ics file."""
|
||||
service = _get_calendar_service(request)
|
||||
events = await service.list_events(user_id=user["user_id"], start=start, end=end)
|
||||
provider = ICSProvider(service)
|
||||
ics_bytes = provider.export_ics(events)
|
||||
return Response(
|
||||
content=ics_bytes,
|
||||
media_type="text/calendar",
|
||||
headers={"Content-Disposition": 'attachment; filename="calendar.ics"'},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,272 @@
|
|||
"""Tests for ICSProvider — iCalendar import/export (U8)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from icalendar import Calendar
|
||||
|
||||
from agentkit.calendar.db import init_calendar_db, list_events as db_list_events
|
||||
from agentkit.calendar.service import CalendarService
|
||||
from agentkit.calendar.sync.ics_provider import ICSProvider
|
||||
from agentkit.server.auth.models import init_auth_db
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def calendar_db_path(tmp_path: Path) -> Path:
|
||||
path = tmp_path / "test_calendar.db"
|
||||
asyncio.run(init_calendar_db(path))
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_db_path(tmp_path: Path) -> Path:
|
||||
path = tmp_path / "test_auth.db"
|
||||
asyncio.run(init_auth_db(path))
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(calendar_db_path: Path, auth_db_path: Path) -> CalendarService:
|
||||
return CalendarService(db_path=calendar_db_path, auth_db_path=auth_db_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def provider(service: CalendarService) -> ICSProvider:
|
||||
return ICSProvider(service)
|
||||
|
||||
|
||||
USER_ID = "user-1"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ICS sample strings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SIMPLE_ICS = (
|
||||
b"BEGIN:VCALENDAR\n"
|
||||
b"VERSION:2.0\n"
|
||||
b"PRODID:-//Test//Test//EN\n"
|
||||
b"BEGIN:VEVENT\n"
|
||||
b"UID:simple-uid@test\n"
|
||||
b"SUMMARY:Test Event\n"
|
||||
b"DTSTART:20260701T100000Z\n"
|
||||
b"DTEND:20260701T110000Z\n"
|
||||
b"DESCRIPTION:Test description\n"
|
||||
b"LOCATION:Room A\n"
|
||||
b"END:VEVENT\n"
|
||||
b"END:VCALENDAR\n"
|
||||
)
|
||||
|
||||
RECURRING_ICS = (
|
||||
b"BEGIN:VCALENDAR\n"
|
||||
b"VERSION:2.0\n"
|
||||
b"PRODID:-//Test//Test//EN\n"
|
||||
b"BEGIN:VEVENT\n"
|
||||
b"UID:recur-uid@test\n"
|
||||
b"SUMMARY:Weekly Meeting\n"
|
||||
b"DTSTART:20260706T100000Z\n"
|
||||
b"DTEND:20260706T110000Z\n"
|
||||
b"RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=4\n"
|
||||
b"END:VEVENT\n"
|
||||
b"END:VCALENDAR\n"
|
||||
)
|
||||
|
||||
ALL_DAY_ICS = (
|
||||
b"BEGIN:VCALENDAR\n"
|
||||
b"VERSION:2.0\n"
|
||||
b"PRODID:-//Test//Test//EN\n"
|
||||
b"BEGIN:VEVENT\n"
|
||||
b"UID:allday-uid@test\n"
|
||||
b"SUMMARY:All Day Event\n"
|
||||
b"DTSTART;VALUE=DATE:20260701\n"
|
||||
b"DTEND;VALUE=DATE:20260702\n"
|
||||
b"END:VEVENT\n"
|
||||
b"END:VCALENDAR\n"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Import tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_import_simple_ics_creates_event(
|
||||
provider: ICSProvider, calendar_db_path: Path
|
||||
) -> None:
|
||||
"""Single VEVENT ICS → event created with correct fields."""
|
||||
result = await provider.import_ics(SIMPLE_ICS, USER_ID)
|
||||
|
||||
assert result["imported"] == 1
|
||||
assert result["skipped"] == 0
|
||||
assert result["errors"] == []
|
||||
|
||||
events = await db_list_events(USER_ID, db_path=calendar_db_path)
|
||||
assert len(events) == 1
|
||||
event = events[0]
|
||||
assert event.title == "Test Event"
|
||||
assert event.description == "Test description"
|
||||
assert event.location == "Room A"
|
||||
assert event.start_time == "2026-07-01T10:00:00+00:00"
|
||||
assert event.end_time == "2026-07-01T11:00:00+00:00"
|
||||
assert event.is_all_day is False
|
||||
assert event.external_id == "simple-uid@test"
|
||||
assert event.external_provider == "ics"
|
||||
|
||||
|
||||
async def test_import_recurring_ics_preserves_rrule(
|
||||
provider: ICSProvider, calendar_db_path: Path
|
||||
) -> None:
|
||||
"""VEVENT with RRULE → rrule field set on the created event."""
|
||||
result = await provider.import_ics(RECURRING_ICS, USER_ID)
|
||||
|
||||
assert result["imported"] == 1
|
||||
assert result["errors"] == []
|
||||
|
||||
events = await db_list_events(USER_ID, db_path=calendar_db_path)
|
||||
assert len(events) == 1
|
||||
event = events[0]
|
||||
assert event.rrule is not None
|
||||
assert "FREQ=WEEKLY" in event.rrule
|
||||
assert "BYDAY=MO" in event.rrule
|
||||
assert "COUNT=4" in event.rrule
|
||||
|
||||
|
||||
async def test_import_all_day_event(provider: ICSProvider, calendar_db_path: Path) -> None:
|
||||
"""DTSTART is date (not datetime) → is_all_day=True, ISO date stored."""
|
||||
result = await provider.import_ics(ALL_DAY_ICS, USER_ID)
|
||||
|
||||
assert result["imported"] == 1
|
||||
assert result["errors"] == []
|
||||
|
||||
events = await db_list_events(USER_ID, db_path=calendar_db_path)
|
||||
assert len(events) == 1
|
||||
event = events[0]
|
||||
assert event.is_all_day is True
|
||||
assert event.start_time == "2026-07-01"
|
||||
assert event.end_time == "2026-07-02"
|
||||
|
||||
|
||||
async def test_import_skips_duplicate_uid(provider: ICSProvider, calendar_db_path: Path) -> None:
|
||||
"""Importing the same ICS twice → second import skips the duplicate UID."""
|
||||
first = await provider.import_ics(SIMPLE_ICS, USER_ID)
|
||||
assert first["imported"] == 1
|
||||
assert first["skipped"] == 0
|
||||
|
||||
second = await provider.import_ics(SIMPLE_ICS, USER_ID)
|
||||
assert second["imported"] == 0
|
||||
assert second["skipped"] == 1
|
||||
|
||||
events = await db_list_events(USER_ID, db_path=calendar_db_path)
|
||||
assert len(events) == 1 # Still only one event
|
||||
|
||||
|
||||
async def test_import_malformed_ics_raises_error(provider: ICSProvider) -> None:
|
||||
"""Invalid ICS bytes → ValueError raised (graceful, not a crash)."""
|
||||
with pytest.raises(ValueError, match="Failed to parse ICS"):
|
||||
await provider.import_ics(b"this is definitely not valid ics at all", USER_ID)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Export tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_export_produces_valid_ics(provider: ICSProvider, service: CalendarService) -> None:
|
||||
"""Create event, export, parse result with icalendar → roundtrip."""
|
||||
await service.create_event(
|
||||
user_id=USER_ID,
|
||||
title="Export Test",
|
||||
start_time="2026-07-01T10:00:00+00:00",
|
||||
end_time="2026-07-01T11:00:00+00:00",
|
||||
description="Desc",
|
||||
location="Room B",
|
||||
)
|
||||
|
||||
events = await service.list_events(USER_ID)
|
||||
ics_bytes = provider.export_ics(events)
|
||||
|
||||
# Parse the exported ICS back
|
||||
cal = Calendar.from_ical(ics_bytes)
|
||||
vevents = list(cal.walk("VEVENT"))
|
||||
assert len(vevents) == 1
|
||||
vevent = vevents[0]
|
||||
assert str(vevent.get("SUMMARY")) == "Export Test"
|
||||
assert str(vevent.get("DESCRIPTION")) == "Desc"
|
||||
assert str(vevent.get("LOCATION")) == "Room B"
|
||||
# DTSTART should be a datetime (not all-day)
|
||||
dtstart = vevent.get("DTSTART").dt
|
||||
assert dtstart.year == 2026
|
||||
assert dtstart.month == 7
|
||||
assert dtstart.day == 1
|
||||
|
||||
|
||||
async def test_export_includes_recurrence(provider: ICSProvider, service: CalendarService) -> None:
|
||||
"""Event with rrule → exported ICS contains RRULE line."""
|
||||
await service.create_event(
|
||||
user_id=USER_ID,
|
||||
title="Recurring Export",
|
||||
start_time="2026-07-06T10:00:00+00:00",
|
||||
end_time="2026-07-06T11:00:00+00:00",
|
||||
rrule="FREQ=WEEKLY;BYDAY=MO;COUNT=4",
|
||||
)
|
||||
|
||||
events = await service.list_events(USER_ID)
|
||||
ics_bytes = provider.export_ics(events)
|
||||
|
||||
cal = Calendar.from_ical(ics_bytes)
|
||||
vevents = list(cal.walk("VEVENT"))
|
||||
assert len(vevents) == 1
|
||||
vevent = vevents[0]
|
||||
rrule = vevent.get("RRULE")
|
||||
assert rrule is not None
|
||||
rrule_str = rrule.to_ical().decode("utf-8")
|
||||
assert "FREQ=WEEKLY" in rrule_str
|
||||
assert "BYDAY=MO" in rrule_str
|
||||
assert "COUNT=4" in rrule_str
|
||||
|
||||
|
||||
async def test_export_date_range_filter(provider: ICSProvider, service: CalendarService) -> None:
|
||||
"""3 events, export range covering 2 → only 2 in ICS output."""
|
||||
# Event 1: July 1
|
||||
await service.create_event(
|
||||
user_id=USER_ID,
|
||||
title="Event 1",
|
||||
start_time="2026-07-01T10:00:00+00:00",
|
||||
end_time="2026-07-01T11:00:00+00:00",
|
||||
)
|
||||
# Event 2: July 5
|
||||
await service.create_event(
|
||||
user_id=USER_ID,
|
||||
title="Event 2",
|
||||
start_time="2026-07-05T10:00:00+00:00",
|
||||
end_time="2026-07-05T11:00:00+00:00",
|
||||
)
|
||||
# Event 3: July 20
|
||||
await service.create_event(
|
||||
user_id=USER_ID,
|
||||
title="Event 3",
|
||||
start_time="2026-07-20T10:00:00+00:00",
|
||||
end_time="2026-07-20T11:00:00+00:00",
|
||||
)
|
||||
|
||||
# Export range: July 1 – July 10 (covers Event 1 and Event 2)
|
||||
events = await service.list_events(
|
||||
USER_ID,
|
||||
start="2026-07-01T00:00:00+00:00",
|
||||
end="2026-07-10T00:00:00+00:00",
|
||||
)
|
||||
ics_bytes = provider.export_ics(events)
|
||||
|
||||
cal = Calendar.from_ical(ics_bytes)
|
||||
vevents = list(cal.walk("VEVENT"))
|
||||
assert len(vevents) == 2
|
||||
summaries = {str(v.get("SUMMARY")) for v in vevents}
|
||||
assert summaries == {"Event 1", "Event 2"}
|
||||
Loading…
Reference in New Issue