port to new django, AI automated
This commit is contained in:
@@ -1,70 +1,112 @@
|
||||
import logging
|
||||
import httplib2
|
||||
"""
|
||||
Google Calendar synchronization module.
|
||||
|
||||
This module handles synchronization between the local event database
|
||||
and Google Calendar, including push notifications for real-time updates.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import time
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from eventplanner.models import Event, EventParticipation
|
||||
from eventplanner_gcal.models import GCalMapping, GCalPushChannel, UserGCalCoupling
|
||||
# noinspection PyUnresolvedReferences,PyUnresolvedReferences
|
||||
from apiclient.http import BatchHttpRequest
|
||||
from builtins import str as text # python2 and python3
|
||||
from django.conf import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Module-level service object cache
|
||||
_service_object = None
|
||||
|
||||
# ---------------------------------- Authentication using oauth2 -----------------------------------------------------
|
||||
|
||||
def create_gcal_service_object():
|
||||
"""Creates a Google API service object. This object is required whenever a Google API call is made"""
|
||||
from oauth2client.file import Storage
|
||||
# noinspection PyUnresolvedReferences
|
||||
from apiclient.discovery import build
|
||||
"""
|
||||
Creates a Google API service object.
|
||||
|
||||
gcal_settings = settings.GCAL_COUPLING
|
||||
This object is required whenever a Google API call is made.
|
||||
Uses the new google-auth library instead of oauth2client.
|
||||
"""
|
||||
try:
|
||||
import os
|
||||
import pickle
|
||||
|
||||
storage = Storage(gcal_settings['credentials_file'])
|
||||
credentials = storage.get()
|
||||
|
||||
logger.debug("Credentials", credentials)
|
||||
if credentials is None or credentials.invalid is True:
|
||||
# flow = client.flow_from_clientsecrets(CLIENT_SECRET_FILE, SCOPES)
|
||||
logger.error("Unable to initialize Google Calendar coupling. Check your settings!")
|
||||
from google.auth.transport.requests import Request
|
||||
from google.oauth2.credentials import Credentials
|
||||
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||
from googleapiclient.discovery import build
|
||||
except ImportError as e:
|
||||
logger.error(f"Required Google API libraries not installed: {e}")
|
||||
return None
|
||||
|
||||
http = httplib2.Http()
|
||||
http = credentials.authorize(http)
|
||||
res = build(serviceName='calendar', version='v3',
|
||||
http=http, developerKey=gcal_settings['developerKey'])
|
||||
gcal_settings = settings.GCAL_COUPLING
|
||||
credentials_file = gcal_settings["credentials_file"]
|
||||
|
||||
if res is None:
|
||||
logger.error("Authentication at Google API failed. Check your settings!")
|
||||
return res
|
||||
creds = None
|
||||
|
||||
# Try to load existing credentials
|
||||
if os.path.exists(credentials_file):
|
||||
try:
|
||||
with open(credentials_file, "rb") as token:
|
||||
creds = pickle.load(token)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load credentials from {credentials_file}: {e}")
|
||||
|
||||
# Check if credentials are valid
|
||||
if creds and creds.expired and creds.refresh_token:
|
||||
try:
|
||||
creds.refresh(Request())
|
||||
# Save refreshed credentials
|
||||
with open(credentials_file, "wb") as token:
|
||||
pickle.dump(creds, token)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to refresh credentials: {e}")
|
||||
creds = None
|
||||
|
||||
if not creds or not creds.valid:
|
||||
logger.error(
|
||||
"Invalid or missing Google Calendar credentials. "
|
||||
"Please run the credential setup process."
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
service = build("calendar", "v3", credentials=creds)
|
||||
return service
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to build Google Calendar service: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_service_object():
|
||||
if get_service_object.__serviceObject is None:
|
||||
get_service_object.__serviceObject = create_gcal_service_object()
|
||||
|
||||
return get_service_object.__serviceObject
|
||||
"""Get or create the Google Calendar service object."""
|
||||
global _service_object
|
||||
if _service_object is None:
|
||||
_service_object = create_gcal_service_object()
|
||||
return _service_object
|
||||
|
||||
|
||||
get_service_object.__serviceObject = None
|
||||
def reset_service_object():
|
||||
"""Reset the cached service object (useful for testing or credential refresh)."""
|
||||
global _service_object
|
||||
_service_object = None
|
||||
|
||||
|
||||
# --------------------- Building GCal event representation ----------------------------------------------------------
|
||||
# --------------------- Building GCal event representation ------------------------------------
|
||||
|
||||
|
||||
def build_gcal_attendees_obj(event):
|
||||
"""Builds an attendees object that is inserted into the GCal event.
|
||||
Attendees are all users that have a google mail address."""
|
||||
"""
|
||||
Builds an attendees object that is inserted into the GCal event.
|
||||
|
||||
Attendees are all users that have a Google mail address.
|
||||
"""
|
||||
result = []
|
||||
|
||||
for userMapping in UserGCalCoupling.objects.all():
|
||||
u = userMapping.user
|
||||
for user_mapping in UserGCalCoupling.objects.all():
|
||||
u = user_mapping.user
|
||||
|
||||
# No get or create here, since a create would trigger another synchronization
|
||||
# participation = EventParticipation.get_or_create( u, event )
|
||||
try:
|
||||
participation = EventParticipation.objects.get(event=event, user=u)
|
||||
local_status = participation.status
|
||||
@@ -76,31 +118,33 @@ def build_gcal_attendees_obj(event):
|
||||
status = "needsAction"
|
||||
if local_status == "?":
|
||||
status = "tentative"
|
||||
elif local_status == 'Yes':
|
||||
elif local_status == "Yes":
|
||||
status = "accepted"
|
||||
elif local_status == 'No':
|
||||
elif local_status == "No":
|
||||
status = "declined"
|
||||
|
||||
o = {
|
||||
'id': userMapping.email,
|
||||
'email': userMapping.email,
|
||||
'displayName': u.username,
|
||||
'comment': local_comment,
|
||||
'responseStatus': status,
|
||||
attendee = {
|
||||
"email": user_mapping.email,
|
||||
"displayName": u.username,
|
||||
"comment": local_comment,
|
||||
"responseStatus": status,
|
||||
}
|
||||
result.append(o)
|
||||
result.append(attendee)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def build_gcal_event(event, timezone="Europe/Berlin"):
|
||||
""" Builds a GCal event using a local event. """
|
||||
"""Builds a GCal event using a local event."""
|
||||
|
||||
def create_date_time_obj(date, time_obj):
|
||||
if time_obj is None:
|
||||
return {'date': text(date), 'timeZone': timezone}
|
||||
def create_datetime_obj(date, time_val):
|
||||
if time_val is None:
|
||||
return {"date": str(date), "timeZone": timezone}
|
||||
else:
|
||||
return {'dateTime': text(date) + 'T' + text(time_obj), 'timeZone': timezone}
|
||||
return {
|
||||
"dateTime": f"{date}T{time_val}",
|
||||
"timeZone": timezone,
|
||||
}
|
||||
|
||||
start_date = event.date
|
||||
end_date = event.end_date
|
||||
@@ -116,251 +160,356 @@ def build_gcal_event(event, timezone="Europe/Berlin"):
|
||||
else:
|
||||
end_time = datetime.time(22, 30)
|
||||
|
||||
g_location = text(event.location)
|
||||
g_location = str(event.location)
|
||||
if event.map_location:
|
||||
# Map location has the following format: latitude,longitude,zoomlevel
|
||||
# the first two are needed
|
||||
s = event.map_location.split(",")
|
||||
g_location = text("%s,%s" % (s[0], s[1]))
|
||||
parts = event.map_location.split(",")
|
||||
if len(parts) >= 2:
|
||||
g_location = f"{parts[0]},{parts[1]}"
|
||||
|
||||
gcal_settings = settings.GCAL_COUPLING
|
||||
|
||||
return {
|
||||
'summary': text(settings.GCAL_COUPLING['eventPrefix'] + event.title),
|
||||
'description': text(event.desc),
|
||||
'location': g_location,
|
||||
'start': create_date_time_obj(start_date, start_time),
|
||||
'end': create_date_time_obj(end_date, end_time),
|
||||
'extendedProperties': {
|
||||
'private': {
|
||||
'blechreizEvent': 'true',
|
||||
'blechreizID': event.id,
|
||||
"summary": gcal_settings["eventPrefix"] + event.title,
|
||||
"description": str(event.desc),
|
||||
"location": g_location,
|
||||
"start": create_datetime_obj(start_date, start_time),
|
||||
"end": create_datetime_obj(end_date, end_time),
|
||||
"extendedProperties": {
|
||||
"private": {
|
||||
"blechreizEvent": "true",
|
||||
"blechreizID": str(event.id),
|
||||
}
|
||||
},
|
||||
'attendees': build_gcal_attendees_obj(event),
|
||||
"attendees": build_gcal_attendees_obj(event),
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------ Callback Functions -------------------------------------------------------------------
|
||||
# ------------------------------ Callback Functions ------------------------------------------------
|
||||
|
||||
def on_gcal_event_created(_, response, exception=None):
|
||||
"""Callback function for created events to enter new gcal id in the mapping table"""
|
||||
|
||||
def on_gcal_event_created(request_id, response, exception=None):
|
||||
"""Callback function for created events to enter new gcal id in the mapping table."""
|
||||
if exception is not None:
|
||||
logger.error("on_gcal_event_created: Exception " + str(exception))
|
||||
logger.error(f"Error creating GCal event: {exception}")
|
||||
raise exception
|
||||
|
||||
google_id = response['id']
|
||||
django_id = response['extendedProperties']['private']['blechreizID']
|
||||
mapping = GCalMapping(gcal_id=google_id, event=Event.objects.get(pk=django_id))
|
||||
mapping.save()
|
||||
google_id = response["id"]
|
||||
django_id = response["extendedProperties"]["private"]["blechreizID"]
|
||||
|
||||
try:
|
||||
event = Event.objects.get(pk=django_id)
|
||||
mapping = GCalMapping(gcal_id=google_id, event=event)
|
||||
mapping.save()
|
||||
logger.info(f"Created mapping: GCal {google_id} <-> Event {django_id}")
|
||||
except Event.DoesNotExist:
|
||||
logger.error(f"Event {django_id} not found when creating GCal mapping")
|
||||
|
||||
|
||||
# ------------------------------ GCal Api Calls --------------------------------------------------------------------
|
||||
# ------------------------------ GCal Api Calls -------------------------------------------------
|
||||
|
||||
|
||||
def get_all_gcal_events(service, from_now=False):
|
||||
"""Retrieves all gcal events with custom property blechreizEvent=True i.e. all
|
||||
events that have been created by this script."""
|
||||
"""
|
||||
Retrieves all gcal events with custom property blechreizEvent=True.
|
||||
|
||||
These are all events that have been created by this script.
|
||||
"""
|
||||
if from_now:
|
||||
now = datetime.datetime.now()
|
||||
min_time = now.strftime("%Y-%m-%dT%H:%M:%S-00:00")
|
||||
else:
|
||||
min_time = '2000-01-01T00:00:00-00:00'
|
||||
min_time = "2000-01-01T00:00:00-00:00"
|
||||
|
||||
events = service.events().list(
|
||||
calendarId='primary',
|
||||
singleEvents=True,
|
||||
maxResults=1000,
|
||||
orderBy='startTime',
|
||||
timeMin=min_time,
|
||||
timeMax='2100-01-01T00:00:00-00:00',
|
||||
privateExtendedProperty='blechreizEvent=true',
|
||||
).execute()
|
||||
return events['items']
|
||||
try:
|
||||
events = (
|
||||
service.events()
|
||||
.list(
|
||||
calendarId="primary",
|
||||
singleEvents=True,
|
||||
maxResults=1000,
|
||||
orderBy="startTime",
|
||||
timeMin=min_time,
|
||||
timeMax="2100-01-01T00:00:00-00:00",
|
||||
privateExtendedProperty="blechreizEvent=true",
|
||||
)
|
||||
.execute()
|
||||
)
|
||||
return events.get("items", [])
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to retrieve GCal events: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def create_gcal_event(service, event, timezone="Europe/Berlin"):
|
||||
"""Creates a new gcal event using a local event"""
|
||||
def create_gcal_event_request(service, event, timezone="Europe/Berlin"):
|
||||
"""Creates a request to create a new gcal event using a local event."""
|
||||
google_event = build_gcal_event(event, timezone)
|
||||
return service.events().insert(calendarId='primary', body=google_event)
|
||||
return service.events().insert(calendarId="primary", body=google_event)
|
||||
|
||||
|
||||
def update_gcal_event(service, event, timezone="Europe/Berlin"):
|
||||
"""Updates an existing gcal event, using a local event"""
|
||||
def update_gcal_event_request(service, event, timezone="Europe/Berlin"):
|
||||
"""Creates a request to update an existing gcal event using a local event."""
|
||||
google_event = build_gcal_event(event, timezone)
|
||||
try:
|
||||
mapping = GCalMapping.objects.get(event=event)
|
||||
except GCalMapping.DoesNotExist:
|
||||
return create_gcal_event(service, event, timezone)
|
||||
return create_gcal_event_request(service, event, timezone)
|
||||
|
||||
return service.events().patch(calendarId='primary', eventId=mapping.gcal_id, body=google_event)
|
||||
return service.events().patch(
|
||||
calendarId="primary", eventId=mapping.gcal_id, body=google_event
|
||||
)
|
||||
|
||||
|
||||
def delete_gcal_event(service, event):
|
||||
"""Deletes gcal that belongs to the given local event"""
|
||||
mapping = GCalMapping.objects.get(event=event)
|
||||
gcal_id = mapping.gcal_id
|
||||
mapping.delete()
|
||||
return service.events().delete(calendarId='primary', eventId=gcal_id)
|
||||
|
||||
|
||||
# ------------------------------------- Synchronization -------------------------------------------------------------
|
||||
|
||||
def delete_all_gcal_events(service=None):
|
||||
"""Deletes all gcal events that have been created by this script"""
|
||||
|
||||
if service is None:
|
||||
service = get_service_object()
|
||||
|
||||
gcal_ids = [ev['id'] for ev in get_all_gcal_events(service)]
|
||||
num_ids = len(gcal_ids)
|
||||
if num_ids == 0:
|
||||
return num_ids
|
||||
|
||||
batch = BatchHttpRequest()
|
||||
for ev_id in gcal_ids:
|
||||
batch.add(service.events().delete(calendarId='primary', eventId=ev_id))
|
||||
batch.execute()
|
||||
|
||||
GCalMapping.objects.all().delete()
|
||||
|
||||
return num_ids
|
||||
|
||||
|
||||
def sync_from_local_to_google(service=None):
|
||||
""" Creates a google event for each local event (if it does not exist yet) and deletes all google events
|
||||
that are not found in local database. Updates participation info of gcal events using local data
|
||||
"""
|
||||
|
||||
if service is None:
|
||||
service = get_service_object()
|
||||
|
||||
all_events = get_all_gcal_events(service)
|
||||
|
||||
events_at_google_django_id = set()
|
||||
events_at_google_google_id = set()
|
||||
for gcal_ev in all_events:
|
||||
events_at_google_django_id.add(int(gcal_ev['extendedProperties']['private']['blechreizID']))
|
||||
events_at_google_google_id.add(gcal_ev['id'])
|
||||
|
||||
local_events_django_id = set(Event.objects.all().values_list('pk', flat=True))
|
||||
local_events_google_id = set(GCalMapping.objects.all().values_list('gcal_id', flat=True))
|
||||
|
||||
events_to_create_django_id = local_events_django_id - events_at_google_django_id
|
||||
events_to_delete_google_id = events_at_google_google_id - local_events_google_id
|
||||
|
||||
batch = BatchHttpRequest()
|
||||
|
||||
batch_is_empty = True
|
||||
for event_django_id in events_to_create_django_id:
|
||||
batch.add(create_gcal_event(service, Event.objects.get(pk=event_django_id)), callback=on_gcal_event_created)
|
||||
batch_is_empty = False
|
||||
|
||||
for eventGoogleID in events_to_delete_google_id:
|
||||
batch.add(service.events().delete(calendarId='primary', eventId=eventGoogleID))
|
||||
batch_is_empty = False
|
||||
|
||||
for gcal_ev in all_events:
|
||||
event_django_id = int(gcal_ev['extendedProperties']['private']['blechreizID'])
|
||||
try:
|
||||
django_ev = Event.objects.get(pk=event_django_id)
|
||||
if 'attendees' not in gcal_ev:
|
||||
gcal_ev['attendees'] = []
|
||||
|
||||
if gcal_ev['attendees'] != build_gcal_attendees_obj(django_ev):
|
||||
batch.add(update_gcal_event(service, django_ev))
|
||||
batch_is_empty = False
|
||||
except Event.DoesNotExist:
|
||||
pass
|
||||
|
||||
if not batch_is_empty:
|
||||
batch.execute()
|
||||
|
||||
return len(events_to_create_django_id), len(events_to_delete_google_id)
|
||||
|
||||
|
||||
def sync_from_google_to_local(service=None):
|
||||
"""Retrieves only participation infos for all events and updates local database if anything has changed. """
|
||||
|
||||
if service is None:
|
||||
service = get_service_object()
|
||||
|
||||
new_status_received = False
|
||||
all_events = get_all_gcal_events(service, from_now=True)
|
||||
for e in all_events:
|
||||
local_id = e['extendedProperties']['private']['blechreizID']
|
||||
local_event = Event.objects.get(pk=local_id)
|
||||
for a in e['attendees']:
|
||||
user = UserGCalCoupling.objects.get(email=a['email']).user
|
||||
part = EventParticipation.get_or_create(user, local_event)
|
||||
if 'comment' in a:
|
||||
part.comment = a['comment']
|
||||
|
||||
if a['responseStatus'] == 'needsAction':
|
||||
part.status = "-"
|
||||
elif a['responseStatus'] == 'tentative':
|
||||
part.status = '?'
|
||||
elif a['responseStatus'] == 'accepted':
|
||||
part.status = 'Yes'
|
||||
elif a['responseStatus'] == 'declined':
|
||||
part.status = 'No'
|
||||
else:
|
||||
logger.error("Unknown response status when mapping gcal event: " + a['responseStatus'])
|
||||
|
||||
prev = EventParticipation.objects.get(event=part.event, user=part.user)
|
||||
|
||||
# Important: Save only if the participation info has changed
|
||||
# otherwise everything is synced back to google via the post save signal
|
||||
# and an endless loop is entered
|
||||
if prev.status != part.status or prev.comment != part.comment:
|
||||
part.save()
|
||||
new_status_received = True
|
||||
|
||||
return new_status_received
|
||||
def delete_gcal_event_request(service, event):
|
||||
"""Creates a request to delete gcal event that belongs to the given local event."""
|
||||
try:
|
||||
mapping = GCalMapping.objects.get(event=event)
|
||||
gcal_id = mapping.gcal_id
|
||||
mapping.delete()
|
||||
return service.events().delete(calendarId="primary", eventId=gcal_id)
|
||||
except GCalMapping.DoesNotExist:
|
||||
logger.warning(f"No GCal mapping found for event {event.id}")
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------- Synchronization ----------------------------------------------------
|
||||
|
||||
|
||||
def check_gcal_subscription(service=None, time_to_live=14 * 24 * 3600, renew_before_expiry=None):
|
||||
"""Google offers a push service if any event information has changed. This works using a so called
|
||||
channel, which has a certain time to live. This method checks that a valid channel exists:
|
||||
- if none exists a new one is created
|
||||
- if existing channel does expire soon, the channel is renewed
|
||||
- if channel has already expired a sync is triggered and a new channel is created
|
||||
def delete_all_gcal_events(service=None):
|
||||
"""Deletes all gcal events that have been created by this script."""
|
||||
if service is None:
|
||||
service = get_service_object()
|
||||
|
||||
if service is None:
|
||||
logger.error("No service object available")
|
||||
return 0
|
||||
|
||||
gcal_events = get_all_gcal_events(service)
|
||||
gcal_ids = [ev["id"] for ev in gcal_events]
|
||||
count = len(gcal_ids)
|
||||
|
||||
if count == 0:
|
||||
return 0
|
||||
|
||||
# Use batch request for efficiency
|
||||
from googleapiclient.http import BatchHttpRequest
|
||||
|
||||
batch = BatchHttpRequest()
|
||||
for gcal_id in gcal_ids:
|
||||
batch.add(service.events().delete(calendarId="primary", eventId=gcal_id))
|
||||
|
||||
try:
|
||||
batch.execute()
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting GCal events: {e}")
|
||||
|
||||
GCalMapping.objects.all().delete()
|
||||
|
||||
return count
|
||||
|
||||
|
||||
def sync_from_local_to_google(service=None):
|
||||
"""
|
||||
Creates a google event for each local event (if it does not exist yet) and
|
||||
deletes all google events that are not found in local database.
|
||||
Updates participation info of gcal events using local data.
|
||||
"""
|
||||
if service is None:
|
||||
service = get_service_object()
|
||||
|
||||
if service is None:
|
||||
logger.error("No service object available for sync")
|
||||
return 0, 0
|
||||
|
||||
all_events = get_all_gcal_events(service)
|
||||
|
||||
events_at_google_django_id = set()
|
||||
events_at_google_google_id = set()
|
||||
|
||||
for gcal_ev in all_events:
|
||||
try:
|
||||
django_id = int(gcal_ev["extendedProperties"]["private"]["blechreizID"])
|
||||
events_at_google_django_id.add(django_id)
|
||||
events_at_google_google_id.add(gcal_ev["id"])
|
||||
except (KeyError, ValueError) as e:
|
||||
logger.warning(f"Invalid GCal event structure: {e}")
|
||||
|
||||
local_events_django_id = set(Event.objects.all().values_list("pk", flat=True))
|
||||
local_events_google_id = set(
|
||||
GCalMapping.objects.all().values_list("gcal_id", flat=True)
|
||||
)
|
||||
|
||||
events_to_create_django_id = local_events_django_id - events_at_google_django_id
|
||||
events_to_delete_google_id = events_at_google_google_id - local_events_google_id
|
||||
|
||||
from googleapiclient.http import BatchHttpRequest
|
||||
|
||||
batch = BatchHttpRequest()
|
||||
batch_is_empty = True
|
||||
|
||||
for event_django_id in events_to_create_django_id:
|
||||
try:
|
||||
event = Event.objects.get(pk=event_django_id)
|
||||
batch.add(
|
||||
create_gcal_event_request(service, event),
|
||||
callback=on_gcal_event_created,
|
||||
)
|
||||
batch_is_empty = False
|
||||
except Event.DoesNotExist:
|
||||
pass
|
||||
|
||||
for event_google_id in events_to_delete_google_id:
|
||||
batch.add(
|
||||
service.events().delete(calendarId="primary", eventId=event_google_id)
|
||||
)
|
||||
batch_is_empty = False
|
||||
|
||||
for gcal_ev in all_events:
|
||||
try:
|
||||
event_django_id = int(
|
||||
gcal_ev["extendedProperties"]["private"]["blechreizID"]
|
||||
)
|
||||
django_ev = Event.objects.get(pk=event_django_id)
|
||||
|
||||
gcal_attendees = gcal_ev.get("attendees", [])
|
||||
local_attendees = build_gcal_attendees_obj(django_ev)
|
||||
|
||||
# Simple comparison - check if attendees differ
|
||||
if gcal_attendees != local_attendees:
|
||||
batch.add(update_gcal_event_request(service, django_ev))
|
||||
batch_is_empty = False
|
||||
except Event.DoesNotExist:
|
||||
pass
|
||||
except (KeyError, ValueError):
|
||||
pass
|
||||
|
||||
if not batch_is_empty:
|
||||
try:
|
||||
batch.execute()
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing batch request: {e}")
|
||||
|
||||
return len(events_to_create_django_id), len(events_to_delete_google_id)
|
||||
|
||||
|
||||
def sync_from_google_to_local(service=None):
|
||||
"""
|
||||
Retrieves only participation infos for all events and
|
||||
updates local database if anything has changed.
|
||||
"""
|
||||
if service is None:
|
||||
service = get_service_object()
|
||||
|
||||
if service is None:
|
||||
logger.error("No service object available for sync")
|
||||
return False
|
||||
|
||||
new_status_received = False
|
||||
all_events = get_all_gcal_events(service, from_now=True)
|
||||
|
||||
for e in all_events:
|
||||
try:
|
||||
local_id = e["extendedProperties"]["private"]["blechreizID"]
|
||||
local_event = Event.objects.get(pk=local_id)
|
||||
|
||||
for a in e.get("attendees", []):
|
||||
try:
|
||||
user_coupling = UserGCalCoupling.objects.get(email=a["email"])
|
||||
user = user_coupling.user
|
||||
part = EventParticipation.get_or_create(user, local_event)
|
||||
|
||||
if "comment" in a:
|
||||
part.comment = a["comment"]
|
||||
|
||||
response_status = a.get("responseStatus", "needsAction")
|
||||
if response_status == "needsAction":
|
||||
part.status = "-"
|
||||
elif response_status == "tentative":
|
||||
part.status = "?"
|
||||
elif response_status == "accepted":
|
||||
part.status = "Yes"
|
||||
elif response_status == "declined":
|
||||
part.status = "No"
|
||||
else:
|
||||
logger.error(
|
||||
f"Unknown response status when mapping gcal event: {response_status}"
|
||||
)
|
||||
|
||||
prev = EventParticipation.objects.get(
|
||||
event=part.event, user=part.user
|
||||
)
|
||||
|
||||
# Important: Save only if the participation info has changed
|
||||
# otherwise everything is synced back to google via the post save signal
|
||||
# and an endless loop is entered
|
||||
if prev.status != part.status or prev.comment != part.comment:
|
||||
part.save()
|
||||
new_status_received = True
|
||||
|
||||
except UserGCalCoupling.DoesNotExist:
|
||||
pass
|
||||
|
||||
except Event.DoesNotExist:
|
||||
logger.warning(f"Event with id {local_id} not found in local database")
|
||||
except KeyError as e:
|
||||
logger.warning(f"Invalid event structure: {e}")
|
||||
|
||||
return new_status_received
|
||||
|
||||
|
||||
# ------------------------------------- Push Channel Management ----------------------------------------------------
|
||||
|
||||
|
||||
def check_gcal_subscription(
|
||||
service=None, time_to_live=14 * 24 * 3600, renew_before_expiry=None
|
||||
):
|
||||
"""
|
||||
Google offers a push service if any event information has changed.
|
||||
|
||||
This works using a so called channel, which has a certain time to live.
|
||||
This method checks that a valid channel exists:
|
||||
- if none exists a new one is created
|
||||
- if existing channel does expire soon, the channel is renewed
|
||||
- if channel has already expired a sync is triggered and a new channel is created
|
||||
"""
|
||||
if service is None:
|
||||
service = get_service_object()
|
||||
|
||||
if service is None:
|
||||
logger.error("No service object available")
|
||||
return
|
||||
|
||||
if renew_before_expiry is None:
|
||||
renew_before_expiry = 0.8 * time_to_live
|
||||
|
||||
callback_url = settings.GCAL_COUPLING['push_url']
|
||||
callback_url = settings.GCAL_COUPLING["push_url"]
|
||||
|
||||
# Test if a channel already exists for this callbackURL
|
||||
try:
|
||||
db_channel = GCalPushChannel.objects.get(address=callback_url)
|
||||
g_channel = db_channel.to_google_channel()
|
||||
|
||||
# if expiration time between 0 and two days: stop and create new channel
|
||||
cur_time = int(time.time() * 1000)
|
||||
if g_channel.expiration > cur_time:
|
||||
|
||||
if db_channel.expiration > cur_time:
|
||||
# not yet expired
|
||||
if cur_time + renew_before_expiry * 1000 > g_channel.expiration:
|
||||
# will expire in less than "renewBeforeExpiry"
|
||||
logger.info("Renewing Google Calendar Subscription: " + callback_url)
|
||||
GCalPushChannel.stop(service, g_channel)
|
||||
if cur_time + renew_before_expiry * 1000 > db_channel.expiration:
|
||||
# will expire in less than "renew_before_expiry"
|
||||
logger.info(f"Renewing Google Calendar Subscription: {callback_url}")
|
||||
db_channel.stop(service)
|
||||
GCalPushChannel.create_new(callback_url, service, time_to_live)
|
||||
else:
|
||||
logger.info("Channel active until %d " % (g_channel.expiration,))
|
||||
logger.info(f"Channel active until {db_channel.expiration}")
|
||||
else:
|
||||
logger.info("Google calendar subscription had expired - getting new subscription")
|
||||
logger.info(
|
||||
"Google calendar subscription had expired - getting new subscription"
|
||||
)
|
||||
# to get back in sync again we have to decide which data to take
|
||||
# so we use the local data as reference
|
||||
sync_from_local_to_google(service)
|
||||
GCalPushChannel.create_new(callback_url, service, time_to_live)
|
||||
|
||||
except GCalPushChannel.DoesNotExist:
|
||||
# create new channel and save it in database
|
||||
logger.info("No CGalCallback Channel exists yet for: " + callback_url)
|
||||
logger.info(f"No GCalCallback Channel exists yet for: {callback_url}")
|
||||
# to get back in sync again we have to decide which data to take
|
||||
# so we use the local data as reference
|
||||
sync_from_local_to_google(service)
|
||||
@@ -368,45 +517,68 @@ def check_gcal_subscription(service=None, time_to_live=14 * 24 * 3600, renew_bef
|
||||
|
||||
|
||||
def stop_all_gcal_subscriptions(service=None):
|
||||
"""Stops the channel subscription """
|
||||
|
||||
"""Stops all channel subscriptions."""
|
||||
if service is None:
|
||||
service = get_service_object()
|
||||
|
||||
for dbChannel in GCalPushChannel.objects.all():
|
||||
logger.info("Stopping %s expiry at %d " % (dbChannel.id, dbChannel.expiration))
|
||||
GCalPushChannel.stop(service, dbChannel.to_google_channel())
|
||||
if service is None:
|
||||
logger.error("No service object available")
|
||||
return
|
||||
|
||||
for db_channel in GCalPushChannel.objects.all():
|
||||
logger.info(
|
||||
f"Stopping channel {db_channel.id} expiry at {db_channel.expiration}"
|
||||
)
|
||||
db_channel.stop(service)
|
||||
|
||||
|
||||
def check_if_google_callback_is_valid(token, channel_id, resource_id, service=None):
|
||||
"""Validate an incoming Google Calendar push notification."""
|
||||
if service is None:
|
||||
service = get_service_object()
|
||||
|
||||
all_channels = GCalPushChannel.objects.all()
|
||||
|
||||
if len(all_channels) == 0:
|
||||
return False # no known subscriptions -> callback has to be from an old channel
|
||||
|
||||
if len(all_channels) > 1:
|
||||
logger.warning("Multiple GCal subscriptions! This is strange and probably an error. "
|
||||
"All channels are closed and one new is created. ")
|
||||
logger.warning(
|
||||
"Multiple GCal subscriptions! This is strange and probably an error. "
|
||||
"All channels are closed and one new is created."
|
||||
)
|
||||
stop_all_gcal_subscriptions(service)
|
||||
check_gcal_subscription()
|
||||
all_channels = GCalPushChannel.objects.all()
|
||||
|
||||
assert (len(all_channels) == 1)
|
||||
if len(all_channels) != 1:
|
||||
return False
|
||||
|
||||
the_channel = all_channels[0]
|
||||
|
||||
if channel_id != the_channel.id or resource_id != the_channel.resource_id or token != the_channel.token:
|
||||
logger.warning("Got GCal Response from an unexpected Channel"
|
||||
"Got (%s,%s,%s) "
|
||||
"expected (%s,%s,%s) "
|
||||
"Old Channel is stopped."
|
||||
% (channel_id, resource_id, token, the_channel.id, the_channel.resource_id, the_channel.token))
|
||||
|
||||
channel_to_stop = GCalPushChannel(id=channel_id, resource_id=resource_id, token=token)
|
||||
GCalPushChannel.stop(service, channel_to_stop.to_google_channel())
|
||||
if (
|
||||
channel_id != the_channel.id
|
||||
or resource_id != the_channel.resource_id
|
||||
or token != the_channel.token
|
||||
):
|
||||
logger.warning(
|
||||
f"Got GCal Response from an unexpected Channel. "
|
||||
f"Got ({channel_id}, {resource_id}, {token}) "
|
||||
f"expected ({the_channel.id}, {the_channel.resource_id}, {the_channel.token}). "
|
||||
f"Old Channel is stopped."
|
||||
)
|
||||
|
||||
GCalPushChannel.stop_channel(service, channel_id, resource_id)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# Backwards compatibility aliases
|
||||
syncFromLocalToGoogle = sync_from_local_to_google
|
||||
syncFromGoogleToLocal = sync_from_google_to_local
|
||||
checkIfGoogleCallbackIsValid = check_if_google_callback_is_valid
|
||||
checkGCalSubscription = check_gcal_subscription
|
||||
stopAllGCalSubscriptions = stop_all_gcal_subscriptions
|
||||
deleteAllGCalEvents = delete_all_gcal_events
|
||||
getServiceObject = get_service_object
|
||||
|
||||
Reference in New Issue
Block a user