blechreiz-website/eventplanner_gcal/google_sync.py

413 lines
15 KiB
Python
Raw Permalink Normal View History

2014-04-26 11:17:10 +02:00
import logging
import httplib2
import datetime
import time
from eventplanner.models import Event, EventParticipation
from eventplanner_gcal.models import GCalMapping, GCalPushChannel, UserGCalCoupling
2023-01-03 20:13:35 +01:00
# noinspection PyUnresolvedReferences,PyUnresolvedReferences
2014-04-26 11:17:10 +02:00
from apiclient.http import BatchHttpRequest
2019-01-05 13:04:20 +01:00
from builtins import str as text # python2 and python3
2014-04-26 11:17:10 +02:00
from django.conf import settings
logger = logging.getLogger(__name__)
2019-01-05 13:04:20 +01:00
# ---------------------------------- Authentication using oauth2 -----------------------------------------------------
2014-04-26 11:17:10 +02:00
2019-01-05 13:04:20 +01:00
def create_gcal_service_object():
2014-04-26 11:17:10 +02:00
"""Creates a Google API service object. This object is required whenever a Google API call is made"""
from oauth2client.file import Storage
2023-01-03 20:13:35 +01:00
# noinspection PyUnresolvedReferences
2014-04-26 11:17:10 +02:00
from apiclient.discovery import build
gcal_settings = settings.GCAL_COUPLING
storage = Storage(gcal_settings['credentials_file'])
2014-04-26 11:17:10 +02:00
credentials = storage.get()
2019-01-05 13:04:20 +01:00
logger.debug("Credentials", credentials)
if credentials is None or credentials.invalid is True:
# flow = client.flow_from_clientsecrets(CLIENT_SECRET_FILE, SCOPES)
2014-04-26 11:17:10 +02:00
logger.error("Unable to initialize Google Calendar coupling. Check your settings!")
return None
http = httplib2.Http()
http = credentials.authorize(http)
res = build(serviceName='calendar', version='v3',
http=http, developerKey=gcal_settings['developerKey'])
2014-04-26 11:17:10 +02:00
if res is None:
2019-01-05 13:04:20 +01:00
logger.error("Authentication at Google API failed. Check your settings!")
2014-04-26 11:17:10 +02:00
return res
2019-01-05 13:04:20 +01:00
def get_service_object():
if get_service_object.__serviceObject is None:
get_service_object.__serviceObject = create_gcal_service_object()
2014-04-26 11:17:10 +02:00
2019-01-05 13:04:20 +01:00
return get_service_object.__serviceObject
2014-04-26 11:17:10 +02:00
2019-01-05 13:04:20 +01:00
get_service_object.__serviceObject = None
2014-04-26 11:17:10 +02:00
2019-01-05 13:04:20 +01:00
# --------------------- Building GCal event representation ----------------------------------------------------------
2014-04-26 11:17:10 +02:00
2019-01-05 13:04:20 +01:00
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."""
2014-04-26 11:17:10 +02:00
result = []
for userMapping in UserGCalCoupling.objects.all():
u = userMapping.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)
2019-01-05 13:04:20 +01:00
local_status = participation.status
local_comment = participation.comment
except EventParticipation.DoesNotExist:
2019-01-05 13:04:20 +01:00
local_status = "-"
local_comment = ""
2014-06-22 10:33:39 +02:00
status = "needsAction"
2019-01-05 13:04:20 +01:00
if local_status == "?":
status = "tentative"
elif local_status == 'Yes':
status = "accepted"
elif local_status == 'No':
status = "declined"
o = {
'id': userMapping.email,
2014-06-22 10:33:39 +02:00
'email': userMapping.email,
'displayName': u.username,
2019-01-05 13:04:20 +01:00
'comment': local_comment,
'responseStatus': status,
}
result.append(o)
2014-04-26 11:17:10 +02:00
return result
2019-01-05 13:04:20 +01:00
def build_gcal_event(event, timezone="Europe/Berlin"):
2014-04-26 11:17:10 +02:00
""" Builds a GCal event using a local event. """
2019-01-05 13:04:20 +01:00
def create_date_time_obj(date, time_obj):
if time_obj is None:
return {'date': text(date), 'timeZone': timezone}
2014-04-26 11:17:10 +02:00
else:
2019-01-05 13:04:20 +01:00
return {'dateTime': text(date) + 'T' + text(time_obj), 'timeZone': timezone}
2014-04-26 11:17:10 +02:00
2019-01-05 13:04:20 +01:00
start_date = event.date
end_date = event.end_date
if end_date is None:
end_date = start_date
2014-04-26 11:17:10 +02:00
2019-01-05 13:04:20 +01:00
start_time = event.meeting_time
if start_time is None:
start_time = event.time
2014-04-26 11:17:10 +02:00
2019-01-05 13:04:20 +01:00
if start_time is None:
end_time = None
2014-04-26 11:17:10 +02:00
else:
2019-01-05 13:04:20 +01:00
end_time = datetime.time(22, 30)
2014-04-26 11:17:10 +02:00
2019-01-05 13:04:20 +01:00
g_location = text(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(",")
2019-01-05 13:04:20 +01:00
g_location = text("%s,%s" % (s[0], s[1]))
2014-04-26 11:17:10 +02:00
return {
2019-01-05 13:04:20 +01:00
'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),
2014-04-26 11:17:10 +02:00
'extendedProperties': {
'private': {
'blechreizEvent': 'true',
'blechreizID': event.id,
}
},
2019-01-05 13:04:20 +01:00
'attendees': build_gcal_attendees_obj(event),
2014-04-26 11:17:10 +02:00
}
2019-01-05 13:04:20 +01:00
# ------------------------------ Callback Functions -------------------------------------------------------------------
2019-01-05 13:04:20 +01:00
def on_gcal_event_created(_, response, exception=None):
"""Callback function for created events to enter new gcal id in the mapping table"""
if exception is not None:
2019-01-05 13:04:20 +01:00
logger.error("on_gcal_event_created: Exception " + str(exception))
raise exception
2019-01-05 13:04:20 +01:00
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()
2014-04-26 11:17:10 +02:00
2019-01-05 13:04:20 +01:00
# ------------------------------ GCal Api Calls --------------------------------------------------------------------
2014-04-26 11:17:10 +02:00
2019-01-05 13:04:20 +01:00
def get_all_gcal_events(service, from_now=False):
2014-04-26 11:17:10 +02:00
"""Retrieves all gcal events with custom property blechreizEvent=True i.e. all
events that have been created by this script."""
2019-01-05 13:04:20 +01:00
if from_now:
2014-04-26 11:17:10 +02:00
now = datetime.datetime.now()
2019-01-05 13:04:20 +01:00
min_time = now.strftime("%Y-%m-%dT%H:%M:%S-00:00")
2014-04-26 11:17:10 +02:00
else:
2019-01-05 13:04:20 +01:00
min_time = '2000-01-01T00:00:00-00:00'
2014-04-26 11:17:10 +02:00
events = service.events().list(
calendarId='primary',
singleEvents=True,
maxResults=1000,
orderBy='startTime',
2019-01-05 13:04:20 +01:00
timeMin=min_time,
2014-04-26 11:17:10 +02:00
timeMax='2100-01-01T00:00:00-00:00',
privateExtendedProperty='blechreizEvent=true',
).execute()
2014-04-26 11:17:10 +02:00
return events['items']
2019-01-05 13:04:20 +01:00
def create_gcal_event(service, event, timezone="Europe/Berlin"):
2014-04-26 11:17:10 +02:00
"""Creates a new gcal event using a local event"""
2019-01-05 13:04:20 +01:00
google_event = build_gcal_event(event, timezone)
return service.events().insert(calendarId='primary', body=google_event)
2014-04-26 11:17:10 +02:00
2019-01-05 13:04:20 +01:00
def update_gcal_event(service, event, timezone="Europe/Berlin"):
2014-04-26 11:17:10 +02:00
"""Updates an existing gcal event, using a local event"""
2019-01-05 13:04:20 +01:00
google_event = build_gcal_event(event, timezone)
try:
mapping = GCalMapping.objects.get(event=event)
except GCalMapping.DoesNotExist:
2019-01-05 13:04:20 +01:00
return create_gcal_event(service, event, timezone)
2019-01-05 13:04:20 +01:00
return service.events().patch(calendarId='primary', eventId=mapping.gcal_id, body=google_event)
2014-04-26 11:17:10 +02:00
2019-01-05 13:04:20 +01:00
def delete_gcal_event(service, event):
2014-04-26 11:17:10 +02:00
"""Deletes gcal that belongs to the given local event"""
mapping = GCalMapping.objects.get(event=event)
2019-01-05 13:04:20 +01:00
gcal_id = mapping.gcal_id
mapping.delete()
2019-01-05 13:04:20 +01:00
return service.events().delete(calendarId='primary', eventId=gcal_id)
2014-04-26 11:17:10 +02:00
2019-01-05 13:04:20 +01:00
# ------------------------------------- Synchronization -------------------------------------------------------------
2014-04-26 11:17:10 +02:00
2019-01-05 13:04:20 +01:00
def delete_all_gcal_events(service=None):
2014-04-26 11:17:10 +02:00
"""Deletes all gcal events that have been created by this script"""
if service is None:
2019-01-05 13:04:20 +01:00
service = get_service_object()
2014-04-26 11:17:10 +02:00
2019-01-05 13:04:20 +01:00
gcal_ids = [ev['id'] for ev in get_all_gcal_events(service)]
num_ids = len(gcal_ids)
if num_ids == 0:
return num_ids
2014-04-26 11:17:10 +02:00
batch = BatchHttpRequest()
2019-01-05 13:04:20 +01:00
for ev_id in gcal_ids:
batch.add(service.events().delete(calendarId='primary', eventId=ev_id))
2014-04-26 11:17:10 +02:00
batch.execute()
GCalMapping.objects.all().delete()
2019-01-05 13:04:20 +01:00
return num_ids
2014-04-26 11:17:10 +02:00
2019-01-05 13:04:20 +01:00
def sync_from_local_to_google(service=None):
2014-04-26 11:17:10 +02:00
""" 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
"""
2019-01-05 13:04:20 +01:00
if service is None:
service = get_service_object()
2019-01-05 13:04:20 +01:00
all_events = get_all_gcal_events(service)
2014-04-26 11:17:10 +02:00
2019-01-05 13:04:20 +01:00
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'])
2014-04-26 11:17:10 +02:00
2019-01-05 13:04:20 +01:00
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))
2014-04-26 11:17:10 +02:00
2019-01-05 13:04:20 +01:00
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
2014-04-26 11:17:10 +02:00
batch = BatchHttpRequest()
2019-01-05 13:04:20 +01:00
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
2014-04-26 11:17:10 +02:00
2019-01-05 13:04:20 +01:00
for eventGoogleID in events_to_delete_google_id:
batch.add(service.events().delete(calendarId='primary', eventId=eventGoogleID))
2019-01-05 13:04:20 +01:00
batch_is_empty = False
2014-04-26 11:17:10 +02:00
2019-01-05 13:04:20 +01:00
for gcal_ev in all_events:
event_django_id = int(gcal_ev['extendedProperties']['private']['blechreizID'])
2014-04-26 11:17:10 +02:00
try:
2019-01-05 13:04:20 +01:00
django_ev = Event.objects.get(pk=event_django_id)
if 'attendees' not in gcal_ev:
gcal_ev['attendees'] = []
2019-01-05 13:04:20 +01:00
if gcal_ev['attendees'] != build_gcal_attendees_obj(django_ev):
batch.add(update_gcal_event(service, django_ev))
batch_is_empty = False
2014-04-26 11:17:10 +02:00
except Event.DoesNotExist:
pass
2019-01-05 13:04:20 +01:00
if not batch_is_empty:
2014-04-26 11:17:10 +02:00
batch.execute()
2019-01-05 13:04:20 +01:00
return len(events_to_create_django_id), len(events_to_delete_google_id)
2014-04-26 11:17:10 +02:00
2019-01-05 13:04:20 +01:00
def sync_from_google_to_local(service=None):
2014-04-26 11:17:10 +02:00
"""Retrieves only participation infos for all events and updates local database if anything has changed. """
if service is None:
2019-01-05 13:04:20 +01:00
service = get_service_object()
2014-04-26 11:17:10 +02:00
2019-01-05 13:04:20 +01:00
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)
2014-04-26 11:17:10 +02:00
for a in e['attendees']:
user = UserGCalCoupling.objects.get(email=a['email']).user
2019-01-05 13:04:20 +01:00
part = EventParticipation.get_or_create(user, local_event)
2014-04-26 11:17:10 +02:00
if 'comment' in a:
part.comment = a['comment']
if a['responseStatus'] == 'needsAction':
2014-06-22 10:33:39 +02:00
part.status = "-"
elif a['responseStatus'] == 'tentative':
2014-04-26 11:17:10 +02:00
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'])
2014-04-26 11:17:10 +02:00
prev = EventParticipation.objects.get(event=part.event, user=part.user)
2014-04-26 11:17:10 +02:00
# 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()
2019-01-05 13:04:20 +01:00
new_status_received = True
2014-06-17 23:42:18 +02:00
2019-01-05 13:04:20 +01:00
return new_status_received
2014-04-26 11:17:10 +02:00
# ------------------------------------- Synchronization ----------------------------------------------------
2019-01-05 13:04:20 +01:00
def check_gcal_subscription(service=None, time_to_live=14 * 24 * 3600, renew_before_expiry=None):
2014-04-26 11:17:10 +02:00
"""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:
2019-01-05 13:04:20 +01:00
service = get_service_object()
2014-04-26 11:17:10 +02:00
2019-01-05 13:04:20 +01:00
if renew_before_expiry is None:
renew_before_expiry = 0.8 * time_to_live
2014-04-26 11:17:10 +02:00
2019-01-05 13:04:20 +01:00
callback_url = settings.GCAL_COUPLING['push_url']
2014-04-26 11:17:10 +02:00
# Test if a channel already exists for this callbackURL
try:
2019-01-05 13:04:20 +01:00
db_channel = GCalPushChannel.objects.get(address=callback_url)
g_channel = db_channel.to_google_channel()
2014-04-26 11:17:10 +02:00
# if expiration time between 0 and two days: stop and create new channel
2019-01-05 13:04:20 +01:00
cur_time = int(time.time() * 1000)
if g_channel.expiration > cur_time:
2014-04-26 11:17:10 +02:00
# not yet expired
2019-01-05 13:04:20 +01:00
if cur_time + renew_before_expiry * 1000 > g_channel.expiration:
# will expire in less than "renewBeforeExpiry"
2019-01-05 13:04:20 +01:00
logger.info("Renewing Google Calendar Subscription: " + callback_url)
GCalPushChannel.stop(service, g_channel)
GCalPushChannel.create_new(callback_url, service, time_to_live)
2014-04-26 11:17:10 +02:00
else:
2019-01-05 13:04:20 +01:00
logger.info("Channel active until %d " % (g_channel.expiration,))
2014-04-26 11:17:10 +02:00
else:
logger.info("Google calendar subscription had expired - getting new subscription")
2014-04-26 11:17:10 +02:00
# to get back in sync again we have to decide which data to take
# so we use the local data as reference
2019-01-05 13:04:20 +01:00
sync_from_local_to_google(service)
GCalPushChannel.create_new(callback_url, service, time_to_live)
2014-04-26 11:17:10 +02:00
except GCalPushChannel.DoesNotExist:
# create new channel and save it in database
2019-01-05 13:04:20 +01:00
logger.info("No CGalCallback Channel exists yet for: " + callback_url)
2014-04-26 11:17:10 +02:00
# to get back in sync again we have to decide which data to take
# so we use the local data as reference
2019-01-05 13:04:20 +01:00
sync_from_local_to_google(service)
GCalPushChannel.create_new(callback_url, service, time_to_live)
2014-04-26 11:17:10 +02:00
2019-01-05 13:04:20 +01:00
def stop_all_gcal_subscriptions(service=None):
2014-04-26 11:17:10 +02:00
"""Stops the channel subscription """
if service is None:
2019-01-05 13:04:20 +01:00
service = get_service_object()
2014-04-26 11:17:10 +02:00
for dbChannel in GCalPushChannel.objects.all():
2019-01-05 13:04:20 +01:00
logger.info("Stopping %s expiry at %d " % (dbChannel.id, dbChannel.expiration))
GCalPushChannel.stop(service, dbChannel.to_google_channel())
2019-01-05 13:04:20 +01:00
def check_if_google_callback_is_valid(token, channel_id, resource_id, service=None):
if service is None:
2019-01-05 13:04:20 +01:00
service = get_service_object()
2019-01-05 13:04:20 +01:00
all_channels = GCalPushChannel.objects.all()
if len(all_channels) == 0:
return False # no known subscriptions -> callback has to be from an old channel
2019-01-05 13:04:20 +01:00
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. ")
2019-01-05 13:04:20 +01:00
stop_all_gcal_subscriptions(service)
check_gcal_subscription()
all_channels = GCalPushChannel.objects.all()
2019-01-05 13:04:20 +01:00
assert (len(all_channels) == 1)
2019-01-05 13:04:20 +01:00
the_channel = all_channels[0]
2019-01-05 13:04:20 +01:00
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."
2019-01-05 13:04:20 +01:00
% (channel_id, resource_id, token, the_channel.id, the_channel.resource_id, the_channel.token))
2019-01-05 13:04:20 +01:00
channel_to_stop = GCalPushChannel(id=channel_id, resource_id=resource_id, token=token)
GCalPushChannel.stop(service, channel_to_stop.to_google_channel())
return False
return True