reservations: Add task to sync with Google Calendar

This commit is contained in:
Adam Goldsmith 2024-08-05 22:08:06 -04:00
parent 075812face
commit 35c063c44e
9 changed files with 493 additions and 1 deletions

View File

@ -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)

255
pdm.lock
View File

@ -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"

View File

@ -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

View File

@ -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)

View File

View File

@ -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()

View File

View File

@ -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)