11 logger = logging.getLogger('gooswapper')
12 logger.setLevel(logging.INFO)
13 consolelog = logging.StreamHandler()
14 consolelog.setLevel(logging.INFO)
15 logformatter = logging.Formatter('%(name)s - %(levelname)s - %(message)s')
16 consolelog.setFormatter(logformatter)
17 logger.addHandler(consolelog)
18 #We can't use this, because that way all the libraries' logs spam us
19 #logging.basicConfig(level=logging.INFO)
21 #Exchange-related library
22 sys.path.append("/upstreams/exchangelib")
25 #Google calendar-api libraries
27 import apiclient.discovery
29 import oauth2client.file
30 import oauth2client.client
31 import googleapiclient.errors
33 #Not sure what the distribution approach here is...
34 gcal_client_id = '805127902516-ptbbtgpq9o8pjr6r3k6hsm60j589o85u.apps.googleusercontent.com'
35 gcal_client_secret = '8hpdxV3MauorryTDoZ1YK8JO'
37 #scope URL for r/w calendar access
38 scope = 'https://www.googleapis.com/auth/calendar'
39 #flow object, for doing OAuth2.0 stuff
40 flow = oauth2client.client.OAuth2WebServerFlow(gcal_client_id,
45 gcal_authpath=".gooswap_gcal_creds.dat"
47 cachepath=".gooswapcache"
49 exchange_credential = None
51 CachedExEvent=collections.namedtuple('CachedExEvent',
52 ['changekey','gcal_link'])
54 class ex_gcal_link(exchangelib.ExtendedProperty):
55 distinguished_property_set_id = 'PublicStrings'
56 property_name = "google calendar event id"
57 property_type = 'String'
60 exchangelib.CalendarItem.get_field_by_fieldname('gcal_link')
62 exchangelib.CalendarItem.register('gcal_link',ex_gcal_link)
64 #useful if you want to replay an event
65 def drop_from_ex_cache(itemid):
66 with open(cachepath,"rb") as f:
67 cache = pickle.load(f)
69 with open(cachepath,"wb") as f:
72 def get_ex_event_by_itemid(calendar,itemid):
73 return calendar.get(item_id=itemid)
75 def get_ex_event_by_id_and_changekey(acct,itemid,changekey):
76 l=list(acct.fetch([(itemid,changekey)]))
77 return list(acct.fetch([(itemid,changekey)]))[0]
79 def get_gcal_event_by_eventid(gcal_acct,eventId,gcal_id="primary"):
80 return gcal_acct.events().get(calendarId=gcal_id,eventId=eventId).execute()
82 def get_gcal_recur_instance(gcal_acct,gcal_master,start,gcal_id="primary"):
83 if gcal_master is None:
84 logger.warning("Cannot get recurrences from event with null gcal id")
86 ans = gcal_acct.events().instances(calendarId=gcal_id,
88 originalStart=start.isoformat(),
89 showDeleted=True).execute()
90 if len(ans['items']) != 1:
91 logger.error("Searching for recurrance instance returned %d events" % \
94 return ans['items'][0]
96 def get_ex_cred(username="SANGER\mv3",password=None):
98 password = getpass.getpass(prompt="Password for user %s: " % username)
99 return exchangelib.ServiceAccount(username,password)
101 def ex_login(username,emailaddr,ad_cache_path=None):
102 global exchange_credential
104 if exchange_credential is None:
105 exchange_credential = get_ex_cred(username)
106 if ad_cache_path is not None:
108 with open(ad_cache_path,"rb") as f:
109 url,auth_type = pickle.load(f)
111 except FileNotFoundError:
115 ex_ac = exchangelib.Account(emailaddr,
116 credentials = exchange_credential,
117 autodiscover = autodiscover)
118 if ad_cache_path is not None:
119 cache=(ex_ac.protocol.service_endpoint,
120 ex_ac.protocol.auth_type)
121 with open(ad_cache_path,"wb") as f:
124 ex_conf = exchangelib.Configuration(service_endpoint=url,
125 credentials=exchange_credential,
127 ex_ac = exchangelib.Account(emailaddr,
130 access_type=exchangelib.DELEGATE)
134 def get_ex_events(calendar):
136 for event in calendar.all().only('changekey','item_id','gcal_link'):
137 if event.item_id in ans:
138 logger.warning("Event item_id %s was duplicated!" % event.item_id)
139 ans[event.item_id] = CachedExEvent(event.changekey,event.gcal_link)
140 logger.info("%d events found" % len(ans))
143 def ex_event_changes(old,new):
144 olds = set(old.keys())
145 news = set(new.keys())
146 added = list(news-olds)
147 deleted = list(olds-news)
149 #intersection - i.e. common to both sets
150 for event in olds & news:
151 if old[event].changekey != new[event].changekey:
152 changed.append(event)
153 logger.info("%d events updated, %d added, %d deleted" % (len(changed),
156 return added, deleted, changed
158 #exchangelib gives us days in recurrence patterns as integers,
159 #RFC5545 wants SU,MO,TU,WE,TH,FR,SA
160 #it has a utility function to convert to Monday, Tuesday, ...
161 def rr_daystr_from_int(i):
162 return exchangelib.recurrence._weekday_to_str(i).upper()[:2]
164 #for monthly patterns, we want the week (or -1 for last) combined with each
166 def rr_daystr_monthly(p):
167 if p.week_number == 5:
170 wn = str(p.week_number)
171 return ",".join([wn + rr_daystr_from_int(x) for x in p.weekdays])
173 def rrule_from_ex(event,gcal_tz):
174 if event.type != "RecurringMaster":
175 logger.error("Cannot make recurrence from not-recurring event")
177 if event.recurrence is None:
178 logger.error("Empty recurrence structure")
180 if isinstance(event.recurrence.pattern,
181 exchangelib.recurrence.DailyPattern):
182 rr = "RRULE:FREQ=DAILY;INTERVAL=%d" % event.recurrence.pattern.interval
183 elif isinstance(event.recurrence.pattern,
184 exchangelib.recurrence.WeeklyPattern):
185 rr = "RRULE:FREQ=WEEKLY;INTERVAL=%d;BYDAY=%s;WKST=%s" % \
186 (event.recurrence.pattern.interval,
187 ",".join([rr_daystr_from_int(x) for x in event.recurrence.pattern.weekdays]),
188 rr_daystr_from_int(event.recurrence.pattern.first_day_of_week) )
189 elif isinstance(event.recurrence.pattern,
190 exchangelib.recurrence.RelativeMonthlyPattern):
191 rr = "RRULE:FREQ=MONTHLY;INTERVAL=%d;BYDAY=%s" % \
192 (event.recurrence.pattern.interval,
193 rr_daystr_monthly(event.recurrence.pattern))
194 elif isinstance(event.recurrence.pattern,
195 exchangelib.recurrence.AbsoluteMonthlyPattern):
196 rr = "RRULE:FREQ=MONTHLY;INTERVAL=%d;BYMONTHDAY=%d" % \
197 (event.recurrence.pattern.interval,
198 event.recurrence.pattern.day_of_month)
199 elif isinstance(event.recurrence.pattern,
200 exchangelib.recurrence.AbsoluteYearlyPattern):
201 rr = "RRULE:FREQ=YEARLY;BYMONTH=%d;BYMONTHDAY=%d" % \
202 (event.recurrence.pattern.month,
203 event.recurrence.pattern.day_of_month)
204 elif isinstance(event.recurrence.pattern,
205 exchangelib.recurrence.RelativeYearlyPattern):
206 rr = "RRULE:FREQ=YEARLY;BYMONTH=%d;BYDAY=%s" % \
207 (event.recurrence.pattern.month,
208 rr_daystr_monthly(event.recurrence.pattern))
210 logger.error("Recurrence %s not supported" % event.recurrence)
212 if isinstance(event.recurrence.boundary,
213 exchangelib.recurrence.EndDatePattern):
214 rr += ";UNTIL={0:%Y}{0:%m}{0:%d}".format(event.recurrence.boundary.end)
215 elif isinstance(event.recurrence.boundary,
216 exchangelib.recurrence.NoEndPattern):
217 pass #no end date to set
219 logger.error("Recurrence %s not supported" % event.recurrence)
223 def modify_recurring(ex_acct,gcal_acct,gcal_tz,
224 events,master,gcal_id="primary"):
225 if master.modified_occurrences is not None:
226 for mod in master.modified_occurrences:
227 instance = get_gcal_recur_instance(gcal_acct,master.gcal_link,
228 mod.original_start,gcal_id)
229 if instance is None: #give up after first failure
231 mod_event = get_ex_event_by_itemid(ex_acct.calendar,mod.item_id)
232 gevent = build_gcal_event_from_ex(mod_event,gcal_tz)
233 gevent = gcal_acct.events().update(calendarId=gcal_id,
234 eventId=instance.get('id'),
236 sendUpdates="none").execute()
237 mod_event.gcal_link = gevent.get("id")
238 mod_event.save(update_fields=["gcal_link"])
239 if master.deleted_occurrences is not None:
240 for d in master.deleted_occurrences:
241 instance = get_gcal_recur_instance(gcal_acct,master.gcal_link,
243 if instance is None: #give up after any failure
245 if instance["status"] != "cancelled":
246 instance["status"]="cancelled"
247 gcal_acct.events().update(calendarId=gcal_id,
248 eventId=instance.get('id'),
250 sendUpdates="none").execute()
252 def build_gcal_event_from_ex(event,gcal_tz):
254 gevent["summary"]=event.subject
256 gevent["end"]={"date": str(event.end.astimezone(gcal_tz).date())}
257 gevent["start"]={"date": str(event.start.astimezone(gcal_tz).date())}
259 gevent["end"]={"dateTime": event.end.astimezone(gcal_tz).isoformat(),
260 "timeZone": str(gcal_tz)}
261 gevent["start"]={"dateTime": event.start.astimezone(gcal_tz).isoformat(),
262 "timeZone": str(gcal_tz)}
263 if event.text_body is not None and event.text_body.strip() != '':
264 gevent["description"] = event.text_body
265 if event.location is not None:
266 gevent["location"] = event.location
267 gevent["extendedProperties"]={"shared": {"ex_id": event.item_id}}
270 def add_ex_to_gcal(ex_acct,
271 gcal_acct,gcal_tz,events,
275 event = get_ex_event_by_itemid(ex_acct.calendar,ev_id)
276 gevent = build_gcal_event_from_ex(event,gcal_tz)
277 if event.type=="RecurringMaster":
278 rr = rrule_from_ex(event,gcal_tz)
280 gevent["recurrence"] = rr
283 logger.warning("Unable to set recurrence for %s" % event.item_id)
284 continue #don't make the gcal event
285 gevent = gcal_acct.events().insert(calendarId=gcal_id,
286 body=gevent).execute()
287 event.gcal_link = gevent.get("id")
288 event.save(update_fields=["gcal_link"])
289 if event.type=="RecurringMaster" and (event.deleted_occurrences or \
290 event.modified_occurrences):
291 modify_recurring(ex_acct,gcal_acct,gcal_tz,
292 events,event,gcal_id)
293 #changekey is updated by the above
295 events[event.item_id] = events[event.item_id]._replace(changekey=event.changekey,gcal_link=event.gcal_link)
297 def del_ex_to_gcal(ex_acct, gcal_acct, events, deleted, gcal_id="primary"):
298 for ev_id in deleted:
299 if events[ev_id].gcal_link is not None:
300 gevent = get_gcal_event_by_eventid(gcal_acct,
301 events[ev_id].gcal_link,
303 if gevent["status"] != "cancelled":
304 gcal_acct.events().delete(calendarId=gcal_id,
305 eventId=events[ev_id].gcal_link,
306 sendUpdates="none").execute()
308 def update_ex_to_gcal(ex_acct,
312 for ev_id in changed:
313 event = get_ex_event_by_itemid(ex_acct.calendar,ev_id)
314 if event.gcal_link is None:
315 logger.warning("Cannot apply update where event has no gcal link")
317 gevent = build_gcal_event_from_ex(event,gcal_tz)
318 if event.type=="RecurringMaster":
319 rr = rrule_from_ex(event,gcal_tz)
321 gevent["recurrence"] = rr
322 if event.deleted_occurrences or \
323 event.modified_occurrences:
324 modify_recurring(ex_acct,gcal_acct,gcal_tz,
325 events,event,gcal_id)
326 event.refresh() #changekey is updated by the above
327 events[event.item_id] = events[event.item_id]._replace(changekey=event.changekey,gcal_link=event.gcal_link)
329 logger.warning("Unable to set recurrence for %s" % event.item_id)
330 continue #don't make the gcal event
331 try: #may fail if we don't own the event
332 gevent = gcal_acct.events().update(calendarId=gcal_id,
333 eventId=event.gcal_link,
335 sendUpdates="none").execute()
336 except googleapiclient.errors.HttpError as err:
337 if err.resp.status == 403:
340 def match_ex_to_gcal(ex_acct,gcal_acct,gcal_tz,events,gcal_id="primary",ignore_link=True):
346 event = get_ex_event_by_itemid(ex_acct.calendar,ev_id)
347 if event.gcal_link is not None and ignore_link is False:
351 matches = gcal_acct.events().list(calendarId=gcal_id,
352 timeMin=event.start.isoformat(),
353 timeMax=event.end.isoformat()).execute()
354 for ge in matches['items']:
355 if ( ge.get("summary") is None and event.subject is None ) or \
356 ( ge.get("summary") is not None and event.subject is not None \
357 and ge['summary'].strip()==event.subject.strip()):
358 logger.info("Matching '%s' starting at %s" % (event.subject,
359 event.start.isoformat()))
360 event.gcal_link = ge['id']
361 event.save(update_fields=["gcal_link"])
362 events[event.item_id] = events[event.item_id]._replace(changekey=event.changekey,gcal_link=event.gcal_link)
364 gevent["start"] = ge["start"]
365 gevent["end"] = ge["end"]
366 gevent["extendedProperties"]={"shared": {"ex_id": event.item_id}}
368 gcal_acct.events().update(calendarId=gcal_id,
369 eventId=event.gcal_link,
371 sendUpdates="none").execute()
372 #this may fail if we don't own the event
373 except googleapiclient.errors.HttpError as err:
374 if err.resp.status == 403:
381 logger.info("Matched %d events, skipped %d with existing link, and %d recurring ones" % (matched,skipped,recur))
384 def get_gcal_cred(args):
385 #each such file can only store a single credential
386 storage = oauth2client.file.Storage(gcal_authpath)
387 gcal_credential = storage.get()
388 #if no credential found, or they're invalid (e.g. expired),
389 #then get a new one; pass --noauth_local_webserver on the command line
390 #if you don't want it to spawn a browser
391 if gcal_credential is None or gcal_credential.invalid:
392 gcal_credential = oauth2client.tools.run_flow(flow,
395 return gcal_credential
397 def gcal_login(args):
398 gcal_credential = get_gcal_cred(args)
399 # Object to handle http requests; could add proxy details
400 http = httplib2.Http()
401 http = gcal_credential.authorize(http)
402 return apiclient.discovery.build('calendar', 'v3', http=http)
404 def get_gcal_timezone(gcal_account,calendarid="primary"):
405 gcal = gcal_account.calendars().get(calendarId=calendarid).execute()
406 return exchangelib.EWSTimeZone.timezone(gcal['timeZone'])
409 ap=argparse.ArgumentParser(description="Gooswapper calendar sync",
410 parents=[oauth2client.tools.argparser])
411 ap.add_argument("exchuser",help="Exchange user e.g. 'SANGER\mv3'")
412 ap.add_argument("exchemail",
413 help="Exchange calendar email e.g. ISGGroup@sanger.ac.uk")
414 ap.add_argument("-g","--gcalid",help="google Calendar ID")
415 ap.add_argument("-l","--loop",help="keep running indefinitely",
417 args = ap.parse_args()
418 if args.gcalid is None:
421 gcal_id = args.gcalid
423 #log in to the accounts
424 ex_account = ex_login(args.exchuser,args.exchemail,
425 ".gooswapper_exch_conf.dat")
426 gcal_account = gcal_login(args)
427 gcal_tz = get_gcal_timezone(gcal_account,gcal_id)
429 #Main loop (broken at the end if login is false)
432 with open(cachepath,"rb") as f:
433 cache = pickle.load(f)
434 except FileNotFoundError:
437 current = get_ex_events(ex_account.calendar)
439 if cache is not None:
440 added,deleted,changed = ex_event_changes(cache,current)
441 add_ex_to_gcal(ex_account,gcal_account,gcal_tz,current,
443 #delete op needs the "cache" set, as that has the link ids in
444 #for events that are now deleted
445 del_ex_to_gcal(ex_account,gcal_account,cache,deleted,gcal_id)
446 update_ex_to_gcal(ex_account,gcal_account,gcal_tz,current,
449 toadd = match_ex_to_gcal(ex_account,gcal_account,gcal_tz,current,
451 add_ex_to_gcal(ex_account,gcal_account,gcal_tz,current,
454 with open(cachepath,"wb") as f:
455 pickle.dump(current,f)
457 #If not looping, break here (after 1 run)
460 #otherwise, wait 10 minutes, then go round again
463 if __name__ == "__main__":