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 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 logger = logging.getLogger(__name__) SCOPES = ["https://www.googleapis.com/auth/calendar"] def parse_google_calendar_datetime(dt) -> date | datetime: if "date" in dt: return date.fromisoformat(dt["date"]) elif "dateTime" in dt: return datetime.fromisoformat(dt["dateTime"]) else: raise Exception("Google Calendar event with out a start/end date/dateTime") 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 ): 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") ) ): 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, ) .execute() ) reservation.google_calendar_event_id = created_event["id"] reservation.save() def insert_or_update_calendar_event( self, 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, ) self.insert_calendar_event(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 = ( 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"] 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) 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(): synchronizer = GoogleCalendarSynchronizer() now = timezone.now() for resource in Resource.objects.all(): synchronizer.sync_resource(resource, now)