Refactored google calendar sync
This commit is contained in:
parent
46b4078fac
commit
1aa23d2691
|
@ -1 +1 @@
|
||||||
{"_module": "oauth2client.client", "token_expiry": "2014-04-20T19:09:26Z", "access_token": "ya29.1.AADtN_VE0fTw3cu2E9SR0XoblR3gXjnON1u2Qy__EB7E6HyEpaDky0LA-SmZVyok6A", "token_uri": "https://accounts.google.com/o/oauth2/token", "invalid": false, "token_response": {"access_token": "ya29.1.AADtN_VE0fTw3cu2E9SR0XoblR3gXjnON1u2Qy__EB7E6HyEpaDky0LA-SmZVyok6A", "token_type": "Bearer", "expires_in": 3600}, "client_id": "34462582242-4kpdvvbi27ajt4u22uitqurpve9o8ipj.apps.googleusercontent.com", "id_token": null, "client_secret": "y4t9XBrJdCODPTO5UvtONWWn", "revoke_uri": "https://accounts.google.com/o/oauth2/revoke", "_class": "OAuth2Credentials", "refresh_token": "1/7-6-m_lLAKX8IeD7OuGtkcIiprty_nZUSxhMunSC5b0", "user_agent": null}
|
{"_module": "oauth2client.client", "token_expiry": "2014-04-26T10:33:34Z", "access_token": "ya29.1.AADtN_WzqSiT0Ir0jy8f_InaX_NAUAs98E5YwU_uHYMMCvhfc90boeQSVPQpJQTWHw", "token_uri": "https://accounts.google.com/o/oauth2/token", "invalid": false, "token_response": {"access_token": "ya29.1.AADtN_WzqSiT0Ir0jy8f_InaX_NAUAs98E5YwU_uHYMMCvhfc90boeQSVPQpJQTWHw", "token_type": "Bearer", "expires_in": 3600}, "client_id": "34462582242-4kpdvvbi27ajt4u22uitqurpve9o8ipj.apps.googleusercontent.com", "id_token": null, "client_secret": "y4t9XBrJdCODPTO5UvtONWWn", "revoke_uri": "https://accounts.google.com/o/oauth2/revoke", "_class": "OAuth2Credentials", "refresh_token": "1/7-6-m_lLAKX8IeD7OuGtkcIiprty_nZUSxhMunSC5b0", "user_agent": null}
|
|
@ -11,7 +11,7 @@ DEBUG = True
|
||||||
TEMPLATE_DEBUG = DEBUG
|
TEMPLATE_DEBUG = DEBUG
|
||||||
|
|
||||||
ADMINS = (
|
ADMINS = (
|
||||||
# ('Your Name', 'your_email@example.com'),
|
('Martin Bauer', 'bauer_martin@gmx.de'),
|
||||||
)
|
)
|
||||||
|
|
||||||
MANAGERS = ADMINS
|
MANAGERS = ADMINS
|
||||||
|
@ -203,7 +203,7 @@ LOGGING = {
|
||||||
'file': {
|
'file': {
|
||||||
'level': 'DEBUG',
|
'level': 'DEBUG',
|
||||||
'class': 'logging.FileHandler',
|
'class': 'logging.FileHandler',
|
||||||
'filename': '/srv/test/eventplanner.log',
|
'filename': PROJECT_PATH + '/eventplanner.log',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'loggers': {
|
'loggers': {
|
||||||
|
|
|
@ -133,9 +133,9 @@ class EventParticipation( models.Model ):
|
||||||
return self.user.username
|
return self.user.username
|
||||||
|
|
||||||
def save( self, *args, **kwargs ):
|
def save( self, *args, **kwargs ):
|
||||||
prev = EventParticipation.objects.get( event = self.event, user = self.user )
|
prev = EventParticipation.objects.get( event = self.event, user = self.user )
|
||||||
if prev.status != self.status or prev.comment != self.comment:
|
if prev.status != self.status or prev.comment != self.comment:
|
||||||
super(EventParticipation,self).save( *args,**kwargs)
|
super(EventParticipation,self).save( *args,**kwargs)
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
|
@ -0,0 +1,330 @@
|
||||||
|
import logging
|
||||||
|
import httplib2
|
||||||
|
import datetime
|
||||||
|
import time
|
||||||
|
|
||||||
|
from eventplanner.models import Event, EventParticipation
|
||||||
|
from eventplanner_gcal.models import GCalMapping, GCalPushChannel
|
||||||
|
from apiclient.http import BatchHttpRequest
|
||||||
|
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
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()
|
||||||
|
|
||||||
|
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 u in User.objects.all():
|
||||||
|
if u.email.endswith( "@gmail.com") or u.email.endswith("@googlemail.com"):
|
||||||
|
participation = EventParticipation.get_or_create( u, event )
|
||||||
|
status = "tentative"
|
||||||
|
if participation.status == 'Yes': status = "accepted"
|
||||||
|
if participation.status == 'No' : status = "declined"
|
||||||
|
|
||||||
|
o = {
|
||||||
|
'id': u.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 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()
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------- 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() )
|
|
@ -1,6 +1,6 @@
|
||||||
from django.core.management.base import NoArgsCommand
|
from django.core.management.base import NoArgsCommand
|
||||||
|
|
||||||
from eventplanner_gcal.signals import checkGCalSubscription
|
from eventplanner_gcal.google_sync import checkGCalSubscription
|
||||||
|
|
||||||
class Command(NoArgsCommand):
|
class Command(NoArgsCommand):
|
||||||
help = 'Checks if the GCal notification channel is still active'
|
help = 'Checks if the GCal notification channel is still active'
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
from django.core.management.base import NoArgsCommand
|
from django.core.management.base import NoArgsCommand
|
||||||
from eventplanner_gcal.signals import deleteAllGCalEvents
|
from eventplanner_gcal.google_sync import deleteAllGCalEvents
|
||||||
|
|
||||||
class Command(NoArgsCommand):
|
class Command(NoArgsCommand):
|
||||||
help = 'Delete all events in the google calendar created by this app'
|
help = 'Delete all events in the google calendar created by this app'
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
from django.core.management.base import NoArgsCommand
|
from django.core.management.base import NoArgsCommand
|
||||||
|
|
||||||
from eventplanner_gcal.signals import stopAllGCalSubscriptions
|
from eventplanner_gcal.google_sync import stopAllGCalSubscriptions
|
||||||
|
|
||||||
class Command(NoArgsCommand):
|
class Command(NoArgsCommand):
|
||||||
help = 'Stops all GCal subscriptions'
|
help = 'Stops all GCal subscriptions'
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
from django.core.management.base import NoArgsCommand
|
from django.core.management.base import NoArgsCommand
|
||||||
|
|
||||||
from eventplanner_gcal.signals import syncGCalEvents
|
from eventplanner_gcal.google_sync import syncFromLocalToGoogle
|
||||||
|
|
||||||
class Command(NoArgsCommand):
|
class Command(NoArgsCommand):
|
||||||
help = 'Synchronize Google Calendar with locally stored Events'
|
help = 'Synchronize Google Calendar with locally stored Events'
|
||||||
def handle_noargs(self, **options):
|
def handle_noargs(self, **options):
|
||||||
print ( "Running Sync")
|
print ( "Running Sync")
|
||||||
created, deleted = syncGCalEvents()
|
created, deleted = syncFromLocalToGoogle()
|
||||||
print ( "Created %d and deleted %d events" % (created,deleted) )
|
print ( "Created %d and deleted %d events" % (created,deleted) )
|
||||||
|
|
|
@ -1,32 +1,23 @@
|
||||||
from apiclient.channel import new_webhook_channel, Channel
|
|
||||||
from django.db import models
|
|
||||||
from eventplanner.models import Event, EventParticipation
|
|
||||||
|
|
||||||
from apiclient.discovery import build
|
|
||||||
from apiclient.http import BatchHttpRequest
|
|
||||||
|
|
||||||
from oauth2client.file import Storage
|
|
||||||
import httplib2
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
import logging
|
import logging
|
||||||
import datetime
|
|
||||||
from django.contrib.auth.models import User
|
|
||||||
import time
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from eventplanner.models import Event
|
||||||
|
|
||||||
|
from apiclient.channel import Channel
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class GCalMapping( models.Model ):
|
class GCalMapping( models.Model ):
|
||||||
|
"""Mapping between event id at google and local event id"""
|
||||||
gcal_id = models.CharField( max_length=64 )
|
gcal_id = models.CharField( max_length=64 )
|
||||||
event = models.OneToOneField( Event, primary_key=True )
|
event = models.OneToOneField( Event, primary_key=True )
|
||||||
|
|
||||||
|
|
||||||
class GCalPushChannel( models.Model ):
|
class GCalPushChannel( models.Model ):
|
||||||
"""This table has either zero or one entry. Required to store if a channel already exists,
|
"""This table has either zero or one entry. Required to store if a channel already exists,
|
||||||
when it expires, and how to stop (renew) the channel
|
when it expires and how to stop (renew) the channel
|
||||||
"""
|
"""
|
||||||
|
|
||||||
id = models.CharField( max_length=128, primary_key=True )
|
id = models.CharField( max_length=128, primary_key=True )
|
||||||
|
@ -60,262 +51,3 @@ class GCalPushChannel( models.Model ):
|
||||||
channelService = service.channels()
|
channelService = service.channels()
|
||||||
channelService.stop( body = gChannel.body() ).execute()
|
channelService.stop( body = gChannel.body() ).execute()
|
||||||
GCalPushChannel.fromGChannel( gChannel ).delete()
|
GCalPushChannel.fromGChannel( gChannel ).delete()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def init( gcal_settings ):
|
|
||||||
"""Creates a API service object required by the following synchronization functions"""
|
|
||||||
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 )
|
|
||||||
return build( serviceName='calendar', version='v3',
|
|
||||||
http=http, developerKey=gcal_settings['developerKey'] )
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
service = init( settings.GCAL_COUPLING )
|
|
||||||
|
|
||||||
|
|
||||||
def createAttendeesObj( event ):
|
|
||||||
result = []
|
|
||||||
for u in User.objects.all():
|
|
||||||
if u.email.endswith( "@gmail.com") or u.email.endswith("@googlemail.com"):
|
|
||||||
participation = EventParticipation.get_or_create( u, event )
|
|
||||||
status = "tentative"
|
|
||||||
if participation.status == 'Yes': status = "accepted"
|
|
||||||
if participation.status == 'No' : status = "declined"
|
|
||||||
|
|
||||||
o = {
|
|
||||||
'id': u.email,
|
|
||||||
'email': u.email,
|
|
||||||
'displayName': u.username,
|
|
||||||
'comment': participation.comment,
|
|
||||||
'responseStatus': status,
|
|
||||||
}
|
|
||||||
result.append( o )
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def buildGCalEvent( event, timezone="Europe/Berlin" ):
|
|
||||||
if service is None:
|
|
||||||
logger.error("createEvent: Google API connection not configured")
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
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': createAttendeesObj( event ),
|
|
||||||
}
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def getAllGCalEvents( fromNow=False, pageToken=None ):
|
|
||||||
|
|
||||||
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',
|
|
||||||
pageToken=pageToken,
|
|
||||||
privateExtendedProperty='blechreizEvent=true',
|
|
||||||
).execute()
|
|
||||||
return events['items']
|
|
||||||
|
|
||||||
|
|
||||||
def createGCalEvent( event, timezone="Europe/Berlin" ):
|
|
||||||
googleEvent = buildGCalEvent(event,timezone)
|
|
||||||
return service.events().insert(calendarId='primary', body=googleEvent)
|
|
||||||
|
|
||||||
def updateGCalEvent( event, timezone="Europe/Berlin"):
|
|
||||||
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( event ):
|
|
||||||
mapping = GCalMapping.objects.get( event=event )
|
|
||||||
gcalId = mapping.gcal_id
|
|
||||||
return service.events().delete(calendarId='primary', eventId=gcalId)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def deleteAllGCalEvents():
|
|
||||||
if service is None:
|
|
||||||
logger.error("deleteAllGCalEvents: Google API connection not configured")
|
|
||||||
return
|
|
||||||
|
|
||||||
gcalIds = [ ev['id'] for ev in getAllGCalEvents() ]
|
|
||||||
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 syncGCalEvents():
|
|
||||||
if service is None:
|
|
||||||
logger.error("syncGCalEvents: Google API connection not configured")
|
|
||||||
return
|
|
||||||
|
|
||||||
allEvents = getAllGCalEvents()
|
|
||||||
|
|
||||||
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"""
|
|
||||||
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( 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
|
|
||||||
|
|
||||||
if not batchIsEmpty:
|
|
||||||
batch.execute()
|
|
||||||
|
|
||||||
return len (eventsToCreate_djangoID), len(eventsToDelete_googleID )
|
|
||||||
|
|
||||||
|
|
||||||
def syncParticipationFromGoogleToLocal():
|
|
||||||
allEvents = getAllGCalEvents(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'] )
|
|
||||||
|
|
||||||
part.save()
|
|
||||||
|
|
||||||
|
|
||||||
def checkGCalSubscription():
|
|
||||||
global service
|
|
||||||
callbackUrl = settings.GCAL_COUPLING['push_url']
|
|
||||||
|
|
||||||
timeToLive = 14*24*3600 # how long the channel should be active
|
|
||||||
renewBeforeExpiry = 2*24*3600 # duration before expiry when channel is renewed
|
|
||||||
#timeToLive = 60*5
|
|
||||||
#renewBeforeExpiry = 60*3
|
|
||||||
|
|
||||||
# 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:
|
|
||||||
print( "Google calendar subscription had expired - getting new subscription" )
|
|
||||||
syncParticipationFromGoogleToLocal()
|
|
||||||
GCalPushChannel.createNew( callbackUrl, service, timeToLive )
|
|
||||||
|
|
||||||
except GCalPushChannel.DoesNotExist:
|
|
||||||
# create new channel and save it in database
|
|
||||||
print ( "No CGalCallback Channel exists yet for: " + callbackUrl )
|
|
||||||
syncParticipationFromGoogleToLocal()
|
|
||||||
GCalPushChannel.createNew( callbackUrl, service, timeToLive )
|
|
||||||
|
|
||||||
|
|
||||||
def stopAllGCalSubscriptions():
|
|
||||||
for dbChannel in GCalPushChannel.objects.all():
|
|
||||||
print("Stopping %s expiry at %d " % ( dbChannel.id, dbChannel.expiration ) )
|
|
||||||
GCalPushChannel.stop( service, dbChannel.toGChannel() )
|
|
||||||
|
|
|
@ -2,105 +2,41 @@ from django.db.models.signals import post_save,pre_delete
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from eventplanner.models import Event, EventParticipation
|
from eventplanner.models import Event, EventParticipation
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
import eventplanner_gcal.models
|
from eventplanner_gcal.google_sync import getServiceObject, syncFromLocalToGoogle,\
|
||||||
|
createGCalEvent, deleteGCalEvent, updateGCalEvent
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger( __name__ )
|
logger = logging.getLogger( __name__ )
|
||||||
|
|
||||||
|
|
||||||
class SignalLock:
|
|
||||||
def __init__(self):
|
|
||||||
self.locked=False
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
if self.locked:
|
|
||||||
return False
|
|
||||||
|
|
||||||
self.locked=True
|
|
||||||
return True
|
|
||||||
|
|
||||||
def __exit__(self, type, value, traceback):
|
|
||||||
self.locked=False
|
|
||||||
|
|
||||||
def isLocked(self):
|
|
||||||
return self.locked
|
|
||||||
|
|
||||||
signalLock = SignalLock()
|
|
||||||
|
|
||||||
|
|
||||||
def onGoogleCallback():
|
|
||||||
if not signalLock.isLocked():
|
|
||||||
with signalLock:
|
|
||||||
logger.info( "Sync back from google" )
|
|
||||||
eventplanner_gcal.models.syncParticipationFromGoogleToLocal()
|
|
||||||
|
|
||||||
|
|
||||||
@receiver( post_save, sender=User )
|
@receiver( post_save, sender=User )
|
||||||
def user_changed( **kwargs ):
|
def user_changed( **kwargs ):
|
||||||
logger.info("User info changed")
|
logger.info("Synchronizing with google - user information changed")
|
||||||
if not signalLock.isLocked():
|
syncFromLocalToGoogle( getServiceObject() )
|
||||||
with signalLock:
|
|
||||||
logger.info("Synchronizing with google - user information changed")
|
|
||||||
eventplanner_gcal.models.deleteAllGCalEvents()
|
|
||||||
eventplanner_gcal.models.syncGCalEvents()
|
|
||||||
|
|
||||||
|
|
||||||
@receiver( post_save,sender= Event)
|
@receiver( post_save,sender= Event)
|
||||||
def event_post_save_handler( **kwargs):
|
def event_post_save_handler( **kwargs):
|
||||||
if not signalLock.isLocked():
|
event = kwargs['instance']
|
||||||
with signalLock:
|
created = kwargs['created']
|
||||||
event = kwargs['instance']
|
if created:
|
||||||
created = kwargs['created']
|
logger.info("Creating Gcal event")
|
||||||
if created:
|
createGCalEvent( getServiceObject(), event ).execute()
|
||||||
logger.info("Creating Gcal event")
|
else:
|
||||||
eventplanner_gcal.models.createGCalEvent( event ).execute()
|
logger.info( "Updating Gcal event")
|
||||||
else:
|
updateGCalEvent( getServiceObject(),event ).execute()
|
||||||
logger.info( "Updating Gcal event")
|
|
||||||
eventplanner_gcal.models.updateGCalEvent( event ).execute()
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@receiver( pre_delete,sender= Event)
|
@receiver( pre_delete,sender= Event)
|
||||||
def event_pre_delete_handler( **kwargs):
|
def event_pre_delete_handler( **kwargs):
|
||||||
if not signalLock.isLocked():
|
event = kwargs['instance']
|
||||||
with signalLock:
|
logger.info ("Deleting GCAL event")
|
||||||
event = kwargs['instance']
|
deleteGCalEvent( getServiceObject(), event ).execute()
|
||||||
logger.info ("Deleting GCAL event")
|
|
||||||
eventplanner_gcal.models.deleteGCalEvent( event ).execute()
|
|
||||||
|
|
||||||
|
|
||||||
@receiver( post_save, sender=EventParticipation )
|
@receiver( post_save, sender=EventParticipation )
|
||||||
def participation_post_save_handler( **kwargs):
|
def participation_post_save_handler( **kwargs):
|
||||||
if not signalLock.isLocked():
|
participation = kwargs['instance']
|
||||||
with signalLock:
|
logger.info("Participation post save -> update gcal")
|
||||||
participation = kwargs['instance']
|
updateGCalEvent( getServiceObject(), participation.event ).execute()
|
||||||
logger.info("Participation post save -> update gcal")
|
|
||||||
eventplanner_gcal.models.updateGCalEvent( participation.event ).execute()
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# -------------- For management commands ------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def stopAllGCalSubscriptions():
|
|
||||||
if not signalLock.isLocked():
|
|
||||||
with signalLock:
|
|
||||||
eventplanner_gcal.models.stopAllGCalSubscriptions()
|
|
||||||
|
|
||||||
def checkGCalSubscription():
|
|
||||||
if not signalLock.isLocked():
|
|
||||||
with signalLock:
|
|
||||||
eventplanner_gcal.models.checkGCalSubscription()
|
|
||||||
|
|
||||||
|
|
||||||
def deleteAllGCalEvents():
|
|
||||||
if not signalLock.isLocked():
|
|
||||||
with signalLock:
|
|
||||||
eventplanner_gcal.models.deleteAllGCalEvents()
|
|
||||||
|
|
||||||
def syncGCalEvents():
|
|
||||||
if not signalLock.isLocked():
|
|
||||||
with signalLock:
|
|
||||||
eventplanner_gcal.models.syncGCalEvents()
|
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from eventplanner_gcal.models import syncGCalEvents, syncParticipationFromGoogleToLocal
|
from eventplanner_gcal.google_sync import syncFromGoogleToLocal, syncFromLocalToGoogle
|
||||||
from eventplanner_gcal.signals import onGoogleCallback
|
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
|
||||||
|
@ -10,13 +9,12 @@ logger = logging.getLogger( __name__ )
|
||||||
|
|
||||||
|
|
||||||
def runSync( request ):
|
def runSync( request ):
|
||||||
syncGCalEvents()
|
syncFromLocalToGoogle()
|
||||||
|
|
||||||
return redirect("/")
|
return redirect("/")
|
||||||
|
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
def gcalApiCallback( request ):
|
def gcalApiCallback( request ):
|
||||||
onGoogleCallback()
|
syncFromGoogleToLocal()
|
||||||
logger.info("Received callback from GCal - updating event participations... ")
|
logger.info("Received callback from GCal - updating event participations... ")
|
||||||
return HttpResponse('<h1>Callback successful</h1>')
|
return HttpResponse('<h1>Callback successful</h1>')
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue