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