feat: update ics, first version working

This commit is contained in:
2025-09-05 15:24:53 -06:00
parent 81ec17d009
commit d53edfa8dd
7 changed files with 691 additions and 28 deletions

View File

@@ -0,0 +1,46 @@
"""add_ics_uid_to_calendar_event
Revision ID: a256772ef058
Revises: d4a1c446458c
Create Date: 2025-08-19 09:27:26.472456
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "a256772ef058"
down_revision: Union[str, None] = "d4a1c446458c"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("calendar_event", schema=None) as batch_op:
batch_op.add_column(sa.Column("ics_uid", sa.Text(), nullable=False))
batch_op.drop_constraint(batch_op.f("uq_room_calendar_event"), type_="unique")
batch_op.create_unique_constraint(
"uq_room_calendar_event", ["room_id", "ics_uid"]
)
batch_op.drop_column("external_id")
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("calendar_event", schema=None) as batch_op:
batch_op.add_column(
sa.Column("external_id", sa.TEXT(), autoincrement=False, nullable=True)
)
batch_op.drop_constraint("uq_room_calendar_event", type_="unique")
batch_op.create_unique_constraint(
batch_op.f("uq_room_calendar_event"), ["room_id", "external_id"]
)
batch_op.drop_column("ics_uid")
# ### end Alembic commands ###

View File

@@ -36,6 +36,18 @@ class SyncStats(TypedDict):
events_deleted: int
class SyncResult(TypedDict, total=False):
status: str # "success", "unchanged", "error", "skipped"
hash: str | None
events_found: int
total_events: int
events_created: int
events_updated: int
events_deleted: int
error: str | None
reason: str | None
class ICSFetchService:
def __init__(self):
self.client = httpx.AsyncClient(
@@ -53,8 +65,9 @@ class ICSFetchService:
def extract_room_events(
self, calendar: Calendar, room_name: str, room_url: str
) -> list[EventData]:
) -> tuple[list[EventData], int]:
events = []
total_events = 0
now = datetime.now(timezone.utc)
window_start = now - timedelta(hours=1)
window_end = now + timedelta(hours=24)
@@ -66,18 +79,19 @@ class ICSFetchService:
if status == "CANCELLED":
continue
# Check if event matches this room
if self._event_matches_room(component, room_name, room_url):
# Count total non-cancelled events in the time window
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
):
total_events += 1
# Check if event matches this room
if self._event_matches_room(component, room_name, room_url):
events.append(event_data)
return events
return events, total_events
def _event_matches_room(self, event: Event, room_name: str, room_url: str) -> bool:
location = str(event.get("LOCATION", ""))
@@ -160,11 +174,10 @@ class ICSFetchService:
attendees = []
# Parse ATTENDEE properties
for attendee in event.get("ATTENDEE", []):
if not isinstance(attendee, list):
attendee = [attendee]
for att in attendee:
attendees = event.get("ATTENDEE", [])
if not isinstance(attendees, list):
attendees = [attendees]
for att in attendees:
att_data: AttendeeData = {
"email": str(att).replace("mailto:", "") if att else None,
"name": att.params.get("CN") if hasattr(att, "params") else None,
@@ -194,7 +207,7 @@ class ICSSyncService:
def __init__(self):
self.fetch_service = ICSFetchService()
async def sync_room_calendar(self, room: Room) -> dict:
async def sync_room_calendar(self, room: Room) -> SyncResult:
if not room.ics_enabled or not room.ics_url:
return {"status": "skipped", "reason": "ICS not configured"}
@@ -210,7 +223,21 @@ class ICSSyncService:
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}
# Still parse to get event count
calendar = self.fetch_service.parse_ics(ics_content)
room_url = f"{settings.BASE_URL}/room/{room.name}"
events, total_events = self.fetch_service.extract_room_events(
calendar, room.name, room_url
)
return {
"status": "unchanged",
"hash": content_hash,
"events_found": len(events),
"total_events": total_events,
"events_created": 0,
"events_updated": 0,
"events_deleted": 0,
}
# Parse calendar
calendar = self.fetch_service.parse_ics(ics_content)
@@ -219,7 +246,7 @@ class ICSSyncService:
room_url = f"{settings.BASE_URL}/room/{room.name}"
# Extract matching events
events = self.fetch_service.extract_room_events(
events, total_events = self.fetch_service.extract_room_events(
calendar, room.name, room_url
)
@@ -240,6 +267,7 @@ class ICSSyncService:
"status": "success",
"hash": content_hash,
"events_found": len(events),
"total_events": total_events,
**sync_result,
}

View File

@@ -1,6 +1,7 @@
import logging
import sqlite3
from datetime import datetime, timedelta, timezone
from enum import Enum
from typing import Annotated, Any, Literal, Optional
import asyncpg.exceptions
@@ -300,14 +301,23 @@ class ICSStatus(BaseModel):
events_count: int = 0
class SyncStatus(str, Enum):
success = "success"
unchanged = "unchanged"
error = "error"
skipped = "skipped"
class ICSSyncResult(BaseModel):
status: str
status: SyncStatus
hash: Optional[str] = None
events_found: int = 0
total_events: int = 0
events_created: int = 0
events_updated: int = 0
events_deleted: int = 0
error: Optional[str] = None
reason: Optional[str] = None
@router.post("/rooms/{room_name}/ics/sync", response_model=ICSSyncResult)

29
server/test.ics Normal file
View File

@@ -0,0 +1,29 @@
BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
METHOD:PUBLISH
PRODID:-//Fastmail/2020.5/EN
X-APPLE-CALENDAR-COLOR:#0F6A0F
X-WR-CALNAME:Test reflector
X-WR-TIMEZONE:America/Costa_Rica
BEGIN:VTIMEZONE
TZID:America/Costa_Rica
BEGIN:STANDARD
DTSTART:19700101T000000
TZOFFSETFROM:-0600
TZOFFSETTO:-0600
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
ATTENDEE;CN=Mathieu Virbel;PARTSTAT=ACCEPTED:MAILTO:mathieu@monadical.com
DTEND;TZID=America/Costa_Rica:20250819T143000
DTSTAMP:20250819T155951Z
DTSTART;TZID=America/Costa_Rica:20250819T140000
LOCATION:http://localhost:1250/room/mathieu
ORGANIZER;CN=Mathieu Virbel:MAILTO:mathieu@monadical.com
SEQUENCE:1
SUMMARY:Checkin
TRANSP:OPAQUE
UID:867df50d-8105-4c58-9280-2b5d26cc9cd3
END:VEVENT
END:VCALENDAR

View File

@@ -0,0 +1,18 @@
BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
METHOD:PUBLISH
PRODID:-//Test/1.0/EN
X-WR-CALNAME:Test Attendee Bug
BEGIN:VEVENT
ATTENDEE:MAILTO:alice@example.com,bob@example.com,charlie@example.com,diana@example.com,eve@example.com,frank@example.com,george@example.com,helen@example.com,ivan@example.com,jane@example.com,kevin@example.com,laura@example.com,mike@example.com,nina@example.com,oscar@example.com,paul@example.com,queen@example.com,robert@example.com,sarah@example.com,tom@example.com,ursula@example.com,victor@example.com,wendy@example.com,xavier@example.com,yvonne@example.com,zack@example.com,amy@example.com,bill@example.com,carol@example.com
DTEND:20250819T190000Z
DTSTAMP:20250819T174000Z
DTSTART:20250819T180000Z
LOCATION:http://localhost:1250/room/test-room
ORGANIZER;CN=Test Organizer:MAILTO:organizer@example.com
SEQUENCE:1
SUMMARY:Test Meeting with Many Attendees
UID:test-attendee-bug-event
END:VEVENT
END:VCALENDAR

View File

@@ -0,0 +1,167 @@
import os
from unittest.mock import AsyncMock, patch
import pytest
from reflector.db.rooms import rooms_controller
from reflector.services.ics_sync import ICSSyncService
@pytest.mark.asyncio
async def test_attendee_parsing_bug():
"""
Test that reproduces the attendee parsing bug where a string with comma-separated
emails gets parsed as individual characters instead of separate email addresses.
The bug manifests as getting 29 attendees with emails like "M", "A", "I", etc.
instead of properly parsed email addresses.
"""
# Create a test room
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="http://test.com/test.ics",
ics_enabled=True,
)
# Read the test ICS file that reproduces the bug
test_ics_path = os.path.join(
os.path.dirname(__file__), "test_attendee_parsing_bug.ics"
)
with open(test_ics_path, "r") as f:
ics_content = f.read()
# Create sync service and mock the 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
# Debug: Parse the ICS content directly to examine attendee parsing
calendar = sync_service.fetch_service.parse_ics(ics_content)
from reflector.settings import settings
room_url = f"{settings.BASE_URL}/room/{room.name}"
print(f"Room URL being used for matching: {room_url}")
print(f"ICS content:\n{ics_content}")
events, total_events = sync_service.fetch_service.extract_room_events(
calendar, room.name, room_url
)
print(f"Total events in calendar: {total_events}")
print(f"Events matching room: {len(events)}")
# Perform the sync
result = await sync_service.sync_room_calendar(room)
# Check that the sync succeeded
assert result.get("status") == "success"
assert result.get("events_found", 0) >= 0 # Allow for debugging
# We already have the matching events from the debug code above
assert len(events) == 1
event = events[0]
# This is where the bug manifests - check the attendees
attendees = event["attendees"]
# Print attendee info for debugging
print(f"Number of attendees found: {len(attendees)}")
for i, attendee in enumerate(attendees):
print(
f"Attendee {i}: email='{attendee.get('email')}', name='{attendee.get('name')}'"
)
# The bug would cause individual characters to be parsed as attendees
# Check if we have the problematic parsing (emails like "M", "A", "I", etc.)
single_char_emails = [
att for att in attendees if att.get("email") and len(att["email"]) == 1
]
if single_char_emails:
print(
f"BUG DETECTED: Found {len(single_char_emails)} single-character emails:"
)
for att in single_char_emails:
print(f" - '{att['email']}'")
# For now, just assert that we have attendees (the test will show the bug)
# In a fix, we would expect proper email addresses, not single characters
assert len(attendees) > 0
if len(attendees) > 3:
pytest.fail(
f"ATTENDEE PARSING BUG DETECTED: "
f"Found {len(attendees)} attendees with {len(single_char_emails)} single-character emails. "
f"This suggests a comma-separated string was parsed as individual characters."
)
@pytest.mark.asyncio
async def test_correct_attendee_parsing():
"""
Test what correct attendee parsing should look like.
"""
from datetime import datetime, timezone
from icalendar import Event
from reflector.services.ics_sync import ICSFetchService
service = ICSFetchService()
# Create a properly formatted event with multiple attendees
event = Event()
event.add("uid", "test-correct-attendees")
event.add("summary", "Test Meeting")
event.add("location", "http://test.com/test")
event.add("dtstart", datetime.now(timezone.utc))
event.add("dtend", datetime.now(timezone.utc))
# Add attendees the correct way (separate ATTENDEE lines)
event.add("attendee", "mailto:alice@example.com", parameters={"CN": "Alice"})
event.add("attendee", "mailto:bob@example.com", parameters={"CN": "Bob"})
event.add("attendee", "mailto:charlie@example.com", parameters={"CN": "Charlie"})
event.add(
"organizer", "mailto:organizer@example.com", parameters={"CN": "Organizer"}
)
# Parse the event
result = service._parse_event(event)
assert result is not None
attendees = result["attendees"]
# Should have 4 attendees (3 attendees + 1 organizer)
assert len(attendees) == 4
# Check that all emails are valid email addresses
emails = [att["email"] for att in attendees if att.get("email")]
expected_emails = [
"alice@example.com",
"bob@example.com",
"charlie@example.com",
"organizer@example.com",
]
for email in emails:
assert "@" in email, f"Invalid email format: {email}"
assert len(email) > 5, f"Email too short: {email}"
# Check that we have the expected emails
assert "alice@example.com" in emails
assert "bob@example.com" in emails
assert "charlie@example.com" in emails
assert "organizer@example.com" in emails

View File

@@ -0,0 +1,365 @@
"use client";
import {
Box,
VStack,
Heading,
Text,
Card,
HStack,
Badge,
Spinner,
Flex,
Link,
Button,
Alert,
IconButton,
Tooltip,
Wrap,
} from "@chakra-ui/react";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { FaSync, FaClock, FaUsers, FaEnvelope } from "react-icons/fa";
import { LuArrowLeft } from "react-icons/lu";
import useApi from "../../../../lib/useApi";
import { CalendarEventResponse } from "../../../../api";
export default function RoomCalendarPage() {
const params = useParams();
const router = useRouter();
const roomName = params.roomName as string;
const api = useApi();
const [events, setEvents] = useState<CalendarEventResponse[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [syncing, setSyncing] = useState(false);
const fetchEvents = async () => {
if (!api) return;
try {
setLoading(true);
setError(null);
const response = await api.v1RoomsListMeetings({ roomName });
setEvents(response);
} catch (err: any) {
setError(err.body?.detail || "Failed to load calendar events");
} finally {
setLoading(false);
}
};
const handleSync = async () => {
if (!api) return;
try {
setSyncing(true);
await api.v1RoomsSyncIcs({ roomName });
await fetchEvents(); // Refresh events after sync
} catch (err: any) {
setError(err.body?.detail || "Failed to sync calendar");
} finally {
setSyncing(false);
}
};
useEffect(() => {
fetchEvents();
}, [api, roomName]);
const formatEventTime = (start: string, end: string) => {
const startDate = new Date(start);
const endDate = new Date(end);
const options: Intl.DateTimeFormatOptions = {
hour: "2-digit",
minute: "2-digit",
};
const dateOptions: Intl.DateTimeFormatOptions = {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
};
const isSameDay = startDate.toDateString() === endDate.toDateString();
if (isSameDay) {
return `${startDate.toLocaleDateString(undefined, dateOptions)}${startDate.toLocaleTimeString(undefined, options)} - ${endDate.toLocaleTimeString(undefined, options)}`;
} else {
return `${startDate.toLocaleDateString(undefined, dateOptions)} ${startDate.toLocaleTimeString(undefined, options)} - ${endDate.toLocaleDateString(undefined, dateOptions)} ${endDate.toLocaleTimeString(undefined, options)}`;
}
};
const isEventActive = (start: string, end: string) => {
const now = new Date();
const startDate = new Date(start);
const endDate = new Date(end);
return now >= startDate && now <= endDate;
};
const isEventUpcoming = (start: string) => {
const now = new Date();
const startDate = new Date(start);
const hourFromNow = new Date(now.getTime() + 60 * 60 * 1000);
return startDate > now && startDate <= hourFromNow;
};
const getAttendeeDisplay = (attendee: any) => {
// Use name if available, otherwise use email
const displayName = attendee.name || attendee.email || "Unknown";
// Extract just the name part if it's in "Name <email>" format
const cleanName = displayName.replace(/<.*>/, "").trim();
return cleanName;
};
const getAttendeeEmail = (attendee: any) => {
return attendee.email || "";
};
const renderAttendees = (attendees: any[]) => {
if (!attendees || attendees.length === 0) return null;
return (
<HStack fontSize="sm" color="gray.600" flexWrap="wrap">
<FaUsers />
<Text>Attendees:</Text>
<Wrap spacing={2}>
{attendees.map((attendee, index) => {
const email = getAttendeeEmail(attendee);
const display = getAttendeeDisplay(attendee);
if (email && email !== display) {
return (
<Tooltip
key={index}
content={
<HStack>
<FaEnvelope size="12" />
<Text>{email}</Text>
</HStack>
}
>
<Badge variant="subtle" colorPalette="blue" cursor="help">
{display}
</Badge>
</Tooltip>
);
} else {
return (
<Badge key={index} variant="subtle" colorPalette="blue">
{display}
</Badge>
);
}
})}
</Wrap>
</HStack>
);
};
const sortedEvents = [...events].sort(
(a, b) =>
new Date(a.start_time).getTime() - new Date(b.start_time).getTime(),
);
// Separate events by status
const now = new Date();
const activeEvents = sortedEvents.filter((e) =>
isEventActive(e.start_time, e.end_time),
);
const upcomingEvents = sortedEvents.filter(
(e) => new Date(e.start_time) > now,
);
const pastEvents = sortedEvents
.filter((e) => new Date(e.end_time) < now)
.reverse();
return (
<Box w={{ base: "full", md: "container.xl" }} mx="auto" pt={2}>
<VStack align="stretch" spacing={6}>
<Flex justify="space-between" align="center">
<HStack spacing={3}>
<IconButton
aria-label="Back to rooms"
title="Back to rooms"
size="sm"
variant="ghost"
onClick={() => router.push("/rooms")}
>
<LuArrowLeft />
</IconButton>
<Heading size="lg">Calendar for {roomName}</Heading>
</HStack>
<Button
colorPalette="blue"
onClick={handleSync}
leftIcon={syncing ? <Spinner size="sm" /> : <FaSync />}
disabled={syncing}
>
Force Sync
</Button>
</Flex>
{error && (
<Alert.Root status="error">
<Alert.Indicator />
<Alert.Title>{error}</Alert.Title>
</Alert.Root>
)}
{loading ? (
<Flex justify="center" py={8}>
<Spinner size="xl" />
</Flex>
) : events.length === 0 ? (
<Card.Root>
<Card.Body>
<Text textAlign="center" color="gray.500">
No calendar events found. Make sure your calendar is configured
and synced.
</Text>
</Card.Body>
</Card.Root>
) : (
<VStack align="stretch" spacing={6}>
{/* Active Events */}
{activeEvents.length > 0 && (
<Box>
<Heading size="md" mb={3} color="green.600">
Active Now
</Heading>
<VStack align="stretch" spacing={3}>
{activeEvents.map((event) => (
<Card.Root
key={event.id}
borderColor="green.200"
borderWidth={2}
>
<Card.Body>
<Flex justify="space-between" align="start">
<VStack align="start" spacing={2} flex={1}>
<HStack>
<Heading size="sm">
{event.title || "Untitled Event"}
</Heading>
<Badge colorPalette="green">Active</Badge>
</HStack>
<HStack fontSize="sm" color="gray.600">
<FaClock />
<Text>
{formatEventTime(
event.start_time,
event.end_time,
)}
</Text>
</HStack>
{event.description && (
<Text
fontSize="sm"
color="gray.700"
noOfLines={2}
>
{event.description}
</Text>
)}
{renderAttendees(event.attendees)}
</VStack>
<Link href={`/${roomName}`}>
<Button size="sm" colorPalette="green">
Join Room
</Button>
</Link>
</Flex>
</Card.Body>
</Card.Root>
))}
</VStack>
</Box>
)}
{/* Upcoming Events */}
{upcomingEvents.length > 0 && (
<Box>
<Heading size="md" mb={3}>
Upcoming Events
</Heading>
<VStack align="stretch" spacing={3}>
{upcomingEvents.map((event) => (
<Card.Root key={event.id}>
<Card.Body>
<VStack align="start" spacing={2}>
<HStack>
<Heading size="sm">
{event.title || "Untitled Event"}
</Heading>
{isEventUpcoming(event.start_time) && (
<Badge colorPalette="orange">Starting Soon</Badge>
)}
</HStack>
<HStack fontSize="sm" color="gray.600">
<FaClock />
<Text>
{formatEventTime(
event.start_time,
event.end_time,
)}
</Text>
</HStack>
{event.description && (
<Text fontSize="sm" color="gray.700" noOfLines={2}>
{event.description}
</Text>
)}
{renderAttendees(event.attendees)}
</VStack>
</Card.Body>
</Card.Root>
))}
</VStack>
</Box>
)}
{/* Past Events */}
{pastEvents.length > 0 && (
<Box>
<Heading size="md" mb={3} color="gray.500">
Past Events
</Heading>
<VStack align="stretch" spacing={3}>
{pastEvents.slice(0, 5).map((event) => (
<Card.Root key={event.id} opacity={0.7}>
<Card.Body>
<VStack align="start" spacing={2}>
<Heading size="sm">
{event.title || "Untitled Event"}
</Heading>
<HStack fontSize="sm" color="gray.600">
<FaClock />
<Text>
{formatEventTime(
event.start_time,
event.end_time,
)}
</Text>
</HStack>
{renderAttendees(event.attendees)}
</VStack>
</Card.Body>
</Card.Root>
))}
{pastEvents.length > 5 && (
<Text fontSize="sm" color="gray.500" textAlign="center">
And {pastEvents.length - 5} more past events...
</Text>
)}
</VStack>
</Box>
)}
</VStack>
)}
</VStack>
</Box>
);
}