cmsmanage/reservations/tasks/sync_google_calendar.py

206 lines
7.6 KiB
Python
Raw Normal View History

import logging
from datetime import date, datetime
from http import HTTPStatus
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
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")
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")
)
):
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 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:
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"],
)
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"],
)
# 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_resource_from_database(
service, resource: Resource, now: datetime, existing_event_ids: set[str]
):
reservations = resource.reservation_set.filter(end__gt=now).select_subclasses()
# TODO: this could probably be more efficient?
for reservation in reservations:
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)
# reservation has an event id, so check if we already handled it earlier
elif reservation.google_calendar_event_id not in existing_event_ids:
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:
# 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(service, resource: Resource, now: datetime):
logger.info(
"Checking calendar %s for resource %s", resource.google_calendar, resource
)
existing_event_ids = sync_resource_from_google_calendar(service, resource, now)
sync_resource_from_database(service, 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,
),
)
now = timezone.now()
for resource in Resource.objects.all():
sync_resource(service, resource, now)