From 35c063c44e0b17918c69e38cbfed1d64f5e11824 Mon Sep 17 00:00:00 2001 From: Adam Goldsmith Date: Mon, 5 Aug 2024 22:08:06 -0400 Subject: [PATCH] reservations: Add task to sync with Google Calendar --- cmsmanage/settings.py | 6 + pdm.lock | 255 +++++++++++++++++- pyproject.toml | 5 + reservations/apps.py | 18 ++ reservations/management/__init__.py | 0 reservations/management/commands/__init__.py | 0 .../sync_reservations_with_google_calendar.py | 21 ++ reservations/tasks/__init__.py | 0 reservations/tasks/sync_google_calendar.py | 189 +++++++++++++ 9 files changed, 493 insertions(+), 1 deletion(-) create mode 100644 reservations/management/__init__.py create mode 100644 reservations/management/commands/__init__.py create mode 100644 reservations/management/commands/sync_reservations_with_google_calendar.py create mode 100644 reservations/tasks/__init__.py create mode 100644 reservations/tasks/sync_google_calendar.py diff --git a/cmsmanage/settings.py b/cmsmanage/settings.py index 75f97d1..fd1818c 100644 --- a/cmsmanage/settings.py +++ b/cmsmanage/settings.py @@ -18,6 +18,10 @@ class Base(Configuration): credentials_directory = os.getenv("CREDENTIALS_DIRECTORY") if credentials_directory is not None: for credential in Path(credentials_directory).iterdir(): + if credential.name.endswith("_path"): + os.environ.setdefault( + credential.name.removesuffix("_path"), str(credential.resolve()) + ) if credential.name.isupper(): os.environ.setdefault(credential.name, credential.read_text()) @@ -226,6 +230,8 @@ class NonCIBase(Base): environ_required=True, environ_prefix=None ) + GOOGLE_SERVICE_ACCOUNT_FILE = values.PathValue(environ_prefix=None) + HID_DOOR_USERNAME = values.Value(environ_required=True, environ_prefix=None) HID_DOOR_PASSWORD = values.SecretValue(environ_prefix=None) diff --git a/pdm.lock b/pdm.lock index cbad528..e7be27d 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "debug", "dev", "lint", "server", "typing"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:cdef77160cecd840eeaef2f45c9d4afc0701146cf0b78e8b5cb782bc57144ad7" +content_hash = "sha256:b76b2cc9bd24beecef27ef102062710607993f9b4c318e7bda89b2217efccd29" [[metadata.targets]] requires_python = "==3.11.*" @@ -187,6 +187,18 @@ files = [ {file = "Brotli-1.1.0.tar.gz", hash = "sha256:81de08ac11bcb85841e440c13611c00b67d3bf82698314928d0b676362546724"}, ] +[[package]] +name = "cachetools" +version = "5.4.0" +requires_python = ">=3.7" +summary = "Extensible memoizing collections and decorators" +groups = ["default"] +marker = "python_version == \"3.11\"" +files = [ + {file = "cachetools-5.4.0-py3-none-any.whl", hash = "sha256:3ae3b49a3d5e28a77a0be2b37dbcb89005058959cb2323858c2657c4a8cab474"}, + {file = "cachetools-5.4.0.tar.gz", hash = "sha256:b8adc2e7c07f105ced7bc56dbb6dfbe7c4a00acce20e2227b3f355be89bc6827"}, +] + [[package]] name = "certifi" version = "2024.7.4" @@ -534,6 +546,21 @@ files = [ {file = "django_markdownx-4.0.7-py2.py3-none-any.whl", hash = "sha256:c1975ae3053481d4c111abd38997a5b5bb89235a1e3215f995d835942925fe7b"}, ] +[[package]] +name = "django-model-utils" +version = "4.5.1" +requires_python = ">=3.8" +summary = "Django model mixins and utilities" +groups = ["default"] +marker = "python_version == \"3.11\"" +dependencies = [ + "Django>=3.2", +] +files = [ + {file = "django_model_utils-4.5.1-py3-none-any.whl", hash = "sha256:f1141fc71796242edeffed5ad53a8cc57f00d345eb5a3a63e3f69401cd562ee2"}, + {file = "django_model_utils-4.5.1.tar.gz", hash = "sha256:1220f22d9a467d53a1e0f4cda4857df0b2f757edf9a29955c42461988caa648a"}, +] + [[package]] name = "django-mysql" version = "4.14.0" @@ -705,6 +732,20 @@ files = [ {file = "django_tables2-2.7.0-py2.py3-none-any.whl", hash = "sha256:99e06d966ca8ac69fd74092eb45c79a280dd5ca0ccb81395d96261f62128e1af"}, ] +[[package]] +name = "django-template-partials" +version = "24.2" +summary = "django-template-partials" +groups = ["default"] +marker = "python_version == \"3.11\"" +dependencies = [ + "Django", +] +files = [ + {file = "django-template-partials-24.2.tar.gz", hash = "sha256:e594063faec6d012df3a4170edf3c5dcc07c82b1c311aa42a1a5838493fd3a72"}, + {file = "django_template_partials-24.2-py2.py3-none-any.whl", hash = "sha256:b859072e6b3cd780743399bf5e9cee8be1c56c88844425a4e669a65c136205b2"}, +] + [[package]] name = "django-vite" version = "3.0.4" @@ -925,6 +966,107 @@ files = [ {file = "frozenlist-1.4.1.tar.gz", hash = "sha256:c037a86e8513059a2613aaba4d817bb90b9d9b6b69aace3ce9c877e8c8ed402b"}, ] +[[package]] +name = "google-api-core" +version = "2.19.1" +requires_python = ">=3.7" +summary = "Google API client core library" +groups = ["default"] +marker = "python_version == \"3.11\"" +dependencies = [ + "google-auth<3.0.dev0,>=2.14.1", + "googleapis-common-protos<2.0.dev0,>=1.56.2", + "proto-plus<2.0.0dev,>=1.22.3", + "protobuf!=3.20.0,!=3.20.1,!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<6.0.0.dev0,>=3.19.5", + "requests<3.0.0.dev0,>=2.18.0", +] +files = [ + {file = "google-api-core-2.19.1.tar.gz", hash = "sha256:f4695f1e3650b316a795108a76a1c416e6afb036199d1c1f1f110916df479ffd"}, + {file = "google_api_core-2.19.1-py3-none-any.whl", hash = "sha256:f12a9b8309b5e21d92483bbd47ce2c445861ec7d269ef6784ecc0ea8c1fa6125"}, +] + +[[package]] +name = "google-api-python-client" +version = "2.139.0" +requires_python = ">=3.7" +summary = "Google API Client Library for Python" +groups = ["default"] +marker = "python_version == \"3.11\"" +dependencies = [ + "google-api-core!=2.0.*,!=2.1.*,!=2.2.*,!=2.3.0,<3.0.0.dev0,>=1.31.5", + "google-auth!=2.24.0,!=2.25.0,<3.0.0.dev0,>=1.32.0", + "google-auth-httplib2<1.0.0,>=0.2.0", + "httplib2<1.dev0,>=0.19.0", + "uritemplate<5,>=3.0.1", +] +files = [ + {file = "google_api_python_client-2.139.0-py2.py3-none-any.whl", hash = "sha256:1850a92505d91a82e2ca1635ab2b8dff179f4b67082c2651e1db332e8039840c"}, + {file = "google_api_python_client-2.139.0.tar.gz", hash = "sha256:ed4bc3abe2c060a87412465b4e8254620bbbc548eefc5388e2c5ff912d36a68b"}, +] + +[[package]] +name = "google-auth" +version = "2.32.0" +requires_python = ">=3.7" +summary = "Google Authentication Library" +groups = ["default"] +marker = "python_version == \"3.11\"" +dependencies = [ + "cachetools<6.0,>=2.0.0", + "pyasn1-modules>=0.2.1", + "rsa<5,>=3.1.4", +] +files = [ + {file = "google_auth-2.32.0-py2.py3-none-any.whl", hash = "sha256:53326ea2ebec768070a94bee4e1b9194c9646ea0c2bd72422785bd0f9abfad7b"}, + {file = "google_auth-2.32.0.tar.gz", hash = "sha256:49315be72c55a6a37d62819e3573f6b416aca00721f7e3e31a008d928bf64022"}, +] + +[[package]] +name = "google-auth-httplib2" +version = "0.2.0" +summary = "Google Authentication Library: httplib2 transport" +groups = ["default"] +marker = "python_version == \"3.11\"" +dependencies = [ + "google-auth", + "httplib2>=0.19.0", +] +files = [ + {file = "google-auth-httplib2-0.2.0.tar.gz", hash = "sha256:38aa7badf48f974f1eb9861794e9c0cb2a0511a4ec0679b1f886d108f5640e05"}, + {file = "google_auth_httplib2-0.2.0-py2.py3-none-any.whl", hash = "sha256:b65a0a2123300dd71281a7bf6e64d65a0759287df52729bdd1ae2e47dc311a3d"}, +] + +[[package]] +name = "google-auth-oauthlib" +version = "1.2.1" +requires_python = ">=3.6" +summary = "Google Authentication Library" +groups = ["default"] +marker = "python_version == \"3.11\"" +dependencies = [ + "google-auth>=2.15.0", + "requests-oauthlib>=0.7.0", +] +files = [ + {file = "google_auth_oauthlib-1.2.1-py2.py3-none-any.whl", hash = "sha256:2d58a27262d55aa1b87678c3ba7142a080098cbc2024f903c62355deb235d91f"}, + {file = "google_auth_oauthlib-1.2.1.tar.gz", hash = "sha256:afd0cad092a2eaa53cd8e8298557d6de1034c6cb4a740500b5357b648af97263"}, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.63.2" +requires_python = ">=3.7" +summary = "Common protobufs used in Google APIs" +groups = ["default"] +marker = "python_version == \"3.11\"" +dependencies = [ + "protobuf!=3.20.0,!=3.20.1,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<6.0.0.dev0,>=3.20.2", +] +files = [ + {file = "googleapis-common-protos-1.63.2.tar.gz", hash = "sha256:27c5abdffc4911f28101e635de1533fb4cfd2c37fbaa9174587c799fac90aa87"}, + {file = "googleapis_common_protos-1.63.2-py2.py3-none-any.whl", hash = "sha256:27a2499c7e8aff199665b22741997e485eccc8645aa9176c7c988e6fae507945"}, +] + [[package]] name = "h11" version = "0.14.0" @@ -980,6 +1122,22 @@ files = [ {file = "html5lib-1.1.tar.gz", hash = "sha256:b2e5b40261e20f354d198eae92afc10d750afb487ed5e50f9c4eaf07c184146f"}, ] +[[package]] +name = "httplib2" +version = "0.22.0" +requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +summary = "A comprehensive HTTP client library." +groups = ["default"] +marker = "python_version == \"3.11\"" +dependencies = [ + "pyparsing!=3.0.0,!=3.0.1,!=3.0.2,!=3.0.3,<4,>=2.4.2; python_version > \"3.0\"", + "pyparsing<3,>=2.4.2; python_version < \"3.0\"", +] +files = [ + {file = "httplib2-0.22.0-py3-none-any.whl", hash = "sha256:14ae0a53c1ba8f3d37e9e27cf37eabb0fb9980f435ba405d546948b009dd64dc"}, + {file = "httplib2-0.22.0.tar.gz", hash = "sha256:d7a10bc5ef5ab08322488bde8c726eeee5c8618723fdb399597ec58f3d82df81"}, +] + [[package]] name = "httptools" version = "0.6.1" @@ -1286,6 +1444,18 @@ files = [ {file = "nh3-0.2.18.tar.gz", hash = "sha256:94a166927e53972a9698af9542ace4e38b9de50c34352b962f4d9a7d4c927af4"}, ] +[[package]] +name = "oauthlib" +version = "3.2.2" +requires_python = ">=3.6" +summary = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" +groups = ["default"] +marker = "python_version == \"3.11\"" +files = [ + {file = "oauthlib-3.2.2-py3-none-any.whl", hash = "sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca"}, + {file = "oauthlib-3.2.2.tar.gz", hash = "sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918"}, +] + [[package]] name = "odfpy" version = "1.4.1" @@ -1409,6 +1579,34 @@ files = [ {file = "prompt_toolkit-3.0.47.tar.gz", hash = "sha256:1e1b29cb58080b1e69f207c893a1a7bf16d127a5c30c9d17a25a5d77792e5360"}, ] +[[package]] +name = "proto-plus" +version = "1.24.0" +requires_python = ">=3.7" +summary = "Beautiful, Pythonic protocol buffers." +groups = ["default"] +marker = "python_version == \"3.11\"" +dependencies = [ + "protobuf<6.0.0dev,>=3.19.0", +] +files = [ + {file = "proto-plus-1.24.0.tar.gz", hash = "sha256:30b72a5ecafe4406b0d339db35b56c4059064e69227b8c3bda7462397f966445"}, + {file = "proto_plus-1.24.0-py3-none-any.whl", hash = "sha256:402576830425e5f6ce4c2a6702400ac79897dab0b4343821aa5188b0fab81a12"}, +] + +[[package]] +name = "protobuf" +version = "5.27.3" +requires_python = ">=3.8" +summary = "" +groups = ["default"] +marker = "python_version == \"3.11\"" +files = [ + {file = "protobuf-5.27.3-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:a55c48f2a2092d8e213bd143474df33a6ae751b781dd1d1f4d953c128a415b25"}, + {file = "protobuf-5.27.3-py3-none-any.whl", hash = "sha256:8572c6533e544ebf6899c360e91d6bcbbee2549251643d32c52cf8a5de295ba5"}, + {file = "protobuf-5.27.3.tar.gz", hash = "sha256:82460903e640f2b7e34ee81a947fdaad89de796d324bcbc38ff5430bcdead82c"}, +] + [[package]] name = "ptyprocess" version = "0.7.0" @@ -1494,6 +1692,18 @@ files = [ {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, ] +[[package]] +name = "pyparsing" +version = "3.1.2" +requires_python = ">=3.6.8" +summary = "pyparsing module - Classes and methods to define and execute parsing grammars" +groups = ["default"] +marker = "python_version == \"3.11\"" +files = [ + {file = "pyparsing-3.1.2-py3-none-any.whl", hash = "sha256:f9db75911801ed778fe61bb643079ff86601aca99fcae6345aa67292038fb742"}, + {file = "pyparsing-3.1.2.tar.gz", hash = "sha256:a1bac0ce561155ecc3ed78ca94d3c9378656ad4c94c1270de543f621420f94ad"}, +] + [[package]] name = "pyphen" version = "0.16.0" @@ -1590,6 +1800,37 @@ files = [ {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, ] +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +requires_python = ">=3.4" +summary = "OAuthlib authentication support for Requests." +groups = ["default"] +marker = "python_version == \"3.11\"" +dependencies = [ + "oauthlib>=3.0.0", + "requests>=2.0.0", +] +files = [ + {file = "requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9"}, + {file = "requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36"}, +] + +[[package]] +name = "rsa" +version = "4.9" +requires_python = ">=3.6,<4" +summary = "Pure-Python RSA implementation" +groups = ["default"] +marker = "python_version == \"3.11\"" +dependencies = [ + "pyasn1>=0.1.3", +] +files = [ + {file = "rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7"}, + {file = "rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"}, +] + [[package]] name = "ruff" version = "0.5.6" @@ -1977,6 +2218,18 @@ files = [ {file = "udm_rest_client-1.2.3-py2.py3-none-any.whl", hash = "sha256:ee29e94e3ba5fba63a694e33d119b1af7450afcdce3a44301d4cd5ddfa1f980b"}, ] +[[package]] +name = "uritemplate" +version = "4.1.1" +requires_python = ">=3.6" +summary = "Implementation of RFC 6570 URI Templates" +groups = ["default"] +marker = "python_version == \"3.11\"" +files = [ + {file = "uritemplate-4.1.1-py2.py3-none-any.whl", hash = "sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e"}, + {file = "uritemplate-4.1.1.tar.gz", hash = "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0"}, +] + [[package]] name = "urllib3" version = "2.2.2" diff --git a/pyproject.toml b/pyproject.toml index 7e0fae4..806d113 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,10 @@ dependencies = [ "django-bootstrap5~=24.2", "django-configurations[database,email]~=2.5", "django-vite~=3.0", + "django-template-partials~=24.2", + "google-api-python-client~=2.139", + "google-auth-oauthlib~=1.2", + "django-model-utils~=4.5", ] requires-python = ">=3.11" @@ -80,6 +84,7 @@ indent = 2 blank_line_after_tag = "load,extends" max_blank_lines = 1 ignore = "T003,H017,H021,H030,H031" +custom_blocks = "partialdef" format_css = true format_js = true diff --git a/reservations/apps.py b/reservations/apps.py index 2dbb2ca..6d90d1b 100644 --- a/reservations/apps.py +++ b/reservations/apps.py @@ -1,6 +1,24 @@ from django.apps import AppConfig +from django.db.models.signals import post_migrate + + +def post_migrate_callback(sender, **kwargs): + from django_q.models import Schedule + + from cmsmanage.django_q2_helper import ensure_scheduled + + from .tasks.sync_google_calendar import sync_reservations_with_google_calendar + + ensure_scheduled( + sync_reservations_with_google_calendar, + schedule_type=Schedule.MINUTES, + minutes=5, + ) class ReservationsConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "reservations" + + def ready(self): + post_migrate.connect(post_migrate_callback, sender=self) diff --git a/reservations/management/__init__.py b/reservations/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/reservations/management/commands/__init__.py b/reservations/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/reservations/management/commands/sync_reservations_with_google_calendar.py b/reservations/management/commands/sync_reservations_with_google_calendar.py new file mode 100644 index 0000000..8ef4632 --- /dev/null +++ b/reservations/management/commands/sync_reservations_with_google_calendar.py @@ -0,0 +1,21 @@ +import logging + +from django.core.management.base import BaseCommand + +from reservations.tasks.sync_google_calendar import ( + logger, + sync_reservations_with_google_calendar, +) + + +class Command(BaseCommand): + def handle(self, *args, verbosity: int, **options): + verbosity_levels = { + 0: logging.ERROR, + 1: logging.WARNING, + 2: logging.INFO, + 3: logging.DEBUG, + } + logger.setLevel(verbosity_levels.get(verbosity, logging.WARNING)) + + sync_reservations_with_google_calendar() diff --git a/reservations/tasks/__init__.py b/reservations/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/reservations/tasks/sync_google_calendar.py b/reservations/tasks/sync_google_calendar.py new file mode 100644 index 0000000..8622b3a --- /dev/null +++ b/reservations/tasks/sync_google_calendar.py @@ -0,0 +1,189 @@ +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 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: + # TODO: handle external events (either Bookly or manually created) + # NOTE: this will also need to check for deleted events + logger.debug( + "Event in Google Calendar not originated by CMSManage: skipping for now | %s", + event["id"], + ) + + 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: + # 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)