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 participation = EventParticipation.get_or_create( u, event ) status = "tentative" if participation.status == 'Yes': status = "accepted" if participation.status == 'No' : status = "declined" o = { 'id': userMapping.email, 'email': u.email, 'displayName': u.username, 'comment': participation.comment, '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 ), } # ------------------------------ 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) mapping = GCalMapping.objects.get( event=event ) gcalId = mapping.gcal_id return service.events().patch(calendarId='primary', eventId= gcalId, 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 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() 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 def onGcalEventCreated( request_id, response, exception ): """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() 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 = User.objects.get( email= a['email'] ) part = EventParticipation.get_or_create( user, localEvent ) if 'comment' in a: part.comment = a['comment'] if a['responseStatus'] == 'needsAction' or 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