# What this PR does
Attempting to solve some Celery retry errors we're seeing around
`apps.google.tasks.sync_out_of_office_calendar_events_for_user`. This PR
adds better logging and documents some findings so far.
## Checklist
- [x] Unit, integration, and e2e (if applicable) tests updated
- [x] Documentation added (or `pr:no public docs` PR label added if not
required)
- [x] Added the relevant release notes label (see labels prefixed w/
`release:`). These labels dictate how your PR will
show up in the autogenerated release notes.
129 lines
5.8 KiB
Python
129 lines
5.8 KiB
Python
import datetime
|
|
import logging
|
|
import typing
|
|
|
|
from django.conf import settings
|
|
from google.auth.exceptions import RefreshError
|
|
from google.oauth2.credentials import Credentials
|
|
from googleapiclient.discovery import build
|
|
from googleapiclient.errors import HttpError
|
|
|
|
from apps.google import constants, utils
|
|
from apps.google.types import GoogleCalendarEvent as GoogleCalendarEventType
|
|
|
|
logger = logging.getLogger(__name__)
|
|
logger.setLevel(logging.DEBUG)
|
|
|
|
|
|
class GoogleCalendarEvent:
|
|
def __init__(self, event: GoogleCalendarEventType):
|
|
self.raw_event = event
|
|
self._start_time = utils.datetime_strptime(event["start"]["dateTime"])
|
|
self._end_time = utils.datetime_strptime(event["end"]["dateTime"])
|
|
|
|
self.start_time_utc = self._start_time.astimezone(datetime.timezone.utc)
|
|
self.end_time_utc = self._end_time.astimezone(datetime.timezone.utc)
|
|
|
|
|
|
class _GoogleCalendarHTTPError(Exception):
|
|
def __init__(self, http_error) -> None:
|
|
self.error = http_error
|
|
|
|
|
|
class GoogleCalendarGenericHTTPError(_GoogleCalendarHTTPError):
|
|
"""Raised when a generic HTTP error occurs when communicating with the Google Calendar API"""
|
|
|
|
|
|
class GoogleCalendarUnauthorizedHTTPError(_GoogleCalendarHTTPError):
|
|
"""Raised when an HTTP 403 error occurs when communicating with the Google Calendar API"""
|
|
|
|
|
|
class GoogleCalendarRefreshError(Exception):
|
|
def __init__(self, refresh_error) -> None:
|
|
self.error = refresh_error
|
|
|
|
|
|
class GoogleCalendarAPIClient:
|
|
MAX_NUMBER_OF_CALENDAR_EVENTS_TO_FETCH = 250
|
|
"""
|
|
By default the value is 250 events. The page size can never be larger than 2500 events
|
|
"""
|
|
|
|
CALENDAR_ID = "primary"
|
|
"""
|
|
for right now we only consider the user's primary calendar. If in the future we
|
|
want to allow the user to specify a different calendar, we'd need to [retrieve all their calendars](https://developers.google.com/calendar/v3/reference/calendarList/list)
|
|
, display this list to them + perist their choice
|
|
|
|
See `calendarId` under the "Parameters" section [here](https://developers.google.com/calendar/api/v3/reference/events/list)
|
|
"""
|
|
|
|
def __init__(self, access_token: str, refresh_token: str):
|
|
"""
|
|
https://developers.google.com/calendar/api/quickstart/python
|
|
https://google-auth.readthedocs.io/en/stable/reference/google.oauth2.credentials.html
|
|
"""
|
|
credentials = Credentials(
|
|
token=access_token,
|
|
refresh_token=refresh_token,
|
|
token_uri="https://www.googleapis.com/oauth2/v3/token",
|
|
client_id=settings.SOCIAL_AUTH_GOOGLE_OAUTH2_KEY,
|
|
client_secret=settings.SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET,
|
|
)
|
|
|
|
self.service = build("calendar", "v3", credentials=credentials)
|
|
|
|
def fetch_out_of_office_events(self) -> typing.List[GoogleCalendarEvent]:
|
|
"""
|
|
https://developers.google.com/calendar/api/v3/reference/events/list
|
|
"""
|
|
logger.info(
|
|
f"GoogleCalendarAPIClient - Getting the upcoming {self.MAX_NUMBER_OF_CALENDAR_EVENTS_TO_FETCH} "
|
|
"out of office events"
|
|
)
|
|
|
|
now = datetime.datetime.now(datetime.UTC)
|
|
time_min = utils.datetime_strftime(now)
|
|
time_max = utils.datetime_strftime(
|
|
now + datetime.timedelta(days=constants.DAYS_IN_FUTURE_TO_CONSIDER_OUT_OF_OFFICE_EVENTS)
|
|
)
|
|
|
|
try:
|
|
events_result = (
|
|
self.service.events()
|
|
.list(
|
|
calendarId=self.CALENDAR_ID,
|
|
timeMin=time_min,
|
|
timeMax=time_max,
|
|
maxResults=self.MAX_NUMBER_OF_CALENDAR_EVENTS_TO_FETCH,
|
|
singleEvents=True,
|
|
orderBy="startTime",
|
|
eventTypes="outOfOffice",
|
|
)
|
|
.execute()
|
|
)
|
|
except HttpError as e:
|
|
if e.status_code == 403:
|
|
# this scenario can be encountered when, for some reason, the OAuth2 token that we have
|
|
# does not contain the https://www.googleapis.com/auth/calendar.events.readonly scope
|
|
# example error:
|
|
# <HttpError 403 when requesting https://www.googleapis.com/calendar/v3/calendars/primary/events?timeMin=2024-08-08T14%3A00%3A00%2B0000&timeMax=2024-09-07T14%3A00%3A00%2B0000&maxResults=250&singleEvents=true&orderBy=startTime&eventTypes=outOfOffice&alt=json returned "Request had insufficient authentication scopes.". Details: "[{'message': 'Insufficient Permission', 'domain': 'global', 'reason': 'insufficientPermissions'}]"> # noqa: E501
|
|
logger.error(f"GoogleCalendarAPIClient - HttpError 403 when fetching out of office events: {e}")
|
|
raise GoogleCalendarUnauthorizedHTTPError(e)
|
|
|
|
logger.error(f"GoogleCalendarAPIClient - HttpError when fetching out of office events: {e}")
|
|
raise GoogleCalendarGenericHTTPError(e)
|
|
except RefreshError as e:
|
|
# TODO: come back and solve this properly once we get better logging output
|
|
# it seems like right now we are seeing RefreshError in two different scenarios:
|
|
# 1. RefreshError('invalid_grant: Account has been deleted', {'error': 'invalid_grant', 'error_description': 'Account has been deleted'})
|
|
# 2. RefreshError('invalid_grant: Token has been expired or revoked.', {'error': 'invalid_grant', 'error_description': 'Token has been expired or revoked.'})
|
|
# https://stackoverflow.com/a/49024030/3902555
|
|
logger.error(
|
|
f"GoogleCalendarAPIClient - RefreshError when fetching out of office events: {e} "
|
|
# NOTE: remove e.args after debugging how to dig into the error details
|
|
f"args={e.args}"
|
|
)
|
|
raise GoogleCalendarRefreshError(e)
|
|
|
|
return [GoogleCalendarEvent(event) for event in events_result.get("items", [])]
|