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",
|
"aiosqlite>=0.20",
|
||||||
# Calendar & schedule (RRULE expansion)
|
# Calendar & schedule (RRULE expansion)
|
||||||
"python-dateutil>=2.9",
|
"python-dateutil>=2.9",
|
||||||
|
# Calendar ICS import/export (U8)
|
||||||
|
"icalendar>=5.0",
|
||||||
# Document processing (U1-U9)
|
# Document processing (U1-U9)
|
||||||
"python-docx>=1.1",
|
"python-docx>=1.1",
|
||||||
"openpyxl>=3.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
|
import logging
|
||||||
from typing import Any
|
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 pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from agentkit.calendar.sync.ics_provider import ICSProvider
|
||||||
from agentkit.server.auth.dependencies import require_authenticated
|
from agentkit.server.auth.dependencies import require_authenticated
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -397,3 +399,44 @@ async def create_tag(
|
||||||
service = _get_calendar_service(request)
|
service = _get_calendar_service(request)
|
||||||
tag = await service.create_tag(user_id=user["user_id"], name=body.name)
|
tag = await service.create_tag(user_id=user["user_id"], name=body.name)
|
||||||
return {"success": True, "tag": tag.to_dict()}
|
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