395 lines
14 KiB
Python
395 lines
14 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.contrib.auth.models import User
|
|
from django.conf import settings
|
|
from pprint import pprint
|
|
|
|
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()
|
|
|
|
if credentials is None or credentials.invalid == True:
|
|
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'] )
|
|
|
|
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 )
|
|
|
|
return {
|
|
'summary': unicode(settings.GCAL_COUPLING['eventPrefix'] + event.title),
|
|
'description': unicode(event.desc),
|
|
'location': unicode(event.location),
|
|
'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 = getServiceObject() ):
|
|
"""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
|