Skip to content

Commit 58452f9

Browse files
authored
Add hook for integrating with Google Calendar (#20542)
Add GoogleCalendarHook to integrate with Google Calendar.
1 parent 9815e12 commit 58452f9

File tree

4 files changed

+346
-0
lines changed

4 files changed

+346
-0
lines changed

β€Žairflow/providers/google/provider.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,10 @@ integrations:
259259
external-doc-url: https://github.com/googleapis/google-api-python-client/
260260
logo: /integration-logos/gcp/Google-API-Python-Client.png
261261
tags: [google]
262+
- integration-name: Google Calendar
263+
external-doc-url: https://calendar.google.com/
264+
logo: /integration-logos/gcp/Google-Calendar.png
265+
tags: [google]
262266
- integration-name: Google Campaign Manager
263267
external-doc-url: https://developers.google.com/doubleclick-advertisers
264268
how-to-guide:
@@ -642,6 +646,9 @@ hooks:
642646
- integration-name: Google Search Ads 360
643647
python-modules:
644648
- airflow.providers.google.marketing_platform.hooks.search_ads
649+
- integration-name: Google Calendar
650+
python-modules:
651+
- airflow.providers.google.suite.hooks.calendar
645652
- integration-name: Google Drive
646653
python-modules:
647654
- airflow.providers.google.suite.hooks.drive
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
#
2+
# Licensed to the Apache Software Foundation (ASF) under one
3+
# or more contributor license agreements. See the NOTICE file
4+
# distributed with this work for additional information
5+
# regarding copyright ownership. The ASF licenses this file
6+
# to you under the Apache License, Version 2.0 (the
7+
# "License"); you may not use this file except in compliance
8+
# with the License. You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing,
13+
# software distributed under the License is distributed on an
14+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
# KIND, either express or implied. See the License for the
16+
# specific language governing permissions and limitations
17+
# under the License.
18+
#
19+
"""This module contains a Google Calendar API hook"""
20+
21+
from datetime import datetime
22+
from typing import Any, Dict, Optional, Sequence, Union
23+
24+
from googleapiclient.discovery import build
25+
26+
from airflow.exceptions import AirflowException
27+
from airflow.providers.google.common.hooks.base_google import GoogleBaseHook
28+
29+
30+
class GoogleCalendarHook(GoogleBaseHook):
31+
"""
32+
Interact with Google Calendar via Google Cloud connection
33+
Reading and writing cells in Google Sheet:
34+
https://developers.google.com/calendar/api/v3/reference
35+
36+
:param gcp_conn_id: The connection ID to use when fetching connection info.
37+
:type gcp_conn_id: str
38+
:param api_version: API Version. For example v3
39+
:type api_version: str
40+
:param delegate_to: The account to impersonate using domain-wide delegation of authority,
41+
if any. For this to work, the service account making the request must have
42+
domain-wide delegation enabled.
43+
:type delegate_to: str
44+
:param impersonation_chain: Optional service account to impersonate using short-term
45+
credentials, or chained list of accounts required to get the access_token
46+
of the last account in the list, which will be impersonated in the request.
47+
If set as a string, the account must grant the originating account
48+
the Service Account Token Creator IAM role.
49+
If set as a sequence, the identities from the list must grant
50+
Service Account Token Creator IAM role to the directly preceding identity, with first
51+
account from the list granting this role to the originating account.
52+
:type impersonation_chain: Union[str, Sequence[str]]
53+
"""
54+
55+
def __init__(
56+
self,
57+
api_version: str,
58+
gcp_conn_id: str = 'google_cloud_default',
59+
delegate_to: Optional[str] = None,
60+
impersonation_chain: Optional[Union[str, Sequence[str]]] = None,
61+
) -> None:
62+
super().__init__(
63+
gcp_conn_id=gcp_conn_id,
64+
delegate_to=delegate_to,
65+
impersonation_chain=impersonation_chain,
66+
)
67+
self.gcp_conn_id = gcp_conn_id
68+
self.api_version = api_version
69+
self.delegate_to = delegate_to
70+
self._conn = None
71+
72+
def get_conn(self) -> Any:
73+
"""
74+
Retrieves connection to Google Calendar.
75+
76+
:return: Google Calendar services object.
77+
:rtype: Any
78+
"""
79+
if not self._conn:
80+
http_authorized = self._authorize()
81+
self._conn = build('calendar', self.api_version, http=http_authorized, cache_discovery=False)
82+
83+
return self._conn
84+
85+
def get_events(
86+
self,
87+
calendar_id: str = 'primary',
88+
i_cal_uid: Optional[str] = None,
89+
max_attendees: Optional[int] = None,
90+
max_results: Optional[int] = None,
91+
order_by: Optional[str] = None,
92+
private_extended_property: Optional[str] = None,
93+
q: Optional[str] = None,
94+
shared_extended_property: Optional[str] = None,
95+
show_deleted: Optional[bool] = False,
96+
show_hidden_invitation: Optional[bool] = False,
97+
single_events: Optional[bool] = False,
98+
sync_token: Optional[str] = None,
99+
time_max: Optional[datetime] = None,
100+
time_min: Optional[datetime] = None,
101+
time_zone: Optional[str] = None,
102+
updated_min: Optional[datetime] = None,
103+
) -> list:
104+
"""
105+
Gets events from Google Calendar from a single calendar_id
106+
https://developers.google.com/calendar/api/v3/reference/events/list
107+
108+
:param calendar_id: The Google Calendar ID to interact with
109+
:type calendar_id: str
110+
:param i_cal_uid: Optional. Specifies event ID in the ``iCalendar`` format in the response.
111+
:type i_cal_uid: str
112+
:param max_attendees: Optional. If there are more than the specified number of attendees,
113+
only the participant is returned.
114+
:type max_attendees: int
115+
:param max_results: Optional. Maximum number of events returned on one result page.
116+
Incomplete pages can be detected by a non-empty ``nextPageToken`` field in the response.
117+
By default the value is 250 events. The page size can never be larger than 2500 events
118+
:type max_results: int
119+
:param order_by: Optional. Acceptable values are ``"startTime"`` or "updated"
120+
:type order_by: str
121+
:param private_extended_property: Optional. Extended properties constraint specified as
122+
``propertyName=value``. Matches only private properties. This parameter might be repeated
123+
multiple times to return events that match all given constraints.
124+
:type private_extended_property: str
125+
:param q: Optional. Free text search.
126+
:type q: str
127+
:param shared_extended_property: Optional. Extended properties constraint specified as
128+
``propertyName=value``. Matches only shared properties. This parameter might be repeated
129+
multiple times to return events that match all given constraints.
130+
:type shared_extended_property: str
131+
:param show_deleted: Optional. False by default
132+
:type show_deleted: bool
133+
:param show_hidden_invitation: Optional. False by default
134+
:type show_hidden_invitation: bool
135+
:param single_events: Optional. False by default
136+
:type single_events: bool
137+
:param sync_token: Optional. Token obtained from the ``nextSyncToken`` field returned
138+
:type sync_token: str
139+
:param time_max: Optional. Upper bound (exclusive) for an event's start time to filter by.
140+
Default is no filter
141+
:type time_max: datetime
142+
:param time_min: Optional. Lower bound (exclusive) for an event's end time to filter by.
143+
Default is no filter
144+
:type time_min: datetime
145+
:param time_zone: Optional. Time zone used in response. Default is calendars time zone.
146+
:type time_zone: str
147+
:param updated_min: Optional. Lower bound for an event's last modification time
148+
:type updated_min: datetime
149+
:rtype: List
150+
"""
151+
service = self.get_conn()
152+
page_token = None
153+
events = []
154+
while True:
155+
response = (
156+
service.events()
157+
.list(
158+
calendarId=calendar_id,
159+
iCalUID=i_cal_uid,
160+
maxAttendees=max_attendees,
161+
maxResults=max_results,
162+
orderBy=order_by,
163+
pageToken=page_token,
164+
privateExtendedProperty=private_extended_property,
165+
q=q,
166+
sharedExtendedProperty=shared_extended_property,
167+
showDeleted=show_deleted,
168+
showHiddenInvitations=show_hidden_invitation,
169+
singleEvents=single_events,
170+
syncToken=sync_token,
171+
timeMax=time_max,
172+
timeMin=time_min,
173+
timeZone=time_zone,
174+
updatedMin=updated_min,
175+
)
176+
.execute(num_retries=self.num_retries)
177+
)
178+
events.extend(response["items"])
179+
page_token = response.get("nextPageToken")
180+
if not page_token:
181+
break
182+
return events
183+
184+
def create_event(
185+
self,
186+
event: Dict[str, Any],
187+
calendar_id: str = 'primary',
188+
conference_data_version: Optional[int] = 0,
189+
max_attendees: Optional[int] = None,
190+
send_notifications: Optional[bool] = False,
191+
send_updates: Optional[str] = 'false',
192+
supports_attachments: Optional[bool] = False,
193+
) -> dict:
194+
"""
195+
Create event on the specified calendar
196+
https://developers.google.com/calendar/api/v3/reference/events/insert
197+
198+
:param calendar_id: The Google Calendar ID to interact with
199+
:type calendar_id: str
200+
:param conference_data_version: Optional. Version number of conference data
201+
supported by the API client.
202+
:type conference_data_version: int
203+
:param max_attendees: Optional. If there are more than the specified number of attendees,
204+
only the participant is returned.
205+
:type max_attendees: int
206+
:param send_notifications: Optional. Default is False
207+
:type send_notifications: bool
208+
:param send_updates: Optional. Default is "false". Acceptable values as "all", "none",
209+
``"externalOnly"``
210+
:type send_updates: str
211+
:type supports_attachments: Optional. Default is False
212+
:type supports_attachments: bool
213+
:type event: Required. Request body of Events resource. Start and End are required
214+
https://developers.google.com/calendar/api/v3/reference/events#resource
215+
:type event: dict
216+
:rtype: Dict
217+
"""
218+
if "start" not in event or "end" not in event:
219+
raise AirflowException(
220+
f"start and end must be specified in the event body while creating an event. API docs:"
221+
f"https://developers.google.com/calendar/api/{self.api_version}/reference/events/insert "
222+
)
223+
service = self.get_conn()
224+
225+
response = (
226+
service.events()
227+
.insert(
228+
calendarId=calendar_id,
229+
conferenceDataVersion=conference_data_version,
230+
maxAttendees=max_attendees,
231+
sendNotifications=send_notifications,
232+
sendUpdates=send_updates,
233+
supportsAttachments=supports_attachments,
234+
body=event,
235+
)
236+
.execute(num_retries=self.num_retries)
237+
)
238+
239+
return response
20.6 KB
Loading
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
#
2+
# Licensed to the Apache Software Foundation (ASF) under one
3+
# or more contributor license agreements. See the NOTICE file
4+
# distributed with this work for additional information
5+
# regarding copyright ownership. The ASF licenses this file
6+
# to you under the Apache License, Version 2.0 (the
7+
# "License"); you may not use this file except in compliance
8+
# with the License. You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing,
13+
# software distributed under the License is distributed on an
14+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
# KIND, either express or implied. See the License for the
16+
# specific language governing permissions and limitations
17+
# under the License.
18+
#
19+
"""
20+
Unit Tests for the Google Calendar Hook
21+
"""
22+
23+
import unittest
24+
from unittest import mock
25+
26+
from airflow.providers.google.suite.hooks.calendar import GoogleCalendarHook
27+
from tests.providers.google.cloud.utils.base_gcp_mock import mock_base_gcp_hook_default_project_id
28+
29+
API_VERSION = 'api_version'
30+
GCP_CONN_ID = 'test'
31+
CALENDAR_ID = 'test12345'
32+
EVENT = {
33+
'summary': 'Calendar Test Event',
34+
'description': 'A chance to test creating an event from airflow.',
35+
'start': {
36+
'dateTime': '2021-12-28T09:00:00-07:00',
37+
'timeZone': 'America/Los_Angeles',
38+
},
39+
'end': {
40+
'dateTime': '2021-12-28T17:00:00-07:00',
41+
'timeZone': 'America/Los_Angeles',
42+
},
43+
}
44+
NUM_RETRIES = 5
45+
API_RESPONSE = {'test': 'response'}
46+
47+
48+
class TestGoogleCalendarHook(unittest.TestCase):
49+
def setUp(self):
50+
with mock.patch(
51+
'airflow.providers.google.common.hooks.base_google.GoogleBaseHook.__init__',
52+
new=mock_base_gcp_hook_default_project_id,
53+
):
54+
self.hook = GoogleCalendarHook(api_version=API_VERSION, gcp_conn_id=GCP_CONN_ID)
55+
56+
@mock.patch("airflow.providers.google.suite.hooks.calendar.GoogleCalendarHook.get_conn")
57+
def test_get_events(self, get_conn):
58+
get_method = get_conn.return_value.events.return_value.list
59+
execute_method = get_method.return_value.execute
60+
execute_method.return_value = {"kind": "calendar#events", "nextPageToken": None, "items": [EVENT]}
61+
result = self.hook.get_events(calendar_id=CALENDAR_ID)
62+
self.assertEqual(result, [EVENT])
63+
execute_method.assert_called_once_with(num_retries=NUM_RETRIES)
64+
get_method.assert_called_once_with(
65+
calendarId=CALENDAR_ID,
66+
iCalUID=None,
67+
maxAttendees=None,
68+
maxResults=None,
69+
orderBy=None,
70+
pageToken=None,
71+
privateExtendedProperty=None,
72+
q=None,
73+
sharedExtendedProperty=None,
74+
showDeleted=False,
75+
showHiddenInvitations=False,
76+
singleEvents=False,
77+
syncToken=None,
78+
timeMax=None,
79+
timeMin=None,
80+
timeZone=None,
81+
updatedMin=None,
82+
)
83+
84+
@mock.patch("airflow.providers.google.suite.hooks.calendar.GoogleCalendarHook.get_conn")
85+
def test_create_event(self, mock_get_conn):
86+
create_mock = mock_get_conn.return_value.events.return_value.insert
87+
create_mock.return_value.execute.return_value = API_RESPONSE
88+
89+
result = self.hook.create_event(calendar_id=CALENDAR_ID, event=EVENT)
90+
91+
create_mock.assert_called_once_with(
92+
body=EVENT,
93+
calendarId=CALENDAR_ID,
94+
conferenceDataVersion=0,
95+
maxAttendees=None,
96+
sendNotifications=False,
97+
sendUpdates='false',
98+
supportsAttachments=False,
99+
)
100+
assert result == API_RESPONSE

0 commit comments

Comments
 (0)