diff --git a/server/pyproject.toml b/server/pyproject.toml index f7f97dbc..bccd3dc1 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -41,6 +41,7 @@ dependencies = [ "llama-index-llms-openai-like>=0.4.0", "pytest-env>=1.1.5", "webvtt-py>=0.5.0", + "icalendar>=6.0.0", ] [dependency-groups] diff --git a/server/reflector/db/__init__.py b/server/reflector/db/__init__.py index da488a51..f79a2573 100644 --- a/server/reflector/db/__init__.py +++ b/server/reflector/db/__init__.py @@ -24,6 +24,7 @@ def get_database() -> databases.Database: # import models +import reflector.db.calendar_events # noqa import reflector.db.meetings # noqa import reflector.db.recordings # noqa import reflector.db.rooms # noqa diff --git a/server/reflector/db/calendar_events.py b/server/reflector/db/calendar_events.py new file mode 100644 index 00000000..931fd979 --- /dev/null +++ b/server/reflector/db/calendar_events.py @@ -0,0 +1,193 @@ +from datetime import datetime, timezone +from typing import Any + +import sqlalchemy as sa +from pydantic import BaseModel, Field +from sqlalchemy.dialects.postgresql import JSONB + +from reflector.db import get_database, metadata +from reflector.utils import generate_uuid4 + +calendar_events = sa.Table( + "calendar_event", + metadata, + sa.Column("id", sa.String, primary_key=True), + sa.Column( + "room_id", + sa.String, + sa.ForeignKey("room.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("ics_uid", sa.Text, nullable=False), + sa.Column("title", sa.Text), + sa.Column("description", sa.Text), + sa.Column("start_time", sa.DateTime(timezone=True), nullable=False), + sa.Column("end_time", sa.DateTime(timezone=True), nullable=False), + sa.Column("attendees", JSONB), + sa.Column("location", sa.Text), + sa.Column("ics_raw_data", sa.Text), + sa.Column("last_synced", sa.DateTime(timezone=True), nullable=False), + sa.Column("is_deleted", sa.Boolean, nullable=False, server_default=sa.false()), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint("room_id", "ics_uid", name="uq_room_calendar_event"), + sa.Index("idx_calendar_event_room_start", "room_id", "start_time"), + sa.Index( + "idx_calendar_event_deleted", + "is_deleted", + postgresql_where=sa.text("NOT is_deleted"), + ), +) + + +class CalendarEvent(BaseModel): + id: str = Field(default_factory=generate_uuid4) + room_id: str + ics_uid: str + title: str | None = None + description: str | None = None + start_time: datetime + end_time: datetime + attendees: list[dict[str, Any]] | None = None + location: str | None = None + ics_raw_data: str | None = None + last_synced: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + is_deleted: bool = False + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + +class CalendarEventController: + async def get_by_room( + self, + room_id: str, + include_deleted: bool = False, + start_after: datetime | None = None, + end_before: datetime | None = None, + ) -> list[CalendarEvent]: + """Get calendar events for a room.""" + query = calendar_events.select().where(calendar_events.c.room_id == room_id) + + if not include_deleted: + query = query.where(calendar_events.c.is_deleted == False) + + if start_after: + query = query.where(calendar_events.c.start_time >= start_after) + + if end_before: + query = query.where(calendar_events.c.end_time <= end_before) + + query = query.order_by(calendar_events.c.start_time.asc()) + + results = await get_database().fetch_all(query) + return [CalendarEvent(**result) for result in results] + + async def get_upcoming( + self, room_id: str, minutes_ahead: int = 30 + ) -> list[CalendarEvent]: + """Get upcoming events for a room within the specified minutes.""" + now = datetime.now(timezone.utc) + future_time = now + timedelta(minutes=minutes_ahead) + + query = ( + calendar_events.select() + .where( + sa.and_( + calendar_events.c.room_id == room_id, + calendar_events.c.is_deleted == False, + calendar_events.c.start_time >= now, + calendar_events.c.start_time <= future_time, + ) + ) + .order_by(calendar_events.c.start_time.asc()) + ) + + results = await get_database().fetch_all(query) + return [CalendarEvent(**result) for result in results] + + async def get_by_ics_uid(self, room_id: str, ics_uid: str) -> CalendarEvent | None: + """Get a calendar event by its ICS UID.""" + query = calendar_events.select().where( + sa.and_( + calendar_events.c.room_id == room_id, + calendar_events.c.ics_uid == ics_uid, + ) + ) + result = await get_database().fetch_one(query) + return CalendarEvent(**result) if result else None + + async def upsert(self, event: CalendarEvent) -> CalendarEvent: + """Create or update a calendar event.""" + existing = await self.get_by_ics_uid(event.room_id, event.ics_uid) + + if existing: + # Update existing event + event.id = existing.id + event.created_at = existing.created_at + event.updated_at = datetime.now(timezone.utc) + + query = ( + calendar_events.update() + .where(calendar_events.c.id == existing.id) + .values(**event.model_dump()) + ) + else: + # Insert new event + query = calendar_events.insert().values(**event.model_dump()) + + await get_database().execute(query) + return event + + async def soft_delete_missing( + self, room_id: str, current_ics_uids: list[str] + ) -> int: + """Soft delete future events that are no longer in the calendar.""" + now = datetime.now(timezone.utc) + + # First, get the IDs of events to delete + select_query = calendar_events.select().where( + sa.and_( + calendar_events.c.room_id == room_id, + calendar_events.c.start_time > now, + calendar_events.c.is_deleted == False, + calendar_events.c.ics_uid.notin_(current_ics_uids) + if current_ics_uids + else True, + ) + ) + + to_delete = await get_database().fetch_all(select_query) + delete_count = len(to_delete) + + if delete_count > 0: + # Now update them + update_query = ( + calendar_events.update() + .where( + sa.and_( + calendar_events.c.room_id == room_id, + calendar_events.c.start_time > now, + calendar_events.c.is_deleted == False, + calendar_events.c.ics_uid.notin_(current_ics_uids) + if current_ics_uids + else True, + ) + ) + .values(is_deleted=True, updated_at=now) + ) + + await get_database().execute(update_query) + + return delete_count + + async def delete_by_room(self, room_id: str) -> int: + """Hard delete all events for a room (used when room is deleted).""" + query = calendar_events.delete().where(calendar_events.c.room_id == room_id) + result = await get_database().execute(query) + return result.rowcount + + +# Add missing import +from datetime import timedelta + +calendar_events_controller = CalendarEventController() diff --git a/server/reflector/db/meetings.py b/server/reflector/db/meetings.py index 40bd6f8a..552a8a98 100644 --- a/server/reflector/db/meetings.py +++ b/server/reflector/db/meetings.py @@ -1,9 +1,10 @@ from datetime import datetime -from typing import Literal +from typing import Any, Literal import sqlalchemy as sa from fastapi import HTTPException from pydantic import BaseModel, Field +from sqlalchemy.dialects.postgresql import JSONB from reflector.db import get_database, metadata from reflector.db.rooms import Room @@ -41,13 +42,14 @@ meetings = sa.Table( nullable=False, server_default=sa.true(), ), - sa.Index("idx_meeting_room_id", "room_id"), - sa.Index( - "idx_one_active_meeting_per_room", - "room_id", - unique=True, - postgresql_where=sa.text("is_active = true"), + sa.Column( + "calendar_event_id", + sa.String, + sa.ForeignKey("calendar_event.id", ondelete="SET NULL"), ), + sa.Column("calendar_metadata", JSONB), + sa.Index("idx_meeting_room_id", "room_id"), + sa.Index("idx_meeting_calendar_event", "calendar_event_id"), ) meeting_consent = sa.Table( @@ -85,6 +87,9 @@ class Meeting(BaseModel): "none", "prompt", "automatic", "automatic-2nd-participant" ] = "automatic-2nd-participant" num_clients: int = 0 + is_active: bool = True + calendar_event_id: str | None = None + calendar_metadata: dict[str, Any] | None = None class MeetingController: diff --git a/server/reflector/db/rooms.py b/server/reflector/db/rooms.py index a38e6b7f..ad8cb607 100644 --- a/server/reflector/db/rooms.py +++ b/server/reflector/db/rooms.py @@ -40,7 +40,15 @@ rooms = sqlalchemy.Table( sqlalchemy.Column( "is_shared", sqlalchemy.Boolean, nullable=False, server_default=false() ), + sqlalchemy.Column("ics_url", sqlalchemy.Text), + sqlalchemy.Column("ics_fetch_interval", sqlalchemy.Integer, server_default="300"), + sqlalchemy.Column( + "ics_enabled", sqlalchemy.Boolean, nullable=False, server_default=false() + ), + sqlalchemy.Column("ics_last_sync", sqlalchemy.DateTime(timezone=True)), + sqlalchemy.Column("ics_last_etag", sqlalchemy.Text), sqlalchemy.Index("idx_room_is_shared", "is_shared"), + sqlalchemy.Index("idx_room_ics_enabled", "ics_enabled"), ) @@ -59,6 +67,11 @@ class Room(BaseModel): "none", "prompt", "automatic", "automatic-2nd-participant" ] = "automatic-2nd-participant" is_shared: bool = False + ics_url: str | None = None + ics_fetch_interval: int = 300 + ics_enabled: bool = False + ics_last_sync: datetime | None = None + ics_last_etag: str | None = None class RoomController: @@ -107,6 +120,9 @@ class RoomController: recording_type: str, recording_trigger: str, is_shared: bool, + ics_url: str | None = None, + ics_fetch_interval: int = 300, + ics_enabled: bool = False, ): """ Add a new room @@ -122,6 +138,9 @@ class RoomController: recording_type=recording_type, recording_trigger=recording_trigger, is_shared=is_shared, + ics_url=ics_url, + ics_fetch_interval=ics_fetch_interval, + ics_enabled=ics_enabled, ) query = rooms.insert().values(**room.model_dump()) try: diff --git a/server/reflector/services/ics_sync.py b/server/reflector/services/ics_sync.py new file mode 100644 index 00000000..04986096 --- /dev/null +++ b/server/reflector/services/ics_sync.py @@ -0,0 +1,296 @@ +import hashlib +from datetime import date, datetime, timedelta, timezone +from typing import TypedDict + +import httpx +import pytz +from icalendar import Calendar, Event +from loguru import logger + +from reflector.db.calendar_events import CalendarEvent, calendar_events_controller +from reflector.db.rooms import Room, rooms_controller +from reflector.settings import settings + + +class AttendeeData(TypedDict, total=False): + email: str | None + name: str | None + status: str | None + role: str | None + + +class EventData(TypedDict): + ics_uid: str + title: str | None + description: str | None + location: str | None + start_time: datetime + end_time: datetime + attendees: list[AttendeeData] + ics_raw_data: str + + +class SyncStats(TypedDict): + events_created: int + events_updated: int + events_deleted: int + + +class ICSFetchService: + def __init__(self): + self.client = httpx.AsyncClient( + timeout=30.0, headers={"User-Agent": "Reflector/1.0"} + ) + + async def fetch_ics(self, url: str) -> str: + response = await self.client.get(url) + response.raise_for_status() + + return response.text + + def parse_ics(self, ics_content: str) -> Calendar: + return Calendar.from_ical(ics_content) + + def extract_room_events( + self, calendar: Calendar, room_name: str, room_url: str + ) -> list[EventData]: + events = [] + now = datetime.now(timezone.utc) + window_start = now - timedelta(hours=1) + window_end = now + timedelta(hours=24) + + for component in calendar.walk(): + if component.name == "VEVENT": + # Skip cancelled events + status = component.get("STATUS", "").upper() + if status == "CANCELLED": + continue + + # Check if event matches this room + if self._event_matches_room(component, room_name, room_url): + event_data = self._parse_event(component) + + # Only include events in our time window + if ( + event_data + and window_start <= event_data["start_time"] <= window_end + ): + events.append(event_data) + + return events + + def _event_matches_room(self, event: Event, room_name: str, room_url: str) -> bool: + location = str(event.get("LOCATION", "")) + description = str(event.get("DESCRIPTION", "")) + + # Only match full room URL (with or without protocol) + patterns = [ + room_url, # Full URL with protocol + room_url.replace("https://", ""), # Without https protocol + room_url.replace("http://", ""), # Without http protocol + ] + + # Check location and description for patterns + text_to_check = f"{location} {description}".lower() + + for pattern in patterns: + if pattern.lower() in text_to_check: + return True + + return False + + def _parse_event(self, event: Event) -> EventData | None: + # Extract basic fields + uid = str(event.get("UID", "")) + summary = str(event.get("SUMMARY", "")) + description = str(event.get("DESCRIPTION", "")) + location = str(event.get("LOCATION", "")) + + # Parse dates + dtstart = event.get("DTSTART") + dtend = event.get("DTEND") + + if not dtstart: + return None + + # Convert to datetime + start_time = self._normalize_datetime( + dtstart.dt if hasattr(dtstart, "dt") else dtstart + ) + end_time = ( + self._normalize_datetime(dtend.dt if hasattr(dtend, "dt") else dtend) + if dtend + else start_time + timedelta(hours=1) + ) + + # Parse attendees + attendees = self._parse_attendees(event) + + # Get raw event data for storage + raw_data = event.to_ical().decode("utf-8") + + return { + "ics_uid": uid, + "title": summary, + "description": description, + "location": location, + "start_time": start_time, + "end_time": end_time, + "attendees": attendees, + "ics_raw_data": raw_data, + } + + def _normalize_datetime(self, dt) -> datetime: + # Handle date objects (all-day events) + if isinstance(dt, date) and not isinstance(dt, datetime): + # Convert to datetime at start of day in UTC + dt = datetime.combine(dt, datetime.min.time()) + dt = pytz.UTC.localize(dt) + elif isinstance(dt, datetime): + # Add UTC timezone if naive + if dt.tzinfo is None: + dt = pytz.UTC.localize(dt) + else: + # Convert to UTC + dt = dt.astimezone(pytz.UTC) + + return dt + + def _parse_attendees(self, event: Event) -> list[AttendeeData]: + attendees = [] + + # Parse ATTENDEE properties + for attendee in event.get("ATTENDEE", []): + if not isinstance(attendee, list): + attendee = [attendee] + + for att in attendee: + att_data: AttendeeData = { + "email": str(att).replace("mailto:", "") if att else None, + "name": att.params.get("CN") if hasattr(att, "params") else None, + "status": att.params.get("PARTSTAT") + if hasattr(att, "params") + else None, + "role": att.params.get("ROLE") if hasattr(att, "params") else None, + } + attendees.append(att_data) + + # Add organizer + organizer = event.get("ORGANIZER") + if organizer: + org_data: AttendeeData = { + "email": str(organizer).replace("mailto:", "") if organizer else None, + "name": organizer.params.get("CN") + if hasattr(organizer, "params") + else None, + "role": "ORGANIZER", + } + attendees.append(org_data) + + return attendees + + +class ICSSyncService: + def __init__(self): + self.fetch_service = ICSFetchService() + + async def sync_room_calendar(self, room: Room) -> dict: + if not room.ics_enabled or not room.ics_url: + return {"status": "skipped", "reason": "ICS not configured"} + + try: + # Check if it's time to sync + if not self._should_sync(room): + return {"status": "skipped", "reason": "Not time to sync yet"} + + # Fetch ICS file + ics_content = await self.fetch_service.fetch_ics(room.ics_url) + + # Check if content changed + content_hash = hashlib.md5(ics_content.encode()).hexdigest() + if room.ics_last_etag == content_hash: + logger.info(f"No changes in ICS for room {room.id}") + return {"status": "unchanged", "hash": content_hash} + + # Parse calendar + calendar = self.fetch_service.parse_ics(ics_content) + + # Build room URL + room_url = f"{settings.BASE_URL}/room/{room.name}" + + # Extract matching events + events = self.fetch_service.extract_room_events( + calendar, room.name, room_url + ) + + # Sync events to database + sync_result = await self._sync_events_to_database(room.id, events) + + # Update room sync metadata + await rooms_controller.update( + room, + { + "ics_last_sync": datetime.now(timezone.utc), + "ics_last_etag": content_hash, + }, + mutate=False, + ) + + return { + "status": "success", + "hash": content_hash, + "events_found": len(events), + **sync_result, + } + + except Exception as e: + logger.error(f"Failed to sync ICS for room {room.id}: {e}") + return {"status": "error", "error": str(e)} + + def _should_sync(self, room: Room) -> bool: + if not room.ics_last_sync: + return True + + time_since_sync = datetime.now(timezone.utc) - room.ics_last_sync + return time_since_sync.total_seconds() >= room.ics_fetch_interval + + async def _sync_events_to_database( + self, room_id: str, events: list[EventData] + ) -> SyncStats: + created = 0 + updated = 0 + + # Track current event IDs + current_ics_uids = [] + + for event_data in events: + # Create CalendarEvent object + calendar_event = CalendarEvent(room_id=room_id, **event_data) + + # Upsert event + existing = await calendar_events_controller.get_by_ics_uid( + room_id, event_data["ics_uid"] + ) + + if existing: + updated += 1 + else: + created += 1 + + await calendar_events_controller.upsert(calendar_event) + current_ics_uids.append(event_data["ics_uid"]) + + # Soft delete events that are no longer in calendar + deleted = await calendar_events_controller.soft_delete_missing( + room_id, current_ics_uids + ) + + return { + "events_created": created, + "events_updated": updated, + "events_deleted": deleted, + } + + +# Global instance +ics_sync_service = ICSSyncService() diff --git a/server/tests/test_calendar_event.py b/server/tests/test_calendar_event.py new file mode 100644 index 00000000..b39af2bc --- /dev/null +++ b/server/tests/test_calendar_event.py @@ -0,0 +1,351 @@ +""" +Tests for CalendarEvent model. +""" + +from datetime import datetime, timedelta, timezone + +import pytest + +from reflector.db.calendar_events import CalendarEvent, calendar_events_controller +from reflector.db.rooms import rooms_controller + + +@pytest.mark.asyncio +async def test_calendar_event_create(): + """Test creating a calendar event.""" + # Create a room first + room = await rooms_controller.add( + name="test-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ) + + # Create calendar event + now = datetime.now(timezone.utc) + event = CalendarEvent( + room_id=room.id, + ics_uid="test-event-123", + title="Team Meeting", + description="Weekly team sync", + start_time=now + timedelta(hours=1), + end_time=now + timedelta(hours=2), + location=f"https://example.com/room/{room.name}", + attendees=[ + {"email": "alice@example.com", "name": "Alice", "status": "ACCEPTED"}, + {"email": "bob@example.com", "name": "Bob", "status": "TENTATIVE"}, + ], + ) + + # Save event + saved_event = await calendar_events_controller.upsert(event) + + assert saved_event.ics_uid == "test-event-123" + assert saved_event.title == "Team Meeting" + assert saved_event.room_id == room.id + assert len(saved_event.attendees) == 2 + + +@pytest.mark.asyncio +async def test_calendar_event_get_by_room(): + """Test getting calendar events for a room.""" + # Create room + room = await rooms_controller.add( + name="events-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ) + + now = datetime.now(timezone.utc) + + # Create multiple events + for i in range(3): + event = CalendarEvent( + room_id=room.id, + ics_uid=f"event-{i}", + title=f"Meeting {i}", + start_time=now + timedelta(hours=i), + end_time=now + timedelta(hours=i + 1), + ) + await calendar_events_controller.upsert(event) + + # Get events for room + events = await calendar_events_controller.get_by_room(room.id) + + assert len(events) == 3 + assert all(e.room_id == room.id for e in events) + assert events[0].title == "Meeting 0" + assert events[1].title == "Meeting 1" + assert events[2].title == "Meeting 2" + + +@pytest.mark.asyncio +async def test_calendar_event_get_upcoming(): + """Test getting upcoming events within time window.""" + # Create room + room = await rooms_controller.add( + name="upcoming-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ) + + now = datetime.now(timezone.utc) + + # Create events at different times + # Past event (should not be included) + past_event = CalendarEvent( + room_id=room.id, + ics_uid="past-event", + title="Past Meeting", + start_time=now - timedelta(hours=2), + end_time=now - timedelta(hours=1), + ) + await calendar_events_controller.upsert(past_event) + + # Upcoming event within 30 minutes + upcoming_event = CalendarEvent( + room_id=room.id, + ics_uid="upcoming-event", + title="Upcoming Meeting", + start_time=now + timedelta(minutes=15), + end_time=now + timedelta(minutes=45), + ) + await calendar_events_controller.upsert(upcoming_event) + + # Future event beyond 30 minutes + future_event = CalendarEvent( + room_id=room.id, + ics_uid="future-event", + title="Future Meeting", + start_time=now + timedelta(hours=2), + end_time=now + timedelta(hours=3), + ) + await calendar_events_controller.upsert(future_event) + + # Get upcoming events (default 30 minutes) + upcoming = await calendar_events_controller.get_upcoming(room.id) + + assert len(upcoming) == 1 + assert upcoming[0].ics_uid == "upcoming-event" + + # Get upcoming with custom window + upcoming_extended = await calendar_events_controller.get_upcoming( + room.id, minutes_ahead=180 + ) + + assert len(upcoming_extended) == 2 + assert upcoming_extended[0].ics_uid == "upcoming-event" + assert upcoming_extended[1].ics_uid == "future-event" + + +@pytest.mark.asyncio +async def test_calendar_event_upsert(): + """Test upserting (create/update) calendar events.""" + # Create room + room = await rooms_controller.add( + name="upsert-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ) + + now = datetime.now(timezone.utc) + + # Create new event + event = CalendarEvent( + room_id=room.id, + ics_uid="upsert-test", + title="Original Title", + start_time=now, + end_time=now + timedelta(hours=1), + ) + + created = await calendar_events_controller.upsert(event) + assert created.title == "Original Title" + + # Update existing event + event.title = "Updated Title" + event.description = "Added description" + + updated = await calendar_events_controller.upsert(event) + assert updated.title == "Updated Title" + assert updated.description == "Added description" + assert updated.ics_uid == "upsert-test" + + # Verify only one event exists + events = await calendar_events_controller.get_by_room(room.id) + assert len(events) == 1 + assert events[0].title == "Updated Title" + + +@pytest.mark.asyncio +async def test_calendar_event_soft_delete(): + """Test soft deleting events no longer in calendar.""" + # Create room + room = await rooms_controller.add( + name="delete-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ) + + now = datetime.now(timezone.utc) + + # Create multiple events + for i in range(4): + event = CalendarEvent( + room_id=room.id, + ics_uid=f"event-{i}", + title=f"Meeting {i}", + start_time=now + timedelta(hours=i), + end_time=now + timedelta(hours=i + 1), + ) + await calendar_events_controller.upsert(event) + + # Soft delete events not in current list + current_ids = ["event-0", "event-2"] # Keep events 0 and 2 + deleted_count = await calendar_events_controller.soft_delete_missing( + room.id, current_ids + ) + + assert deleted_count == 2 # Should delete events 1 and 3 + + # Get non-deleted events + events = await calendar_events_controller.get_by_room( + room.id, include_deleted=False + ) + assert len(events) == 2 + assert {e.ics_uid for e in events} == {"event-0", "event-2"} + + # Get all events including deleted + all_events = await calendar_events_controller.get_by_room( + room.id, include_deleted=True + ) + assert len(all_events) == 4 + + +@pytest.mark.asyncio +async def test_calendar_event_past_events_not_deleted(): + """Test that past events are not soft deleted.""" + # Create room + room = await rooms_controller.add( + name="past-events-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ) + + now = datetime.now(timezone.utc) + + # Create past event + past_event = CalendarEvent( + room_id=room.id, + ics_uid="past-event", + title="Past Meeting", + start_time=now - timedelta(hours=2), + end_time=now - timedelta(hours=1), + ) + await calendar_events_controller.upsert(past_event) + + # Create future event + future_event = CalendarEvent( + room_id=room.id, + ics_uid="future-event", + title="Future Meeting", + start_time=now + timedelta(hours=1), + end_time=now + timedelta(hours=2), + ) + await calendar_events_controller.upsert(future_event) + + # Try to soft delete all events (only future should be deleted) + deleted_count = await calendar_events_controller.soft_delete_missing(room.id, []) + + assert deleted_count == 1 # Only future event deleted + + # Verify past event still exists + events = await calendar_events_controller.get_by_room( + room.id, include_deleted=False + ) + assert len(events) == 1 + assert events[0].ics_uid == "past-event" + + +@pytest.mark.asyncio +async def test_calendar_event_with_raw_ics_data(): + """Test storing raw ICS data with calendar event.""" + # Create room + room = await rooms_controller.add( + name="raw-ics-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ) + + raw_ics = """BEGIN:VEVENT +UID:test-raw-123 +SUMMARY:Test Event +DTSTART:20240101T100000Z +DTEND:20240101T110000Z +END:VEVENT""" + + event = CalendarEvent( + room_id=room.id, + ics_uid="test-raw-123", + title="Test Event", + start_time=datetime.now(timezone.utc), + end_time=datetime.now(timezone.utc) + timedelta(hours=1), + ics_raw_data=raw_ics, + ) + + saved = await calendar_events_controller.upsert(event) + + assert saved.ics_raw_data == raw_ics + + # Retrieve and verify + retrieved = await calendar_events_controller.get_by_ics_uid(room.id, "test-raw-123") + assert retrieved is not None + assert retrieved.ics_raw_data == raw_ics diff --git a/server/tests/test_ics_sync.py b/server/tests/test_ics_sync.py new file mode 100644 index 00000000..e4bada05 --- /dev/null +++ b/server/tests/test_ics_sync.py @@ -0,0 +1,289 @@ +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from icalendar import Calendar, Event + +from reflector.db.calendar_events import calendar_events_controller +from reflector.db.rooms import rooms_controller +from reflector.services.ics_sync import ICSFetchService, ICSSyncService + + +@pytest.mark.asyncio +async def test_ics_fetch_service_event_matching(): + service = ICSFetchService() + room_name = "test-room" + room_url = "https://example.com/room/test-room" + + # Create test event + event = Event() + event.add("uid", "test-123") + event.add("summary", "Test Meeting") + + # Test matching with full URL in location + event.add("location", "https://example.com/room/test-room") + assert service._event_matches_room(event, room_name, room_url) is True + + # Test matching with URL without protocol + event["location"] = "example.com/room/test-room" + assert service._event_matches_room(event, room_name, room_url) is True + + # Test matching in description + event["location"] = "Conference Room A" + event.add("description", f"Join at {room_url}") + assert service._event_matches_room(event, room_name, room_url) is True + + # Test non-matching + event["location"] = "Different Room" + event["description"] = "No room URL here" + assert service._event_matches_room(event, room_name, room_url) is False + + # Test partial paths should NOT match anymore + event["location"] = "/room/test-room" + assert service._event_matches_room(event, room_name, room_url) is False + + event["location"] = f"Room: {room_name}" + assert service._event_matches_room(event, room_name, room_url) is False + + +@pytest.mark.asyncio +async def test_ics_fetch_service_parse_event(): + service = ICSFetchService() + + # Create test event + event = Event() + event.add("uid", "test-456") + event.add("summary", "Team Standup") + event.add("description", "Daily team sync") + event.add("location", "https://example.com/room/standup") + + now = datetime.now(timezone.utc) + event.add("dtstart", now) + event.add("dtend", now + timedelta(hours=1)) + + # Add attendees + event.add("attendee", "mailto:alice@example.com", parameters={"CN": "Alice"}) + event.add("attendee", "mailto:bob@example.com", parameters={"CN": "Bob"}) + event.add("organizer", "mailto:carol@example.com", parameters={"CN": "Carol"}) + + # Parse event + result = service._parse_event(event) + + assert result is not None + assert result["ics_uid"] == "test-456" + assert result["title"] == "Team Standup" + assert result["description"] == "Daily team sync" + assert result["location"] == "https://example.com/room/standup" + assert len(result["attendees"]) == 3 # 2 attendees + 1 organizer + + +@pytest.mark.asyncio +async def test_ics_fetch_service_extract_room_events(): + service = ICSFetchService() + room_name = "meeting" + room_url = "https://example.com/room/meeting" + + # Create calendar with multiple events + cal = Calendar() + + # Event 1: Matches room + event1 = Event() + event1.add("uid", "match-1") + event1.add("summary", "Planning Meeting") + event1.add("location", room_url) + now = datetime.now(timezone.utc) + event1.add("dtstart", now + timedelta(hours=2)) + event1.add("dtend", now + timedelta(hours=3)) + cal.add_component(event1) + + # Event 2: Doesn't match room + event2 = Event() + event2.add("uid", "no-match") + event2.add("summary", "Other Meeting") + event2.add("location", "https://example.com/room/other") + event2.add("dtstart", now + timedelta(hours=4)) + event2.add("dtend", now + timedelta(hours=5)) + cal.add_component(event2) + + # Event 3: Matches room in description + event3 = Event() + event3.add("uid", "match-2") + event3.add("summary", "Review Session") + event3.add("description", f"Meeting link: {room_url}") + event3.add("dtstart", now + timedelta(hours=6)) + event3.add("dtend", now + timedelta(hours=7)) + cal.add_component(event3) + + # Event 4: Cancelled event (should be skipped) + event4 = Event() + event4.add("uid", "cancelled") + event4.add("summary", "Cancelled Meeting") + event4.add("location", room_url) + event4.add("status", "CANCELLED") + event4.add("dtstart", now + timedelta(hours=8)) + event4.add("dtend", now + timedelta(hours=9)) + cal.add_component(event4) + + # Extract events + events = service.extract_room_events(cal, room_name, room_url) + + assert len(events) == 2 + assert events[0]["ics_uid"] == "match-1" + assert events[1]["ics_uid"] == "match-2" + + +@pytest.mark.asyncio +async def test_ics_sync_service_sync_room_calendar(): + # Create room + room = await rooms_controller.add( + name="sync-test", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_url="https://calendar.example.com/test.ics", + ics_enabled=True, + ) + + # Mock ICS content + cal = Calendar() + event = Event() + event.add("uid", "sync-event-1") + event.add("summary", "Sync Test Meeting") + # Use the actual BASE_URL from settings + from reflector.settings import settings + + event.add("location", f"{settings.BASE_URL}/room/{room.name}") + now = datetime.now(timezone.utc) + event.add("dtstart", now + timedelta(hours=1)) + event.add("dtend", now + timedelta(hours=2)) + cal.add_component(event) + ics_content = cal.to_ical().decode("utf-8") + + # Create sync service and mock fetch + sync_service = ICSSyncService() + + with patch.object( + sync_service.fetch_service, "fetch_ics", new_callable=AsyncMock + ) as mock_fetch: + mock_fetch.return_value = ics_content + + # First sync + result = await sync_service.sync_room_calendar(room) + + assert result["status"] == "success" + assert result["events_found"] == 1 + assert result["events_created"] == 1 + assert result["events_updated"] == 0 + assert result["events_deleted"] == 0 + + # Verify event was created + events = await calendar_events_controller.get_by_room(room.id) + assert len(events) == 1 + assert events[0].ics_uid == "sync-event-1" + assert events[0].title == "Sync Test Meeting" + + # Second sync with same content (should be unchanged) + # Refresh room to get updated etag and force sync by setting old sync time + room = await rooms_controller.get_by_id(room.id) + await rooms_controller.update( + room, {"ics_last_sync": datetime.now(timezone.utc) - timedelta(minutes=10)} + ) + result = await sync_service.sync_room_calendar(room) + assert result["status"] == "unchanged" + + # Third sync with updated event + event["summary"] = "Updated Meeting Title" + cal = Calendar() + cal.add_component(event) + ics_content = cal.to_ical().decode("utf-8") + mock_fetch.return_value = ics_content + + # Force sync by clearing etag + await rooms_controller.update(room, {"ics_last_etag": None}) + + result = await sync_service.sync_room_calendar(room) + assert result["status"] == "success" + assert result["events_created"] == 0 + assert result["events_updated"] == 1 + + # Verify event was updated + events = await calendar_events_controller.get_by_room(room.id) + assert len(events) == 1 + assert events[0].title == "Updated Meeting Title" + + +@pytest.mark.asyncio +async def test_ics_sync_service_should_sync(): + service = ICSSyncService() + + # Room never synced + room = MagicMock() + room.ics_last_sync = None + room.ics_fetch_interval = 300 + assert service._should_sync(room) is True + + # Room synced recently + room.ics_last_sync = datetime.now(timezone.utc) - timedelta(seconds=100) + assert service._should_sync(room) is False + + # Room sync due + room.ics_last_sync = datetime.now(timezone.utc) - timedelta(seconds=400) + assert service._should_sync(room) is True + + +@pytest.mark.asyncio +async def test_ics_sync_service_skip_disabled(): + service = ICSSyncService() + + # Room with ICS disabled + room = MagicMock() + room.ics_enabled = False + room.ics_url = "https://calendar.example.com/test.ics" + + result = await service.sync_room_calendar(room) + assert result["status"] == "skipped" + assert result["reason"] == "ICS not configured" + + # Room without URL + room.ics_enabled = True + room.ics_url = None + + result = await service.sync_room_calendar(room) + assert result["status"] == "skipped" + assert result["reason"] == "ICS not configured" + + +@pytest.mark.asyncio +async def test_ics_sync_service_error_handling(): + # Create room + room = await rooms_controller.add( + name="error-test", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_url="https://calendar.example.com/error.ics", + ics_enabled=True, + ) + + sync_service = ICSSyncService() + + with patch.object( + sync_service.fetch_service, "fetch_ics", new_callable=AsyncMock + ) as mock_fetch: + mock_fetch.side_effect = Exception("Network error") + + result = await sync_service.sync_room_calendar(room) + assert result["status"] == "error" + assert "Network error" in result["error"] diff --git a/server/tests/test_room_ics.py b/server/tests/test_room_ics.py new file mode 100644 index 00000000..7a3c4d74 --- /dev/null +++ b/server/tests/test_room_ics.py @@ -0,0 +1,225 @@ +""" +Tests for Room model ICS calendar integration fields. +""" + +from datetime import datetime, timezone + +import pytest + +from reflector.db.rooms import rooms_controller + + +@pytest.mark.asyncio +async def test_room_create_with_ics_fields(): + """Test creating a room with ICS calendar fields.""" + room = await rooms_controller.add( + name="test-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_url="https://calendar.google.com/calendar/ical/test/private-token/basic.ics", + ics_fetch_interval=600, + ics_enabled=True, + ) + + assert room.name == "test-room" + assert ( + room.ics_url + == "https://calendar.google.com/calendar/ical/test/private-token/basic.ics" + ) + assert room.ics_fetch_interval == 600 + assert room.ics_enabled is True + assert room.ics_last_sync is None + assert room.ics_last_etag is None + + +@pytest.mark.asyncio +async def test_room_update_ics_configuration(): + """Test updating room ICS configuration.""" + # Create room without ICS + room = await rooms_controller.add( + name="update-test", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ) + + assert room.ics_enabled is False + assert room.ics_url is None + + # Update with ICS configuration + await rooms_controller.update( + room, + { + "ics_url": "https://outlook.office365.com/owa/calendar/test/calendar.ics", + "ics_fetch_interval": 300, + "ics_enabled": True, + }, + ) + + assert ( + room.ics_url == "https://outlook.office365.com/owa/calendar/test/calendar.ics" + ) + assert room.ics_fetch_interval == 300 + assert room.ics_enabled is True + + +@pytest.mark.asyncio +async def test_room_ics_sync_metadata(): + """Test updating room ICS sync metadata.""" + room = await rooms_controller.add( + name="sync-test", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_url="https://example.com/calendar.ics", + ics_enabled=True, + ) + + # Update sync metadata + sync_time = datetime.now(timezone.utc) + await rooms_controller.update( + room, + { + "ics_last_sync": sync_time, + "ics_last_etag": "abc123hash", + }, + ) + + assert room.ics_last_sync == sync_time + assert room.ics_last_etag == "abc123hash" + + +@pytest.mark.asyncio +async def test_room_get_with_ics_fields(): + """Test retrieving room with ICS fields.""" + # Create room + created_room = await rooms_controller.add( + name="get-test", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_url="webcal://calendar.example.com/feed.ics", + ics_fetch_interval=900, + ics_enabled=True, + ) + + # Get by ID + room = await rooms_controller.get_by_id(created_room.id) + assert room is not None + assert room.ics_url == "webcal://calendar.example.com/feed.ics" + assert room.ics_fetch_interval == 900 + assert room.ics_enabled is True + + # Get by name + room = await rooms_controller.get_by_name("get-test") + assert room is not None + assert room.ics_url == "webcal://calendar.example.com/feed.ics" + assert room.ics_fetch_interval == 900 + assert room.ics_enabled is True + + +@pytest.mark.asyncio +async def test_room_list_with_ics_enabled_filter(): + """Test listing rooms filtered by ICS enabled status.""" + # Create rooms with and without ICS + room1 = await rooms_controller.add( + name="ics-enabled-1", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=True, + ics_enabled=True, + ics_url="https://calendar1.example.com/feed.ics", + ) + + room2 = await rooms_controller.add( + name="ics-disabled", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=True, + ics_enabled=False, + ) + + room3 = await rooms_controller.add( + name="ics-enabled-2", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=True, + ics_enabled=True, + ics_url="https://calendar2.example.com/feed.ics", + ) + + # Get all rooms + all_rooms = await rooms_controller.get_all() + assert len(all_rooms) == 3 + + # Filter for ICS-enabled rooms (would need to implement this in controller) + ics_rooms = [r for r in all_rooms if r["ics_enabled"]] + assert len(ics_rooms) == 2 + assert all(r["ics_enabled"] for r in ics_rooms) + + +@pytest.mark.asyncio +async def test_room_default_ics_values(): + """Test that ICS fields have correct default values.""" + room = await rooms_controller.add( + name="default-test", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + # Don't specify ICS fields + ) + + assert room.ics_url is None + assert room.ics_fetch_interval == 300 # Default 5 minutes + assert room.ics_enabled is False + assert room.ics_last_sync is None + assert room.ics_last_etag is None diff --git a/server/uv.lock b/server/uv.lock index adeace10..d581afc6 100644 --- a/server/uv.lock +++ b/server/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.11, <3.13" resolution-markers = [ "python_full_version >= '3.12'", @@ -1163,6 +1163,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" }, ] +[[package]] +name = "icalendar" +version = "6.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/13/e5899c916dcf1343ea65823eb7278d3e1a1d679f383f6409380594b5f322/icalendar-6.3.1.tar.gz", hash = "sha256:a697ce7b678072941e519f2745704fc29d78ef92a2dc53d9108ba6a04aeba466", size = 177169, upload-time = "2025-05-20T07:42:50.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/25/b5fc00e85d2dfaf5c806ac8b5f1de072fa11630c5b15b4ae5bbc228abd51/icalendar-6.3.1-py3-none-any.whl", hash = "sha256:7ea1d1b212df685353f74cdc6ec9646bf42fa557d1746ea645ce8779fdfbecdd", size = 242349, upload-time = "2025-05-20T07:42:48.589Z" }, +] + [[package]] name = "idna" version = "3.10" @@ -2661,6 +2674,7 @@ dependencies = [ { name = "fastapi-pagination" }, { name = "faster-whisper" }, { name = "httpx" }, + { name = "icalendar" }, { name = "jsonschema" }, { name = "llama-index" }, { name = "llama-index-llms-openai-like" }, @@ -2727,6 +2741,7 @@ requires-dist = [ { name = "fastapi-pagination", specifier = ">=0.12.6" }, { name = "faster-whisper", specifier = ">=0.10.0" }, { name = "httpx", specifier = ">=0.24.1" }, + { name = "icalendar", specifier = ">=6.0.0" }, { name = "jsonschema", specifier = ">=4.23.0" }, { name = "llama-index", specifier = ">=0.12.52" }, { name = "llama-index-llms-openai-like", specifier = ">=0.4.0" },