diff --git a/reservations/tasks/sync_google_calendar.py b/reservations/tasks/sync_google_calendar.py index 1281cf0..58ff4f8 100644 --- a/reservations/tasks/sync_google_calendar.py +++ b/reservations/tasks/sync_google_calendar.py @@ -1,6 +1,7 @@ import logging from datetime import date, datetime from http import HTTPStatus +from typing import TYPE_CHECKING from django.conf import settings from django.utils import timezone @@ -9,6 +10,9 @@ from google.oauth2 import service_account from googleapiclient.discovery import build from googleapiclient.errors import HttpError +if TYPE_CHECKING: + from googleapiclient._apis.calendar.v3 import CalendarResource, Event + from cmsmanage.django_q2_helper import q_task_group from reservations.models import ExternalReservation, Reservation, Resource @@ -26,196 +30,195 @@ def parse_google_calendar_datetime(dt) -> date | datetime: raise Exception("Google Calendar event with out a start/end date/dateTime") -def update_calendar_event( - service, resource: Resource, existing_event, reservation: Reservation -): - changes = reservation.make_google_calendar_event() - # skip update if no changes are needed - if ( - parse_google_calendar_datetime(existing_event["start"]) != reservation.start - or parse_google_calendar_datetime(existing_event["end"]) != reservation.end - or any( - existing_event[k] != v - for k, v in changes.items() - if k not in ("start", "end") +class GoogleCalendarSynchronizer: + service: "CalendarResource" + + def __init__(self) -> None: + self.service = build( + "calendar", + "v3", + credentials=service_account.Credentials.from_service_account_file( + settings.GOOGLE_SERVICE_ACCOUNT_FILE, + scopes=SCOPES, + ), ) + + def update_calendar_event( + self, resource: Resource, existing_event: "Event", reservation: Reservation ): - logger.debug("Updating event") - new_event = existing_event | changes - service.events().update( - calendarId=resource.google_calendar, - eventId=reservation.google_calendar_event_id, - body=new_event, - ).execute() - - -def insert_calendar_event(service, resource: Resource, reservation: Reservation): - new_gcal_event = reservation.make_google_calendar_event() - created_event = ( - service.events() - .insert( - calendarId=resource.google_calendar, - body=new_gcal_event, - ) - .execute() - ) - reservation.google_calendar_event_id = created_event["id"] - reservation.save() - - -def insert_or_update_calendar_event( - service, resource: Resource, reservation: Reservation -): - if not reservation.google_calendar_event_id: - logger.info( - "Event in database has no Google Calendar event ID: inserting | %s", - reservation.google_calendar_event_id, - ) - insert_calendar_event(service, resource, reservation) - - else: - # this event was in Google Calendar at some point (possibly for a different - # resource/calendar), but did not appear in list(). Try to update it, then - # fall back to insert - logger.info( - "Reservation with event id not in Google Calendar: trying update | %s", - reservation.google_calendar_event_id, - ) - try: - event = ( - service.events() - .get( - calendarId=resource.google_calendar, - eventId=reservation.google_calendar_event_id, - ) - .execute() - ) - update_calendar_event(service, resource, event, reservation) - except HttpError as error: - if error.status_code == HTTPStatus.NOT_FOUND: - logger.info( - "Event in database not in Google Calendar: inserting | %s", - reservation.google_calendar_event_id, - ) - insert_calendar_event(service, resource, reservation) - else: - raise - - -def sync_resource_from_google_calendar( - service, resource: Resource, now: datetime -) -> set[str]: - request = ( - service.events() - .list( - calendarId=resource.google_calendar, - timeMin=now.isoformat(timespec="seconds"), - maxResults=2500, - ) - .execute() - ) - if "nextPageToken" in request: - # TODO: implement pagination - raise Exception( - "More events than fit on a page, and pagination not implemented" - ) - events = request["items"] - - for event in events: + changes = reservation.make_google_calendar_event() + # skip update if no changes are needed if ( - "extendedProperties" in event - and "private" in event["extendedProperties"] - and event["extendedProperties"]["private"].get("cmsmanage") == "1" + parse_google_calendar_datetime(existing_event["start"]) != reservation.start + or parse_google_calendar_datetime(existing_event["end"]) != reservation.end + or any( + existing_event[k] != v + for k, v in changes.items() + if k not in ("start", "end") + ) ): - try: - reservation = resource.reservation_set.get_subclass( - google_calendar_event_id=event["id"] - ) - # event exists in both Google Calendar and database, check for update - logger.debug( - "Event in Google Calendar found in database: checking for update | %s", - event["id"], - ) - update_calendar_event(service, resource, event, reservation) - except Reservation.DoesNotExist: - # reservation deleted in database, so remove from Google Calendar - logger.info( - "Event in Google Calendar not found in database: deleting | %s", - event["id"], - ) - service.events().delete( - calendarId=resource.google_calendar, - eventId=event["id"], - sendUpdates="none", - ).execute() - else: - logger.debug( - "Event in Google Calendar not originated by CMSManage: adding/updating as external reservation | %s", - event["id"], + logger.debug("Updating event") + new_event = existing_event | changes + self.service.events().update( + calendarId=resource.google_calendar, + eventId=reservation.google_calendar_event_id, + body=new_event, + ).execute() + + def insert_calendar_event(self, resource: Resource, reservation: Reservation): + new_gcal_event = reservation.make_google_calendar_event() + created_event = ( + self.service.events() + .insert( + calendarId=resource.google_calendar, + body=new_gcal_event, ) - # TODO: this might cause issues if something external - # creates events with matching IDs in different calendars - reservation, created = ExternalReservation.objects.update_or_create( - google_calendar_event_id=event["id"], - defaults={ - "title": event["summary"], - "start": parse_google_calendar_datetime(event["start"]), - "end": parse_google_calendar_datetime(event["end"]), - }, - ) - reservation.resources.add(resource) + .execute() + ) + reservation.google_calendar_event_id = created_event["id"] + reservation.save() - return {event["id"] for event in events} - - -def sync_reservation_from_database(service, reservation: Reservation): - for resource in reservation.resources.all(): - insert_or_update_calendar_event(service, resource, reservation) - - -def sync_resource_from_database( - service, resource: Resource, now: datetime, existing_event_ids: set[str] -): - reservations = ( - resource.reservation_set.filter(end__gt=now) - # skip events we already pulled from Google Calendar during this sync - .exclude(google_calendar_event_id__in=existing_event_ids) - .select_subclasses() - ) - # TODO: this could probably be more efficient? - for reservation in reservations: - if isinstance(reservation, ExternalReservation): + def insert_or_update_calendar_event( + self, resource: Resource, reservation: Reservation + ): + if not reservation.google_calendar_event_id: logger.info( - "External event in database did not exist in future of Google Calendar: deleting locally | %s", + "Event in database has no Google Calendar event ID: inserting | %s", reservation.google_calendar_event_id, ) - reservation.delete() + self.insert_calendar_event(resource, reservation) else: - insert_or_update_calendar_event(service, resource, reservation) + # this event was in Google Calendar at some point (possibly for a different + # resource/calendar), but did not appear in list(). Try to update it, then + # fall back to insert + logger.info( + "Reservation with event id not in Google Calendar: trying update | %s", + reservation.google_calendar_event_id, + ) + try: + event = ( + self.service.events() + .get( + calendarId=resource.google_calendar, + eventId=reservation.google_calendar_event_id, + ) + .execute() + ) + self.update_calendar_event(resource, event, reservation) + except HttpError as error: + if error.status_code == HTTPStatus.NOT_FOUND: + logger.info( + "Event in database not in Google Calendar: inserting | %s", + reservation.google_calendar_event_id, + ) + self.insert_calendar_event(resource, reservation) + else: + raise + def sync_resource_from_google_calendar( + self, resource: Resource, now: datetime + ) -> set[str]: + request = ( + self.service.events() + .list( + calendarId=resource.google_calendar, + timeMin=now.isoformat(timespec="seconds"), + maxResults=2500, + ) + .execute() + ) + if "nextPageToken" in request: + # TODO: implement pagination + raise Exception( + "More events than fit on a page, and pagination not implemented" + ) + events = request["items"] -def sync_resource(service, resource: Resource, now: datetime): - logger.info( - "Checking calendar %s for resource %s", resource.google_calendar, resource - ) + for event in events: + if ( + "extendedProperties" in event + and "private" in event["extendedProperties"] + and event["extendedProperties"]["private"].get("cmsmanage") == "1" + ): + try: + reservation = resource.reservation_set.get_subclass( + google_calendar_event_id=event["id"] + ) + # event exists in both Google Calendar and database, check for update + logger.debug( + "Event in Google Calendar found in database: checking for update | %s", + event["id"], + ) + self.update_calendar_event(resource, event, reservation) + except Reservation.DoesNotExist: + # reservation deleted in database, so remove from Google Calendar + logger.info( + "Event in Google Calendar not found in database: deleting | %s", + event["id"], + ) + self.service.events().delete( + calendarId=resource.google_calendar, + eventId=event["id"], + sendUpdates="none", + ).execute() + else: + logger.debug( + "Event in Google Calendar not originated by CMSManage: adding/updating as external reservation | %s", + event["id"], + ) + # TODO: this might cause issues if something external + # creates events with matching IDs in different calendars + reservation, created = ExternalReservation.objects.update_or_create( + google_calendar_event_id=event["id"], + defaults={ + "title": event["summary"], + "start": parse_google_calendar_datetime(event["start"]), + "end": parse_google_calendar_datetime(event["end"]), + }, + ) + reservation.resources.add(resource) - existing_event_ids = sync_resource_from_google_calendar(service, resource, now) - sync_resource_from_database(service, resource, now, existing_event_ids) + return {event["id"] for event in events} + + def sync_reservation_from_database(self, reservation: Reservation): + for resource in reservation.resources.all(): + self.insert_or_update_calendar_event(resource, reservation) + + def sync_resource_from_database( + self, resource: Resource, now: datetime, existing_event_ids: set[str] + ): + reservations = ( + resource.reservation_set.filter(end__gt=now) + # skip events we already pulled from Google Calendar during this sync + .exclude(google_calendar_event_id__in=existing_event_ids) + .select_subclasses() + ) + # TODO: this could probably be more efficient? + for reservation in reservations: + if isinstance(reservation, ExternalReservation): + logger.info( + "External event in database did not exist in future of Google Calendar: deleting locally | %s", + reservation.google_calendar_event_id, + ) + reservation.delete() + + else: + self.insert_or_update_calendar_event(resource, reservation) + + def sync_resource(self, resource: Resource, now: datetime): + logger.info( + "Checking calendar %s for resource %s", resource.google_calendar, resource + ) + + existing_event_ids = self.sync_resource_from_google_calendar(resource, now) + self.sync_resource_from_database(resource, now, existing_event_ids) @q_task_group("Sync Reservations with Google Calendar") def sync_reservations_with_google_calendar(): - service = build( - "calendar", - "v3", - credentials=service_account.Credentials.from_service_account_file( - settings.GOOGLE_SERVICE_ACCOUNT_FILE, - scopes=SCOPES, - ), - ) - + synchronizer = GoogleCalendarSynchronizer() now = timezone.now() for resource in Resource.objects.all(): - sync_resource(service, resource, now) + synchronizer.sync_resource(resource, now)