408 lines
15 KiB
Python
408 lines
15 KiB
Python
import logging
|
|
import httplib2
|
|
import datetime
|
|
import time
|
|
|
|
from eventplanner.models import Event, EventParticipation
|
|
from eventplanner_gcal.models import GCalMapping, GCalPushChannel, UserGCalCoupling
|
|
from apiclient.http import BatchHttpRequest
|
|
|
|
from django.conf import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# --------------------- Authentication using oauth2 --------------------------------------------
|
|
|
|
def createGCalServiceObject():
|
|
"""Creates a Google API service object. This object is required whenever a Google API call is made"""
|
|
from oauth2client.file import Storage
|
|
from apiclient.discovery import build
|
|
|
|
gcal_settings = settings.GCAL_COUPLING
|
|
|
|
storage = Storage(gcal_settings['credentials_file'])
|
|
credentials = storage.get()
|
|
|
|
print("credentials", credentials)
|
|
if credentials is None or credentials.invalid == True:
|
|
# flow = client.flow_from_clientsecrets(CLIENT_SEICRET_FILE, SCOPES)
|
|
|
|
print("invalid credentials for gcal")
|
|
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'])
|
|
|
|
print("res", res)
|
|
if res is None:
|
|
logger.error("Authentication at google API failed. Check your settings!")
|
|
return res
|
|
|
|
|
|
def getServiceObject():
|
|
if getServiceObject.__serviceObject is None:
|
|
getServiceObject.__serviceObject = createGCalServiceObject()
|
|
|
|
return getServiceObject.__serviceObject
|
|
|
|
|
|
getServiceObject.__serviceObject = None
|
|
|
|
|
|
# --------------------- Building GCal event representation ------------------------------------
|
|
|
|
|
|
def buildGCalAttendeesObj(event):
|
|
"""Builds a 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
|
|
|
|
# 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)
|
|
localStatus = participation.status
|
|
localComment = participation.comment
|
|
except EventParticipation.DoesNotExist:
|
|
localStatus = "-"
|
|
localComment = ""
|
|
|
|
status = "needsAction"
|
|
if localStatus == "?": status = "tentative"
|
|
if localStatus == 'Yes': status = "accepted"
|
|
if localStatus == 'No': status = "declined"
|
|
|
|
o = {
|
|
'id': userMapping.email,
|
|
'email': userMapping.email,
|
|
'displayName': u.username,
|
|
'comment': localComment,
|
|
'responseStatus': status,
|
|
}
|
|
result.append(o)
|
|
|
|
return result
|
|
|
|
|
|
def buildGCalEvent(event, timezone="Europe/Berlin"):
|
|
""" Builds a GCal event using a local event. """
|
|
|
|
def createDateTimeObj(date, time):
|
|
if time is None:
|
|
return {'date': unicode(date), 'timeZone': timezone}
|
|
else:
|
|
return {'dateTime': unicode(date) + 'T' + unicode(time), 'timeZone': timezone}
|
|
|
|
startDate = event.date
|
|
endDate = event.end_date
|
|
if endDate is None: endDate = startDate
|
|
|
|
startTime = event.meeting_time
|
|
if startTime is None: startTime = event.time
|
|
|
|
if startTime is None:
|
|
endTime = None
|
|
else:
|
|
endTime = datetime.time(22, 30)
|
|
|
|
gLocation = unicode(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(",")
|
|
gLocation = unicode("%s,%s" % (s[0], s[1]))
|
|
|
|
return {
|
|
'summary': unicode(settings.GCAL_COUPLING['eventPrefix'] + event.title),
|
|
'description': unicode(event.desc),
|
|
'location': gLocation,
|
|
'start': createDateTimeObj(startDate, startTime),
|
|
'end': createDateTimeObj(endDate, endTime),
|
|
'extendedProperties': {
|
|
'private': {
|
|
'blechreizEvent': 'true',
|
|
'blechreizID': event.id,
|
|
}
|
|
},
|
|
'attendees': buildGCalAttendeesObj(event),
|
|
}
|
|
|
|
|
|
# ------------------------------ Callback Functions ------------------------------------------------
|
|
|
|
def onGcalEventCreated(request_id, response, exception=None):
|
|
"""Callback function for created events to enter new gcal id in the mapping table"""
|
|
if exception is not None:
|
|
print("response " + str(response))
|
|
raise exception
|
|
|
|
googleId = response['id']
|
|
djangoId = response['extendedProperties']['private']['blechreizID']
|
|
mapping = GCalMapping(gcal_id=googleId, event=Event.objects.get(pk=djangoId))
|
|
mapping.save()
|
|
|
|
|
|
# ------------------------------ GCal Api Calls -------------------------------------------------
|
|
|
|
def getAllGCalEvents(service, fromNow=False):
|
|
"""Retrieves all gcal events with custom property blechreizEvent=True i.e. all
|
|
events that have been created by this script."""
|
|
|
|
if fromNow:
|
|
now = datetime.datetime.now()
|
|
minTime = now.strftime("%Y-%m-%dT%H:%M:%S-00:00")
|
|
else:
|
|
minTime = '2000-01-01T00:00:00-00:00'
|
|
|
|
events = service.events().list(
|
|
calendarId='primary',
|
|
singleEvents=True,
|
|
maxResults=1000,
|
|
orderBy='startTime',
|
|
timeMin=minTime,
|
|
timeMax='2100-01-01T00:00:00-00:00',
|
|
privateExtendedProperty='blechreizEvent=true',
|
|
).execute()
|
|
return events['items']
|
|
|
|
|
|
def createGCalEvent(service, event, timezone="Europe/Berlin"):
|
|
"""Creates a new gcal event using a local event"""
|
|
googleEvent = buildGCalEvent(event, timezone)
|
|
return service.events().insert(calendarId='primary', body=googleEvent)
|
|
|
|
|
|
def updateGCalEvent(service, event, timezone="Europe/Berlin"):
|
|
"""Updates an existing gcal event, using a local event"""
|
|
googleEvent = buildGCalEvent(event, timezone)
|
|
try:
|
|
mapping = GCalMapping.objects.get(event=event)
|
|
except GCalMapping.DoesNotExist:
|
|
return createGCalEvent(service, event, timezone)
|
|
|
|
return service.events().patch(calendarId='primary', eventId=mapping.gcal_id, body=googleEvent)
|
|
|
|
|
|
def deleteGCalEvent(service, event):
|
|
"""Deletes gcal that belongs to the given local event"""
|
|
mapping = GCalMapping.objects.get(event=event)
|
|
gcalId = mapping.gcal_id
|
|
mapping.delete()
|
|
return service.events().delete(calendarId='primary', eventId=gcalId)
|
|
|
|
|
|
# ------------------------------------- Synchronization ----------------------------------------------------
|
|
|
|
def deleteAllGCalEvents(service=None):
|
|
"""Deletes all gcal events that have been created by this script"""
|
|
|
|
if service is None:
|
|
service = getServiceObject()
|
|
|
|
gcalIds = [ev['id'] for ev in getAllGCalEvents(service)]
|
|
l = len(gcalIds)
|
|
if l == 0:
|
|
return l
|
|
|
|
batch = BatchHttpRequest()
|
|
for id in gcalIds:
|
|
batch.add(service.events().delete(calendarId='primary', eventId=id))
|
|
batch.execute()
|
|
|
|
GCalMapping.objects.all().delete()
|
|
|
|
return l
|
|
|
|
|
|
def syncFromLocalToGoogle(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 = getServiceObject()
|
|
|
|
allEvents = getAllGCalEvents(service)
|
|
|
|
eventsAtGoogle_djangoID = set()
|
|
eventsAtGoogle_googleID = set()
|
|
for gcalEv in allEvents:
|
|
eventsAtGoogle_djangoID.add(int(gcalEv['extendedProperties']['private']['blechreizID']))
|
|
eventsAtGoogle_googleID.add(gcalEv['id'])
|
|
|
|
localEvents_djangoID = set(Event.objects.all().values_list('pk', flat=True))
|
|
localEvents_googleID = set(GCalMapping.objects.all().values_list('gcal_id', flat=True))
|
|
|
|
eventsToCreate_djangoID = localEvents_djangoID - eventsAtGoogle_djangoID
|
|
eventsToDelete_googleID = eventsAtGoogle_googleID - localEvents_googleID
|
|
|
|
batch = BatchHttpRequest()
|
|
|
|
batchIsEmpty = True
|
|
for eventDjangoID in eventsToCreate_djangoID:
|
|
batch.add(createGCalEvent(service, Event.objects.get(pk=eventDjangoID)), callback=onGcalEventCreated)
|
|
batchIsEmpty = False
|
|
|
|
for eventGoogleID in eventsToDelete_googleID:
|
|
batch.add(service.events().delete(calendarId='primary', eventId=eventGoogleID))
|
|
batchIsEmpty = False
|
|
|
|
for gcalEv in allEvents:
|
|
eventDjangoID = int(gcalEv['extendedProperties']['private']['blechreizID'])
|
|
try:
|
|
djangoEv = Event.objects.get(pk=eventDjangoID)
|
|
if 'attendees' not in gcalEv:
|
|
gcalEv['attendees'] = []
|
|
|
|
if gcalEv['attendees'] != buildGCalAttendeesObj(djangoEv):
|
|
batch.add(updateGCalEvent(service, djangoEv))
|
|
batchIsEmpty = False
|
|
except Event.DoesNotExist:
|
|
pass
|
|
|
|
if not batchIsEmpty:
|
|
batch.execute()
|
|
|
|
return len(eventsToCreate_djangoID), len(eventsToDelete_googleID)
|
|
|
|
|
|
def syncFromGoogleToLocal(service=None):
|
|
"""Retrieves only participation infos for all events and updates local database if anything has changed. """
|
|
|
|
if service is None:
|
|
service = getServiceObject()
|
|
|
|
newStatusReceived = False
|
|
allEvents = getAllGCalEvents(service, fromNow=True)
|
|
for e in allEvents:
|
|
localId = e['extendedProperties']['private']['blechreizID']
|
|
localEvent = Event.objects.get(pk=localId)
|
|
for a in e['attendees']:
|
|
user = UserGCalCoupling.objects.get(email=a['email']).user
|
|
part = EventParticipation.get_or_create(user, localEvent)
|
|
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()
|
|
newStatusReceived = True
|
|
|
|
return newStatusReceived
|
|
|
|
|
|
# ------------------------------------- Synchronization ----------------------------------------------------
|
|
|
|
|
|
def checkGCalSubscription(service=None, timeToLive=14 * 24 * 3600, renewBeforeExpiry=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 = getServiceObject()
|
|
|
|
if renewBeforeExpiry is None:
|
|
renewBeforeExpiry = 0.8 * timeToLive
|
|
|
|
callbackUrl = settings.GCAL_COUPLING['push_url']
|
|
|
|
# Test if a channel already exists for this callbackURL
|
|
try:
|
|
dbChannel = GCalPushChannel.objects.get(address=callbackUrl)
|
|
gChannel = dbChannel.toGChannel()
|
|
|
|
# if expiration time between 0 and two days: stop and create new channel
|
|
curTime = int(time.time() * 1000)
|
|
if gChannel.expiration > curTime:
|
|
# not yet expired
|
|
if curTime + renewBeforeExpiry * 1000 > gChannel.expiration:
|
|
# will expire in less than "renewBeforeExpiry"
|
|
print("Renewing Google Calendar Subscription: " + callbackUrl)
|
|
GCalPushChannel.stop(service, gChannel)
|
|
GCalPushChannel.createNew(callbackUrl, service, timeToLive)
|
|
else:
|
|
print("Channel active until %d " % (gChannel.expiration,))
|
|
else:
|
|
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
|
|
syncFromLocalToGoogle(service)
|
|
GCalPushChannel.createNew(callbackUrl, service, timeToLive)
|
|
|
|
except GCalPushChannel.DoesNotExist:
|
|
# create new channel and save it in database
|
|
logger.info("No CGalCallback Channel exists yet for: " + callbackUrl)
|
|
# to get back in sync again we have to decide which data to take
|
|
# so we use the local data as reference
|
|
syncFromLocalToGoogle(service)
|
|
GCalPushChannel.createNew(callbackUrl, service, timeToLive)
|
|
|
|
|
|
def stopAllGCalSubscriptions(service=None):
|
|
"""Stops the channel subscription """
|
|
|
|
if service is None:
|
|
service = getServiceObject()
|
|
|
|
for dbChannel in GCalPushChannel.objects.all():
|
|
print("Stopping %s expiry at %d " % (dbChannel.id, dbChannel.expiration))
|
|
GCalPushChannel.stop(service, dbChannel.toGChannel())
|
|
|
|
|
|
def checkIfGoogleCallbackIsValid(token, channelID, resourceID, service=None):
|
|
if service is None:
|
|
service = getServiceObject()
|
|
|
|
allChannels = GCalPushChannel.objects.all()
|
|
if len(allChannels) == 0:
|
|
return False # no known subscriptions -> callback has to be from an old channel
|
|
|
|
if len(allChannels) > 1:
|
|
logger.warning("Multiple GCal subscriptions! This is strange and probably an error. "
|
|
"All channels are closed and one new is created. ")
|
|
stopAllGCalSubscriptions(service)
|
|
checkGCalSubscription()
|
|
allChannels = GCalPushChannel.objects.all()
|
|
|
|
assert (len(allChannels) == 1)
|
|
|
|
theChannel = allChannels[0]
|
|
|
|
if channelID != theChannel.id or resourceID != theChannel.resource_id or token != theChannel.token:
|
|
logger.warning("Got GCal Response from an unexpected Channel"
|
|
"Got (%s,%s,%s) "
|
|
"expected (%s,%s,%s) "
|
|
"Old Channel is stopped."
|
|
% (channelID, resourceID, token, theChannel.id, theChannel.resource_id, theChannel.token))
|
|
|
|
channelToStop = GCalPushChannel(id=channelID, resource_id=resourceID, token=token)
|
|
GCalPushChannel.stop(service, channelToStop.toGChannel())
|
|
|
|
return False
|
|
|
|
return True
|