Skip to content

Commit e7bf925

Browse files
authored
Merge pull request #126 from hubert-marek/google-calendar-branch
Google calendar branch
2 parents 9dce451 + 048fa87 commit e7bf925

5 files changed

Lines changed: 506 additions & 245 deletions

File tree

.gitignore

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ celerybeat.pid
144144

145145
# Environments
146146
.env
147+
.env.*
147148
.envrc
148149
.venv
149150
env/
@@ -239,5 +240,8 @@ backend/src/services/linear/api/resolvers.py.backup
239240
backend/FILES_CREATED.md
240241
third_party/
241242
slack_credentials.md
242-
.env
243-
.env.local
243+
local_experiments/
244+
.claude/settings.local.json
245+
.claude/**
246+
!.claude/CLAUDE.md
247+
!.claude/settings.json

backend/src/services/calendar/api/methods.py

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import json
1111
import logging
12+
from datetime import timezone
1213
from typing import Any, Callable, Awaitable, Optional
1314
from functools import wraps
1415

@@ -105,9 +106,73 @@
105106
generate_resource_id,
106107
etags_match,
107108
REPLICA_NOW_RFC3339,
109+
parse_rfc3339,
108110
)
109111

110112

113+
# ============================================================================
114+
# WATCH EXPIRATION PARSING
115+
# ============================================================================
116+
117+
118+
def parse_watch_expiration(expiration: Any) -> Optional[int]:
119+
"""
120+
Parse expiration value for watch channels.
121+
122+
Google Calendar API accepts expiration as milliseconds since Unix epoch.
123+
Some clients may send ISO date strings which need to be converted.
124+
125+
Args:
126+
expiration: The expiration value (int, string int, or ISO date string)
127+
128+
Returns:
129+
Expiration in milliseconds since epoch, or None if not provided
130+
131+
Raises:
132+
ValidationError: If the expiration format is invalid
133+
"""
134+
if expiration is None:
135+
return None
136+
137+
# If already an int, return as-is
138+
if isinstance(expiration, int):
139+
return expiration
140+
141+
# If it's a string, try to parse it
142+
if isinstance(expiration, str):
143+
expiration = expiration.strip()
144+
if not expiration:
145+
return None
146+
147+
# Try parsing as integer (milliseconds)
148+
try:
149+
return int(expiration)
150+
except ValueError:
151+
pass
152+
153+
# Try parsing as ISO date string
154+
try:
155+
dt = parse_rfc3339(expiration)
156+
# If datetime is naive, assume UTC
157+
if dt.tzinfo is None:
158+
dt = dt.replace(tzinfo=timezone.utc)
159+
# Convert to milliseconds since epoch
160+
return int(dt.timestamp() * 1000)
161+
except (ValueError, AttributeError):
162+
pass
163+
164+
# Invalid format
165+
raise ValidationError(
166+
f"Invalid expiration format: {expiration}. "
167+
"Expected milliseconds since epoch or ISO 8601 date string."
168+
)
169+
170+
raise ValidationError(
171+
f"Invalid expiration type: {type(expiration).__name__}. "
172+
"Expected integer or string."
173+
)
174+
175+
111176
# ============================================================================
112177
# REQUEST UTILITIES
113178
# ============================================================================
@@ -1036,15 +1101,15 @@ async def calendar_list_watch(request: Request) -> JSONResponse:
10361101
from ..database.schema import Channel
10371102

10381103
resource_id = generate_resource_id()
1039-
expiration = body.get("expiration")
1104+
expiration_ms = parse_watch_expiration(body.get("expiration"))
10401105

10411106
channel = Channel(
10421107
id=channel_id,
10431108
resource_id=resource_id,
10441109
resource_uri=f"/users/me/calendarList",
10451110
type=channel_type,
10461111
address=address,
1047-
expiration=int(expiration) if expiration else None,
1112+
expiration=expiration_ms,
10481113
token=body.get("token"),
10491114
params=body.get("params"),
10501115
payload=body.get("payload", False),
@@ -1961,15 +2026,15 @@ async def events_watch(request: Request) -> JSONResponse:
19612026
from ..database.schema import Channel
19622027

19632028
resource_id = generate_resource_id()
1964-
expiration = body.get("expiration")
2029+
expiration_ms = parse_watch_expiration(body.get("expiration"))
19652030

19662031
channel = Channel(
19672032
id=channel_id,
19682033
resource_id=resource_id,
19692034
resource_uri=f"/calendars/{calendar_id}/events",
19702035
type=channel_type,
19712036
address=address,
1972-
expiration=int(expiration) if expiration else None,
2037+
expiration=expiration_ms,
19732038
token=body.get("token"),
19742039
params=body.get("params"),
19752040
payload=body.get("payload", False),
@@ -2400,15 +2465,15 @@ async def acl_watch(request: Request) -> JSONResponse:
24002465
from ..database.schema import Channel
24012466

24022467
resource_id = generate_resource_id()
2403-
expiration = body.get("expiration")
2468+
expiration_ms = parse_watch_expiration(body.get("expiration"))
24042469

24052470
channel = Channel(
24062471
id=channel_id,
24072472
resource_id=resource_id,
24082473
resource_uri=f"/calendars/{calendar_id}/acl",
24092474
type=channel_type,
24102475
address=address,
2411-
expiration=int(expiration) if expiration else None,
2476+
expiration=expiration_ms,
24122477
token=body.get("token"),
24132478
user_id=user_id, # Track ownership
24142479
params=body.get("params"),
@@ -2681,15 +2746,15 @@ async def settings_watch(request: Request) -> JSONResponse:
26812746
from ..database.schema import Channel
26822747

26832748
resource_id = generate_resource_id()
2684-
expiration = body.get("expiration")
2749+
expiration_ms = parse_watch_expiration(body.get("expiration"))
26852750

26862751
channel = Channel(
26872752
id=channel_id,
26882753
resource_id=resource_id,
26892754
resource_uri=f"/users/{user_id}/settings",
26902755
type=channel_type,
26912756
address=address,
2692-
expiration=int(expiration) if expiration else None,
2757+
expiration=expiration_ms,
26932758
token=body.get("token"),
26942759
params=body.get("params"),
26952760
payload=body.get("payload", False),

backend/src/services/calendar/database/operations.py

Lines changed: 44 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -603,6 +603,13 @@ def create_event(
603603
start_date = start.get("date")
604604
end_date = end.get("date")
605605

606+
# Extract organizer/creator fields from kwargs to allow override (e.g., for import)
607+
# Use provided values if present, otherwise default to current user
608+
# This matches Google Calendar API behavior where imports preserve original organizer
609+
organizer_email = kwargs.pop("organizer_email", None) or user.email
610+
organizer_display_name = kwargs.pop("organizer_display_name", None) or user.display_name
611+
organizer_self = organizer_email == user.email
612+
606613
event = Event(
607614
id=event_id,
608615
calendar_id=calendar.id,
@@ -623,17 +630,20 @@ def create_event(
623630
creator_display_name=user.display_name,
624631
creator_self=True,
625632
organizer_id=user_id,
626-
organizer_email=user.email,
627-
organizer_display_name=user.display_name,
628-
organizer_self=True,
633+
organizer_email=organizer_email,
634+
organizer_display_name=organizer_display_name,
635+
organizer_self=organizer_self,
629636
etag=generate_etag(f"{event_id}:1"),
630637
**{k: v for k, v in kwargs.items() if hasattr(Event, k)},
631638
)
632639
session.add(event)
633640

634641
# Add attendees
635642
if attendees:
636-
for attendee_data in attendees:
643+
for idx, attendee_data in enumerate(attendees):
644+
# Validate email is required for attendees (per Google Calendar API)
645+
if "email" not in attendee_data or not attendee_data["email"]:
646+
raise RequiredFieldError(f"attendees[{idx}].email")
637647
attendee = EventAttendee(
638648
event_id=event_id,
639649
email=attendee_data["email"],
@@ -1030,13 +1040,21 @@ def list_events(
10301040

10311041
all_events.append(instance)
10321042

1043+
# Helper to normalize datetime to timezone-aware for comparison
1044+
def _normalize_dt(dt: Optional[datetime]) -> datetime:
1045+
if dt is None:
1046+
return datetime.min.replace(tzinfo=timezone.utc)
1047+
if dt.tzinfo is None:
1048+
return dt.replace(tzinfo=timezone.utc)
1049+
return dt
1050+
10331051
# Sort combined results
10341052
if order_by == "startTime":
1035-
all_events.sort(key=lambda e: (e.start_datetime or datetime.min.replace(tzinfo=timezone.utc), e.id))
1053+
all_events.sort(key=lambda e: (_normalize_dt(e.start_datetime), e.id))
10361054
elif order_by == "updated":
1037-
all_events.sort(key=lambda e: (e.updated_at or datetime.min.replace(tzinfo=timezone.utc), e.id), reverse=True)
1055+
all_events.sort(key=lambda e: (_normalize_dt(e.updated_at), e.id), reverse=True)
10381056
else:
1039-
all_events.sort(key=lambda e: (e.start_datetime or datetime.min.replace(tzinfo=timezone.utc), e.id))
1057+
all_events.sort(key=lambda e: (_normalize_dt(e.start_datetime), e.id))
10401058

10411059
# Apply pagination to combined results (offset already decoded above)
10421060
paginated_events = all_events[offset:offset + max_results + 1]
@@ -1127,7 +1145,10 @@ def update_event(
11271145
)
11281146
# Add new attendees
11291147
user = session.get(User, user_id)
1130-
for attendee_data in kwargs["attendees"]:
1148+
for idx, attendee_data in enumerate(kwargs["attendees"]):
1149+
# Validate email is required for attendees (per Google Calendar API)
1150+
if "email" not in attendee_data or not attendee_data["email"]:
1151+
raise RequiredFieldError(f"attendees[{idx}].email")
11311152
attendee = EventAttendee(
11321153
event_id=event_id,
11331154
email=attendee_data["email"],
@@ -2009,13 +2030,21 @@ def get_event_instances(
20092030
all_instances.append(exc)
20102031

20112032
# Expand recurrence rules to get instance dates
2012-
instance_dates = expand_recurrence(
2013-
recurrence=master.recurrence,
2014-
start=start_dt,
2015-
time_min=min_dt,
2016-
time_max=max_dt,
2017-
max_instances=max_results,
2018-
)
2033+
try:
2034+
instance_dates = expand_recurrence(
2035+
recurrence=master.recurrence,
2036+
start=start_dt,
2037+
time_min=min_dt,
2038+
time_max=max_dt,
2039+
max_instances=max_results,
2040+
)
2041+
except Exception as e:
2042+
# Log and return empty if recurrence expansion fails
2043+
# Keep broad exception to maintain graceful degradation (matching Google's behavior)
2044+
logger.warning(
2045+
"Failed to expand recurrence for event %s in get_instances: %s", master.id, e
2046+
)
2047+
return [], None, None
20192048

20202049
# Calculate event duration
20212050
duration = timedelta(hours=1) # Default

backend/src/services/calendar/database/schema.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
String,
99
Text,
1010
Integer,
11+
BigInteger,
1112
Boolean,
1213
DateTime,
1314
ForeignKey,
@@ -630,8 +631,8 @@ class Channel(Base):
630631
String(1000), nullable=False
631632
) # Webhook callback URL
632633
expiration: Mapped[Optional[int]] = mapped_column(
633-
Integer, nullable=True
634-
) # Unix timestamp ms
634+
BigInteger, nullable=True
635+
) # Unix timestamp ms (requires BigInteger for ms since epoch)
635636
token: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
636637
params: Mapped[Optional[dict[str, Any]]] = mapped_column(JSONB, nullable=True)
637638
# Whether payload is wanted for notifications

0 commit comments

Comments
 (0)