9 logger = logging.getLogger('gooswapper')
10 logger.setLevel(logging.INFO)
11 consolelog = logging.StreamHandler()
12 consolelog.setLevel(logging.INFO)
13 logformatter = logging.Formatter('%(name)s - %(levelname)s - %(message)s')
14 consolelog.setFormatter(logformatter)
15 logger.addHandler(consolelog)
16 #We can't use this, because that way all the libraries' logs spam us
17 #logging.basicConfig(level=logging.INFO)
19 #Exchange-related library
20 sys.path.append("/upstreams/exchangelib")
23 #Google calendar-api libraries
25 import apiclient.discovery
27 import oauth2client.file
28 import oauth2client.client
29 import googleapiclient.errors
31 #Not sure what the distribution approach here is...
32 gcal_client_id = '805127902516-ptbbtgpq9o8pjr6r3k6hsm60j589o85u.apps.googleusercontent.com'
33 gcal_client_secret = '8hpdxV3MauorryTDoZ1YK8JO'
35 #scope URL for r/w calendar access
36 scope = 'https://www.googleapis.com/auth/calendar'
37 #flow object, for doing OAuth2.0 stuff
38 flow = oauth2client.client.OAuth2WebServerFlow(gcal_client_id,
43 gcal_authpath=".gooswap_gcal_creds.dat"
45 cachepath=".gooswapcache"
47 exchange_credential = None
49 CachedExEvent=collections.namedtuple('CachedExEvent',
50 ['changekey','gcal_link'])
52 class ex_gcal_link(exchangelib.ExtendedProperty):
53 distinguished_property_set_id = 'PublicStrings'
54 property_name = "google calendar event id"
55 property_type = 'String'
58 exchangelib.CalendarItem.get_field_by_fieldname('gcal_link')
60 exchangelib.CalendarItem.register('gcal_link',ex_gcal_link)
62 #useful if you want to replay an event
63 def drop_from_ex_cache(itemid):
64 with open(cachepath,"rb") as f:
65 cache = pickle.load(f)
67 with open(cachepath,"wb") as f:
70 def get_ex_event_by_itemid(calendar,itemid):
71 return calendar.get(item_id=itemid)
73 def get_ex_event_by_id_and_changekey(acct,itemid,changekey):
74 l=list(acct.fetch([(itemid,changekey)]))
75 return list(acct.fetch([(itemid,changekey)]))[0]
77 def get_gcal_event_by_eventid(gcal_acct,eventId,gcal_id="primary"):
78 return gcal_acct.events().get(calendarId=gcal_id,eventId=eventId).execute()
80 def get_gcal_recur_instance(gcal_acct,gcal_master,start,gcal_id="primary"):
81 ans = gcal_acct.events().instances(calendarId=gcal_id,
83 originalStart=start.isoformat(),
84 showDeleted=True).execute()
85 if len(ans['items']) != 1:
86 logger.error("Searching for recurrance instance returned %d events" % \
89 return ans['items'][0]
91 def get_ex_cred(username="SANGER\mv3",password=None):
93 password = getpass.getpass(prompt="Password for user %s: " % username)
94 return exchangelib.ServiceAccount(username,password)
96 def ex_login(emailaddr,ad_cache_path=None):
97 global exchange_credential
99 if exchange_credential is None:
100 exchange_credential = get_ex_cred()
101 if ad_cache_path is not None:
103 with open(ad_cache_path,"rb") as f:
104 url,auth_type = pickle.load(f)
106 except FileNotFoundError:
110 ex_ac = exchangelib.Account(emailaddr,
111 credentials = exchange_credential,
112 autodiscover = autodiscover)
113 if ad_cache_path is not None:
114 cache=(ex_ac.protocol.service_endpoint,
115 ex_ac.protocol.auth_type)
116 with open(ad_cache_path,"wb") as f:
119 ex_conf = exchangelib.Configuration(service_endpoint=url,
120 credentials=exchange_credential,
122 ex_ac = exchangelib.Account(emailaddr,
125 access_type=exchangelib.DELEGATE)
129 def get_ex_events(calendar):
131 for event in calendar.all().only('changekey','item_id','gcal_link'):
132 if event.item_id in ans:
133 logger.warning("Event item_id %s was duplicated!" % event.item_id)
134 ans[event.item_id] = CachedExEvent(event.changekey,event.gcal_link)
135 logger.info("%d events found" % len(ans))
138 def ex_event_changes(old,new):
139 olds = set(old.keys())
140 news = set(new.keys())
141 added = list(news-olds)
142 deleted = list(olds-news)
144 #intersection - i.e. common to both sets
145 for event in olds & news:
146 if old[event].changekey != new[event].changekey:
147 changed.append(event)
148 logger.info("%d events updated, %d added, %d deleted" % (len(changed),
151 return added, deleted, changed
153 #exchangelib gives us days in recurrence patterns as integers,
154 #RFC5545 wants SU,MO,TU,WE,TH,FR,SA
155 #it has a utility function to convert to Monday, Tuesday, ...
156 def rr_daystr_from_int(i):
157 return exchangelib.recurrence._weekday_to_str(i).upper()[:2]
159 #for monthly patterns, we want the week (or -1 for last) combined with each
161 def rr_daystr_monthly(p):
162 if p.week_number == 5:
165 wn = str(p.week_number)
166 return ",".join([wn + rr_daystr_from_int(x) for x in p.weekdays])
168 def rrule_from_ex(event,gcal_tz):
169 if event.type != "RecurringMaster":
170 logger.error("Cannot make recurrence from not-recurring event")
172 if event.recurrence is None:
173 logger.error("Empty recurrence structure")
175 if isinstance(event.recurrence.pattern,
176 exchangelib.recurrence.DailyPattern):
177 rr = "RRULE:FREQ=DAILY;INTERVAL=%d" % event.recurrence.pattern.interval
178 elif isinstance(event.recurrence.pattern,
179 exchangelib.recurrence.WeeklyPattern):
180 rr = "RRULE:FREQ=WEEKLY;INTERVAL=%d;BYDAY=%s;WKST=%s" % \
181 (event.recurrence.pattern.interval,
182 ",".join([rr_daystr_from_int(x) for x in event.recurrence.pattern.weekdays]),
183 rr_daystr_from_int(event.recurrence.pattern.first_day_of_week) )
184 elif isinstance(event.recurrence.pattern,
185 exchangelib.recurrence.RelativeMonthlyPattern):
186 rr = "RRULE:FREQ=MONTHLY;INTERVAL=%d;BYDAY=%s" % \
187 (event.recurrence.pattern.interval,
188 rr_daystr_monthly(event.recurrence.pattern))
189 elif isinstance(event.recurrence.pattern,
190 exchangelib.recurrence.AbsoluteMonthlyPattern):
191 rr = "RRULE:FREQ=MONTHLY;INTERVAL=%d;BYMONTHDAY=%d" % \
192 (event.recurrence.pattern.interval,
193 event.recurrence.pattern.day_of_month)
194 elif isinstance(event.recurrence.pattern,
195 exchangelib.recurrence.AbsoluteYearlyPattern):
196 rr = "RRULE:FREQ=YEARLY;BYMONTH=%d;BYMONTHDAY=%d" % \
197 (event.recurrence.pattern.month,
198 event.recurrence.pattern.day_of_month)
199 elif isinstance(event.recurrence.pattern,
200 exchangelib.recurrence.RelativeYearlyPattern):
201 rr = "RRULE:FREQ=YEARLY;BYMONTH=%d;BYDAY=%s" % \
202 (event.recurrence.pattern.month,
203 rr_daystr_monthly(event.recurrence.pattern))
205 logger.error("Recurrence %s not supported" % event.recurrence)
207 if isinstance(event.recurrence.boundary,
208 exchangelib.recurrence.EndDatePattern):
209 rr += ";UNTIL={0:%Y}{0:%m}{0:%d}".format(event.recurrence.boundary.end)
210 elif isinstance(event.recurrence.boundary,
211 exchangelib.recurrence.NoEndPattern):
212 pass #no end date to set
214 logger.error("Recurrence %s not supported" % event.recurrence)
218 def modify_recurring(ex_acct,gcal_acct,gcal_tz,
219 events,master,gcal_id="primary"):
220 if master.modified_occurrences is not None:
221 for mod in master.modified_occurrences:
222 instance = get_gcal_recur_instance(gcal_acct,master.gcal_link,
223 mod.original_start,gcal_id)
224 if instance is None: #give up after first failure
226 mod_event = get_ex_event_by_itemid(ex_acct.calendar,mod.item_id)
227 gevent = build_gcal_event_from_ex(mod_event,gcal_tz)
228 gevent = gcal_acct.events().update(calendarId=gcal_id,
229 eventId=instance.get('id'),
231 sendUpdates="none").execute()
232 mod_event.gcal_link = gevent.get("id")
233 mod_event.save(update_fields=["gcal_link"])
234 if master.deleted_occurrences is not None:
235 for d in master.deleted_occurrences:
236 instance = get_gcal_recur_instance(gcal_acct,master.gcal_link,
238 if instance is None: #give up after any failure
240 if instance["status"] != "cancelled":
241 instance["status"]="cancelled"
242 gcal_acct.events().update(calendarId=gcal_id,
243 eventId=instance.get('id'),
245 sendUpdates="none").execute()
247 def build_gcal_event_from_ex(event,gcal_tz):
249 gevent["summary"]=event.subject
251 gevent["end"]={"date": str(event.end.astimezone(gcal_tz).date())}
252 gevent["start"]={"date": str(event.start.astimezone(gcal_tz).date())}
254 gevent["end"]={"dateTime": event.end.astimezone(gcal_tz).isoformat(),
255 "timeZone": str(gcal_tz)}
256 gevent["start"]={"dateTime": event.start.astimezone(gcal_tz).isoformat(),
257 "timeZone": str(gcal_tz)}
258 if event.text_body is not None and event.text_body.strip() != '':
259 gevent["description"] = event.text_body
260 if event.location is not None:
261 gevent["location"] = event.location
262 gevent["extendedProperties"]={"shared": {"ex_id": event.item_id}}
265 def add_ex_to_gcal(ex_acct,
266 gcal_acct,gcal_tz,events,
270 event = get_ex_event_by_itemid(ex_acct.calendar,ev_id)
271 gevent = build_gcal_event_from_ex(event,gcal_tz)
272 if event.type=="RecurringMaster":
273 rr = rrule_from_ex(event,gcal_tz)
275 gevent["recurrence"] = rr
278 logger.warning("Unable to set recurrence for %s" % event.item_id)
279 continue #don't make the gcal event
280 gevent = gcal_acct.events().insert(calendarId=gcal_id,
281 body=gevent).execute()
282 event.gcal_link = gevent.get("id")
283 event.save(update_fields=["gcal_link"])
284 if event.type=="RecurringMaster" and (event.deleted_occurrences or \
285 event.modified_occurrences):
286 modify_recurring(ex_acct,gcal_acct,gcal_tz,
287 events,event,gcal_id)
288 #changekey is updated by the above
290 events[event.item_id] = events[event.item_id]._replace(changekey=event.changekey,gcal_link=event.gcal_link)
292 def del_ex_to_gcal(ex_acct, gcal_acct, events, deleted, gcal_id="primary"):
293 for ev_id in deleted:
294 if events[ev_id].gcal_link is not None:
295 gevent = get_gcal_event_by_eventid(gcal_acct,
296 events[ev_id].gcal_link,
298 if gevent["status"] != "cancelled":
299 gcal_acct.events().delete(calendarId=gcal_id,
300 eventId=events[ev_id].gcal_link,
301 sendUpdates="none").execute()
303 def update_ex_to_gcal(ex_acct,
307 for ev_id in changed:
308 event = get_ex_event_by_itemid(ex_acct.calendar,ev_id)
309 gevent = build_gcal_event_from_ex(event,gcal_tz)
310 if event.type=="RecurringMaster":
311 rr = rrule_from_ex(event,gcal_tz)
313 gevent["recurrence"] = rr
314 if event.deleted_occurrences or \
315 event.modified_occurrences:
316 modify_recurring(ex_acct,gcal_acct,gcal_tz,
317 events,event,gcal_id)
318 event.refresh() #changekey is updated by the above
319 events[event.item_id] = events[event.item_id]._replace(changekey=event.changekey,gcal_link=event.gcal_link)
321 logger.warning("Unable to set recurrence for %s" % event.item_id)
322 continue #don't make the gcal event
323 gevent = gcal_acct.events().update(calendarId=gcal_id,
324 eventId=event.gcal_link,
326 sendUpdates="none").execute()
328 def match_ex_to_gcal(ex_acct,gcal_acct,gcal_tz,events,gcal_id="primary"):
333 event = get_ex_event_by_itemid(ex_acct.calendar,ev_id)
334 if event.is_recurring:
337 elif event.gcal_link is not None:
340 matches = gcal_acct.events().list(calendarId=gcal_id,
341 timeMin=event.start.isoformat(),
342 timeMax=event.end.isoformat()).execute()
343 for ge in matches['items']:
344 if ge['summary'].strip()==event.subject.strip():
345 logger.info("Matching '%s' starting at %s" % (event.subject,
346 event.start.isoformat()))
347 event.gcal_link = ge['id']
348 event.save(update_fields=["gcal_link"])
349 events[event.item_id] = events[event.item_id]._replace(changekey=event.changekey,gcal_link=event.gcal_link)
351 gevent["start"] = ge["start"]
352 gevent["end"] = ge["end"]
353 gevent["extendedProperties"]={"shared": {"ex_id": event.item_id}}
355 gcal_acct.events().update(calendarId=gcal_id,
356 eventId=event.gcal_link,
358 sendUpdates="none").execute()
359 #this may fail if we don't own the event
360 except googleapiclient.errors.HttpError as err:
361 if err.resp.status == 403:
365 logger.info("Matched %d events, skipped %d with existing link, and %d recurring ones" % (matched,skipped,recur))
368 #each such file can only store a single credential
369 storage = oauth2client.file.Storage(gcal_authpath)
370 gcal_credential = storage.get()
371 #if no credential found, or they're invalid (e.g. expired),
372 #then get a new one; pass --noauth_local_webserver on the command line
373 #if you don't want it to spawn a browser
374 if gcal_credential is None or gcal_credential.invalid:
375 gcal_credential = oauth2client.tools.run_flow(flow,
377 oauth2client.tools.argparser.parse_args())
378 return gcal_credential
381 gcal_credential = get_gcal_cred()
382 # Object to handle http requests; could add proxy details
383 http = httplib2.Http()
384 http = gcal_credential.authorize(http)
385 return apiclient.discovery.build('calendar', 'v3', http=http)
387 def get_gcal_timezone(gcal_account,calendarid="primary"):
388 gcal = gcal_account.calendars().get(calendarId=calendarid).execute()
389 return exchangelib.EWSTimeZone.timezone(gcal['timeZone'])
393 with open(cachepath,"rb") as f:
394 cache = pickle.load(f)
395 except FileNotFoundError:
398 ex_account = ex_login("mv3@sanger.ac.uk",".gooswapper_exch_conf.dat")
399 current = get_ex_events(ex_account.calendar)
401 gcal_account = gcal_login()
402 gcal_tz = get_gcal_timezone(gcal_account)
404 if cache is not None:
405 added,deleted,changed = ex_event_changes(cache,current)
406 add_ex_to_gcal(ex_account,gcal_account,gcal_tz,current,added)
407 #delete op needs the "cache" set, as that has the link ids in
408 #for events that are now deleted
409 del_ex_to_gcal(ex_account,gcal_account,cache,deleted)
410 update_ex_to_gcal(ex_account,gcal_account,gcal_tz,current,changed)
412 match_ex_to_gcal(ex_account,gcal_account,gcal_tz,current)
414 with open(cachepath,"wb") as f:
415 pickle.dump(current,f)
417 if __name__ == "__main__":