Skip to content

Commit c2eae0b

Browse files
committed
Add single-event refresh endpoint
1 parent 16c3b18 commit c2eae0b

8 files changed

Lines changed: 519 additions & 0 deletions

File tree

inbox/api/err.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@
77

88
class APIException(Exception):
99
status_code = 500
10+
message = "An internal error occurred."
11+
12+
def __init__(self, message: str | None = None) -> None:
13+
if message is not None:
14+
self.message = message
15+
super().__init__(self.message)
1016

1117

1218
class InputError(APIException):

inbox/api/ns_api.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,11 @@
7777
is_stale,
7878
)
7979
from inbox.crispin import writable_connection_pool
80+
from inbox.events.google import GoogleEventsProvider
8081
from inbox.events.ical import generate_rsvp, send_rsvp
82+
from inbox.events.microsoft.events_provider import (
83+
MicrosoftEventsProvider,
84+
)
8185
from inbox.events.util import removed_participants
8286
from inbox.ignition import engine_manager
8387
from inbox.models import (
@@ -1206,6 +1210,48 @@ def event_read_api(public_id): # type: ignore[no-untyped-def] # noqa: ANN201
12061210
return g.encoder.jsonify(event)
12071211

12081212

1213+
@app.route("/events/<public_id>/refresh", methods=["POST"])
1214+
def event_refresh_api(public_id): # type: ignore[no-untyped-def] # noqa: ANN201
1215+
"""Re-fetch a single event from the calendar provider and update the DB."""
1216+
valid_public_id(public_id)
1217+
try:
1218+
local_event = (
1219+
g.db_session.query(Event)
1220+
.filter(
1221+
Event.namespace_id == g.namespace.id,
1222+
Event.public_id == public_id,
1223+
Event.deleted_at.is_(None),
1224+
)
1225+
.one()
1226+
)
1227+
except NoResultFound as exc:
1228+
raise NotFoundError(f"Couldn't find event id {public_id}") from exc
1229+
1230+
calendar = local_event.calendar
1231+
account = g.namespace.account
1232+
1233+
# Instantiate the right provider.
1234+
if account.provider == "gmail":
1235+
provider = GoogleEventsProvider(account.id, g.namespace.id)
1236+
elif account.provider == "microsoft":
1237+
provider = MicrosoftEventsProvider(account.id, g.namespace.id)
1238+
else:
1239+
raise APIException(f"Unsupported provider: {account.provider}")
1240+
1241+
refreshed_event = provider.fetch_single_event(
1242+
calendar.uid, local_event.uid, read_only=calendar.read_only
1243+
)
1244+
if refreshed_event is None:
1245+
raise NotFoundError(
1246+
f"Event {public_id} not found on the calendar provider"
1247+
)
1248+
1249+
local_event.update(refreshed_event)
1250+
g.db_session.commit()
1251+
1252+
return g.encoder.jsonify(local_event)
1253+
1254+
12091255
@app.route("/events/<public_id>", methods=["PUT", "PATCH"])
12101256
def event_update_api(public_id): # type: ignore[no-untyped-def] # noqa: ANN201
12111257
g.parser.add_argument(

inbox/events/abstract.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,24 @@ def sync_events(
5555
"""
5656
raise NotImplementedError()
5757

58+
def fetch_single_event(
59+
self, calendar_uid: str, event_uid: str, *, read_only: bool
60+
) -> Event | None:
61+
"""
62+
Fetch a single event from the calendar provider.
63+
64+
Arguments:
65+
calendar_uid: The calendar identifier.
66+
event_uid: The event identifier.
67+
read_only: Whether the event's calendar is read-only.
68+
69+
Returns:
70+
An uncommitted `Event` instance, or `None` if the event was not
71+
found on the provider.
72+
73+
"""
74+
return None
75+
5876
@abc.abstractmethod
5977
def webhook_notifications_enabled(self, account: Account) -> bool:
6078
"""

inbox/events/google.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,38 @@ def sync_calendars(self) -> CalendarSyncResponse:
7373

7474
return CalendarSyncResponse(deletes, updates)
7575

76+
def fetch_single_event(
77+
self, calendar_uid: str, event_uid: str, *, read_only: bool
78+
) -> Event | None:
79+
"""
80+
Fetch a single event from the Google Calendar API.
81+
82+
Arguments:
83+
calendar_uid: The Google calendar identifier.
84+
event_uid: The Google event identifier.
85+
read_only: Whether the event's calendar is read-only.
86+
87+
Returns:
88+
An uncommitted `Event` instance, or `None` if the event was not
89+
found on the provider.
90+
91+
"""
92+
url = "https://www.googleapis.com/calendar/v3/calendars/{}/events/{}".format(
93+
urllib.parse.quote(calendar_uid),
94+
urllib.parse.quote(event_uid),
95+
)
96+
token = self._get_access_token()
97+
response = requests.get(url, auth=OAuthRequestsWrapper(token))
98+
if response.status_code == 401:
99+
token = self._get_access_token(force_refresh=True)
100+
response = requests.get(url, auth=OAuthRequestsWrapper(token))
101+
if response.status_code == 404:
102+
return None
103+
response.raise_for_status()
104+
105+
raw_event = response.json()
106+
return parse_event_response(raw_event, read_only)
107+
76108
def sync_events(
77109
self,
78110
calendar_uid: str,

inbox/events/microsoft/events_provider.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,37 @@ def sync_calendars(self) -> CalendarSyncResponse:
112112

113113
return CalendarSyncResponse(deleted_uids, updates)
114114

115+
def fetch_single_event(
116+
self, calendar_uid: str, event_uid: str, *, read_only: bool
117+
) -> Event | None:
118+
"""
119+
Fetch a single event from the Microsoft Graph API.
120+
121+
Arguments:
122+
calendar_uid: The calendar identifier (unused — Microsoft Graph
123+
fetches events by ID across all calendars).
124+
event_uid: The Microsoft Graph event identifier.
125+
read_only: Whether the event's calendar is read-only.
126+
127+
Returns:
128+
An uncommitted `Event` instance, or `None` if the event was not
129+
found on the provider.
130+
131+
"""
132+
try:
133+
raw_event = self.client.get_event(event_uid, fields=EVENT_FIELDS)
134+
except MicrosoftGraphClientException as exc:
135+
if exc.response is not None and exc.response.status_code == 404:
136+
return None
137+
raise
138+
if not validate_event(raw_event):
139+
self.log.warning(
140+
"Invalid event from refresh",
141+
raw_event=raw_event,
142+
)
143+
return None
144+
return parse_event(raw_event, read_only=read_only)
145+
115146
def sync_events(
116147
self,
117148
calendar_uid: str,

tests/api/test_events.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import json
2+
from unittest import mock
3+
4+
import arrow
25

36
from inbox.api.ns_api import API_VERSIONS
47
from inbox.models import Calendar, Event
@@ -415,3 +418,93 @@ def test_api_filter(db, api_client, calendar, default_namespace):
415418
_filter = "calendar_id=0000000000000000000000000"
416419
events = api_client.get_data("/events?" + _filter)
417420
assert len(events) == 0
421+
422+
423+
# -- POST /events/<id>/refresh tests --
424+
425+
426+
def test_refresh_endpoint_updates_db(db, api_client, calendar):
427+
"""Refresh endpoint fetches from provider and updates the local DB."""
428+
event = add_fake_event(
429+
db.session,
430+
calendar.namespace_id,
431+
calendar=calendar,
432+
title="Old Title",
433+
description="Old description",
434+
)
435+
event.participants = [
436+
{"email": "alice@example.com", "name": "Alice", "status": "noreply"}
437+
]
438+
db.session.commit()
439+
440+
refreshed = Event.create(
441+
uid=event.uid,
442+
raw_data="{}",
443+
title="New Title",
444+
description="New description",
445+
start=arrow.get(2025, 9, 15, 12, 0, 0),
446+
end=arrow.get(2025, 9, 15, 13, 0, 0),
447+
all_day=False,
448+
busy=True,
449+
read_only=False,
450+
owner="Organizer <org@example.com>",
451+
is_owner=True,
452+
participants=[
453+
{"email": "alice@example.com", "name": "Alice", "status": "yes"}
454+
],
455+
status="confirmed",
456+
source="local",
457+
)
458+
459+
with mock.patch(
460+
"inbox.api.ns_api.GoogleEventsProvider"
461+
) as mock_provider_cls:
462+
mock_provider = mock_provider_cls.return_value
463+
mock_provider.fetch_single_event.return_value = refreshed
464+
465+
resp = api_client.post_data(f"/events/{event.public_id}/refresh", {})
466+
467+
assert resp.status_code == 200
468+
469+
data = json.loads(resp.data)
470+
assert data["title"] == "New Title"
471+
assert data["description"] == "New description"
472+
assert len(data["participants"]) == 1
473+
assert data["participants"][0]["status"] == "yes"
474+
475+
# Verify the DB was updated.
476+
db.session.refresh(event)
477+
assert event.title == "New Title"
478+
assert event.participants[0]["status"] == "yes"
479+
480+
481+
def test_refresh_endpoint_unknown_event(db, api_client, calendar):
482+
"""Refresh endpoint returns 404 for an unknown public_id."""
483+
unknown_id = generate_public_id()
484+
resp = api_client.post_data(f"/events/{unknown_id}/refresh", {})
485+
assert resp.status_code == 404
486+
487+
488+
def test_refresh_endpoint_provider_returns_none(db, api_client, calendar):
489+
"""Refresh endpoint returns 404 when the provider can't find the event."""
490+
event = add_fake_event(
491+
db.session,
492+
calendar.namespace_id,
493+
calendar=calendar,
494+
title="Existing Event",
495+
)
496+
497+
with mock.patch(
498+
"inbox.api.ns_api.GoogleEventsProvider"
499+
) as mock_provider_cls:
500+
mock_provider = mock_provider_cls.return_value
501+
mock_provider.fetch_single_event.return_value = None
502+
503+
resp = api_client.post_data(f"/events/{event.public_id}/refresh", {})
504+
505+
assert resp.status_code == 404
506+
507+
# Verify the DB record is NOT deleted.
508+
db.session.refresh(event)
509+
assert event.title == "Existing Event"
510+
assert event.deleted_at is None

0 commit comments

Comments
 (0)