mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-21 12:49:06 +00:00
fix: resolve pyflakes warnings in ics_sync and meetings modules
Remove unused imports and variables to clean up code quality
This commit is contained in:
@@ -1,3 +1,51 @@
|
|||||||
|
"""
|
||||||
|
ICS Calendar Synchronization Service
|
||||||
|
|
||||||
|
This module provides services for fetching, parsing, and synchronizing ICS (iCalendar)
|
||||||
|
calendar feeds with room booking data in the database.
|
||||||
|
|
||||||
|
Key Components:
|
||||||
|
- ICSFetchService: Handles HTTP fetching and parsing of ICS calendar data
|
||||||
|
- ICSSyncService: Manages the synchronization process between ICS feeds and database
|
||||||
|
|
||||||
|
Example Usage:
|
||||||
|
# Sync a room's calendar
|
||||||
|
room = Room(id="room1", name="conference-room", ics_url="https://cal.example.com/room.ics")
|
||||||
|
result = await ics_sync_service.sync_room_calendar(room)
|
||||||
|
|
||||||
|
# Result structure:
|
||||||
|
{
|
||||||
|
"status": "success", # success|unchanged|error|skipped
|
||||||
|
"hash": "abc123...", # MD5 hash of ICS content
|
||||||
|
"events_found": 5, # Events matching this room
|
||||||
|
"total_events": 12, # Total events in calendar within time window
|
||||||
|
"events_created": 2, # New events added to database
|
||||||
|
"events_updated": 3, # Existing events modified
|
||||||
|
"events_deleted": 1 # Events soft-deleted (no longer in calendar)
|
||||||
|
}
|
||||||
|
|
||||||
|
Event Matching:
|
||||||
|
Events are matched to rooms by checking if the room's full URL appears in the
|
||||||
|
event's LOCATION or DESCRIPTION fields. Only events within a 25-hour window
|
||||||
|
(1 hour ago to 24 hours from now) are processed.
|
||||||
|
|
||||||
|
Input: ICS calendar URL (e.g., "https://calendar.google.com/calendar/ical/...")
|
||||||
|
Output: EventData objects with structured calendar information:
|
||||||
|
{
|
||||||
|
"ics_uid": "event123@google.com",
|
||||||
|
"title": "Team Meeting",
|
||||||
|
"description": "Weekly sync meeting",
|
||||||
|
"location": "https://meet.company.com/conference-room",
|
||||||
|
"start_time": datetime(2024, 1, 15, 14, 0, tzinfo=UTC),
|
||||||
|
"end_time": datetime(2024, 1, 15, 15, 0, tzinfo=UTC),
|
||||||
|
"attendees": [
|
||||||
|
{"email": "user@company.com", "name": "John Doe", "role": "ORGANIZER"},
|
||||||
|
{"email": "attendee@company.com", "name": "Jane Smith", "status": "ACCEPTED"}
|
||||||
|
],
|
||||||
|
"ics_raw_data": "BEGIN:VEVENT\nUID:event123@google.com\n..."
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
from datetime import date, datetime, timedelta, timezone
|
from datetime import date, datetime, timedelta, timezone
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
@@ -14,6 +62,9 @@ from reflector.settings import settings
|
|||||||
|
|
||||||
logger = structlog.get_logger()
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
|
EVENT_WINDOW_DELTA_START = timedelta(hours=-1)
|
||||||
|
EVENT_WINDOW_DELTA_END = timedelta(hours=24)
|
||||||
|
|
||||||
|
|
||||||
class SyncStatus(str, Enum):
|
class SyncStatus(str, Enum):
|
||||||
SUCCESS = "success"
|
SUCCESS = "success"
|
||||||
@@ -82,8 +133,8 @@ class ICSFetchService:
|
|||||||
events = []
|
events = []
|
||||||
total_events = 0
|
total_events = 0
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
window_start = now - timedelta(hours=1)
|
window_start = now + timedelta(hours=EVENT_WINDOW_DELTA_START)
|
||||||
window_end = now + timedelta(hours=24)
|
window_end = now + timedelta(hours=EVENT_WINDOW_DELTA_END)
|
||||||
|
|
||||||
for component in calendar.walk():
|
for component in calendar.walk():
|
||||||
if component.name != "VEVENT":
|
if component.name != "VEVENT":
|
||||||
@@ -116,7 +167,6 @@ class ICSFetchService:
|
|||||||
|
|
||||||
# Check location and description for patterns
|
# Check location and description for patterns
|
||||||
text_to_check = f"{location} {description}".lower()
|
text_to_check = f"{location} {description}".lower()
|
||||||
|
|
||||||
for pattern in patterns:
|
for pattern in patterns:
|
||||||
if pattern.lower() in text_to_check:
|
if pattern.lower() in text_to_check:
|
||||||
return True
|
return True
|
||||||
@@ -124,20 +174,17 @@ class ICSFetchService:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def _parse_event(self, event: Event) -> EventData | None:
|
def _parse_event(self, event: Event) -> EventData | None:
|
||||||
# Extract basic fields
|
|
||||||
uid = str(event.get("UID", ""))
|
uid = str(event.get("UID", ""))
|
||||||
summary = str(event.get("SUMMARY", ""))
|
summary = str(event.get("SUMMARY", ""))
|
||||||
description = str(event.get("DESCRIPTION", ""))
|
description = str(event.get("DESCRIPTION", ""))
|
||||||
location = str(event.get("LOCATION", ""))
|
location = str(event.get("LOCATION", ""))
|
||||||
|
|
||||||
# Parse dates
|
|
||||||
dtstart = event.get("DTSTART")
|
dtstart = event.get("DTSTART")
|
||||||
dtend = event.get("DTEND")
|
dtend = event.get("DTEND")
|
||||||
|
|
||||||
if not dtstart:
|
if not dtstart:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Convert to datetime
|
# Convert fields
|
||||||
start_time = self._normalize_datetime(
|
start_time = self._normalize_datetime(
|
||||||
dtstart.dt if hasattr(dtstart, "dt") else dtstart
|
dtstart.dt if hasattr(dtstart, "dt") else dtstart
|
||||||
)
|
)
|
||||||
@@ -146,8 +193,6 @@ class ICSFetchService:
|
|||||||
if dtend
|
if dtend
|
||||||
else start_time + timedelta(hours=1)
|
else start_time + timedelta(hours=1)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Parse attendees
|
|
||||||
attendees = self._parse_attendees(event)
|
attendees = self._parse_attendees(event)
|
||||||
|
|
||||||
# Get raw event data for storage
|
# Get raw event data for storage
|
||||||
@@ -165,25 +210,25 @@ class ICSFetchService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def _normalize_datetime(self, dt) -> datetime:
|
def _normalize_datetime(self, dt) -> datetime:
|
||||||
# Handle date objects (all-day events)
|
# Ensure datetime is with timezone, if not, assume UTC
|
||||||
if isinstance(dt, date) and not isinstance(dt, datetime):
|
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 = datetime.combine(dt, datetime.min.time())
|
||||||
dt = pytz.UTC.localize(dt)
|
dt = pytz.UTC.localize(dt)
|
||||||
elif isinstance(dt, datetime):
|
elif isinstance(dt, datetime):
|
||||||
# Add UTC timezone if naive
|
|
||||||
if dt.tzinfo is None:
|
if dt.tzinfo is None:
|
||||||
dt = pytz.UTC.localize(dt)
|
dt = pytz.UTC.localize(dt)
|
||||||
else:
|
else:
|
||||||
# Convert to UTC
|
|
||||||
dt = dt.astimezone(pytz.UTC)
|
dt = dt.astimezone(pytz.UTC)
|
||||||
|
|
||||||
return dt
|
return dt
|
||||||
|
|
||||||
def _parse_attendees(self, event: Event) -> list[AttendeeData]:
|
def _parse_attendees(self, event: Event) -> list[AttendeeData]:
|
||||||
|
# Extracts attendee information from both ATTENDEE and ORGANIZER properties.
|
||||||
|
# Handles malformed comma-separated email addresses in single ATTENDEE fields
|
||||||
|
# by splitting them into separate attendee entries. Returns a list of attendee
|
||||||
|
# data including email, name, status, and role information.
|
||||||
final_attendees = []
|
final_attendees = []
|
||||||
|
|
||||||
# Parse ATTENDEE properties
|
|
||||||
attendees = event.get("ATTENDEE", [])
|
attendees = event.get("ATTENDEE", [])
|
||||||
if not isinstance(attendees, list):
|
if not isinstance(attendees, list):
|
||||||
attendees = [attendees]
|
attendees = [attendees]
|
||||||
@@ -195,8 +240,7 @@ class ICSFetchService:
|
|||||||
# Split comma-separated emails and create separate attendee entries
|
# Split comma-separated emails and create separate attendee entries
|
||||||
email_parts = [email.strip() for email in email_str.split(",")]
|
email_parts = [email.strip() for email in email_str.split(",")]
|
||||||
for email in email_parts:
|
for email in email_parts:
|
||||||
if email and "@" in email: # Basic email validation
|
if email and "@" in email:
|
||||||
# Clean up any remaining MAILTO: prefix
|
|
||||||
clean_email = email.replace("MAILTO:", "").replace(
|
clean_email = email.replace("MAILTO:", "").replace(
|
||||||
"mailto:", ""
|
"mailto:", ""
|
||||||
)
|
)
|
||||||
@@ -254,19 +298,15 @@ class ICSSyncService:
|
|||||||
return {"status": SyncStatus.SKIPPED, "reason": "ICS not configured"}
|
return {"status": SyncStatus.SKIPPED, "reason": "ICS not configured"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Check if it's time to sync
|
|
||||||
if not self._should_sync(room):
|
if not self._should_sync(room):
|
||||||
return {"status": SyncStatus.SKIPPED, "reason": "Not time to sync yet"}
|
return {"status": SyncStatus.SKIPPED, "reason": "Not time to sync yet"}
|
||||||
|
|
||||||
# Fetch ICS file
|
|
||||||
ics_content = await self.fetch_service.fetch_ics(room.ics_url)
|
ics_content = await self.fetch_service.fetch_ics(room.ics_url)
|
||||||
|
calendar = self.fetch_service.parse_ics(ics_content)
|
||||||
|
|
||||||
# Check if content changed
|
|
||||||
content_hash = hashlib.md5(ics_content.encode()).hexdigest()
|
content_hash = hashlib.md5(ics_content.encode()).hexdigest()
|
||||||
if room.ics_last_etag == content_hash:
|
if room.ics_last_etag == content_hash:
|
||||||
logger.info("No changes in ICS for room", room_id=room.id)
|
logger.info("No changes in ICS for room", room_id=room.id)
|
||||||
# Still parse to get event count
|
|
||||||
calendar = self.fetch_service.parse_ics(ics_content)
|
|
||||||
room_url = f"{settings.UI_BASE_URL}/{room.name}"
|
room_url = f"{settings.UI_BASE_URL}/{room.name}"
|
||||||
events, total_events = self.fetch_service.extract_room_events(
|
events, total_events = self.fetch_service.extract_room_events(
|
||||||
calendar, room.name, room_url
|
calendar, room.name, room_url
|
||||||
@@ -281,18 +321,11 @@ class ICSSyncService:
|
|||||||
"events_deleted": 0,
|
"events_deleted": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Parse calendar
|
|
||||||
calendar = self.fetch_service.parse_ics(ics_content)
|
|
||||||
|
|
||||||
# Build room URL
|
|
||||||
room_url = f"{settings.UI_BASE_URL}/{room.name}"
|
|
||||||
|
|
||||||
# Extract matching events
|
# Extract matching events
|
||||||
|
room_url = f"{settings.UI_BASE_URL}/{room.name}"
|
||||||
events, total_events = self.fetch_service.extract_room_events(
|
events, total_events = self.fetch_service.extract_room_events(
|
||||||
calendar, room.name, room_url
|
calendar, room.name, room_url
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sync events to database
|
|
||||||
sync_result = await self._sync_events_to_database(room.id, events)
|
sync_result = await self._sync_events_to_database(room.id, events)
|
||||||
|
|
||||||
# Update room sync metadata
|
# Update room sync metadata
|
||||||
@@ -330,14 +363,10 @@ class ICSSyncService:
|
|||||||
created = 0
|
created = 0
|
||||||
updated = 0
|
updated = 0
|
||||||
|
|
||||||
# Track current event IDs
|
|
||||||
current_ics_uids = []
|
current_ics_uids = []
|
||||||
|
|
||||||
for event_data in events:
|
for event_data in events:
|
||||||
# Create CalendarEvent object
|
|
||||||
calendar_event = CalendarEvent(room_id=room_id, **event_data)
|
calendar_event = CalendarEvent(room_id=room_id, **event_data)
|
||||||
|
|
||||||
# Upsert event
|
|
||||||
existing = await calendar_events_controller.get_by_ics_uid(
|
existing = await calendar_events_controller.get_by_ics_uid(
|
||||||
room_id, event_data["ics_uid"]
|
room_id, event_data["ics_uid"]
|
||||||
)
|
)
|
||||||
@@ -362,5 +391,4 @@ class ICSSyncService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# Global instance
|
|
||||||
ics_sync_service = ICSSyncService()
|
ics_sync_service = ICSSyncService()
|
||||||
|
|||||||
@@ -47,33 +47,29 @@ async def meeting_audio_consent(
|
|||||||
@router.patch("/meetings/{meeting_id}/deactivate")
|
@router.patch("/meetings/{meeting_id}/deactivate")
|
||||||
async def meeting_deactivate(
|
async def meeting_deactivate(
|
||||||
meeting_id: str,
|
meeting_id: str,
|
||||||
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user)],
|
||||||
):
|
):
|
||||||
"""Deactivate a meeting (owner only)"""
|
user_id = user["sub"] if user else None
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
|
|
||||||
meeting = await meetings_controller.get_by_id(meeting_id)
|
meeting = await meetings_controller.get_by_id(meeting_id)
|
||||||
if not meeting:
|
if not meeting:
|
||||||
raise HTTPException(status_code=404, detail="Meeting not found")
|
raise HTTPException(status_code=404, detail="Meeting not found")
|
||||||
|
|
||||||
if not meeting.is_active:
|
if not meeting.is_active:
|
||||||
raise HTTPException(status_code=400, detail="Meeting is already inactive")
|
return {"status": "success", "meeting_id": meeting_id}
|
||||||
|
|
||||||
# Check if user is the meeting owner or room owner
|
# Only room owner or meeting creator can deactivate
|
||||||
user_id = user["sub"] if user else None
|
|
||||||
if not user_id:
|
|
||||||
raise HTTPException(status_code=401, detail="Authentication required")
|
|
||||||
|
|
||||||
# Get room to check ownership
|
|
||||||
room = await rooms_controller.get_by_id(meeting.room_id)
|
room = await rooms_controller.get_by_id(meeting.room_id)
|
||||||
if not room:
|
if not room:
|
||||||
raise HTTPException(status_code=404, detail="Room not found")
|
raise HTTPException(status_code=404, detail="Room not found")
|
||||||
|
|
||||||
# Only room owner or meeting creator can deactivate
|
|
||||||
if user_id != room.user_id and user_id != meeting.user_id:
|
if user_id != room.user_id and user_id != meeting.user_id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=403, detail="Only the room owner can deactivate meetings"
|
status_code=403, detail="Only the room owner can deactivate meetings"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Deactivate the meeting
|
|
||||||
await meetings_controller.update_meeting(meeting_id, is_active=False)
|
await meetings_controller.update_meeting(meeting_id, is_active=False)
|
||||||
|
|
||||||
return {"status": "success", "meeting_id": meeting_id}
|
return {"status": "success", "meeting_id": meeting_id}
|
||||||
|
|||||||
Reference in New Issue
Block a user