chiark / gitweb /
9f4cefa271a7ff60e1ef96d04db36b070e419a98
[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 logging
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)
18
19 #Exchange-related library
20 sys.path.append("/upstreams/exchangelib")
21 import exchangelib
22
23 #Google calendar-api libraries
24 import httplib2
25 import apiclient.discovery
26 import oauth2client
27 import oauth2client.file
28 import oauth2client.client
29 import googleapiclient.errors
30
31 #Not sure what the distribution approach here is...
32 gcal_client_id = '805127902516-ptbbtgpq9o8pjr6r3k6hsm60j589o85u.apps.googleusercontent.com'
33 gcal_client_secret = '8hpdxV3MauorryTDoZ1YK8JO'
34
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,
39                                                gcal_client_secret,
40                                                scope)
41
42
43 gcal_authpath=".gooswap_gcal_creds.dat"
44
45 cachepath=".gooswapcache"
46
47 exchange_credential = None
48
49 CachedExEvent=collections.namedtuple('CachedExEvent',
50                                      ['changekey','gcal_link'])
51
52 class ex_gcal_link(exchangelib.ExtendedProperty):
53     distinguished_property_set_id = 'PublicStrings'
54     property_name = "google calendar event id"
55     property_type = 'String'
56
57 try:
58     exchangelib.CalendarItem.get_field_by_fieldname('gcal_link')
59 except ValueError:
60     exchangelib.CalendarItem.register('gcal_link',ex_gcal_link)
61
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)
66     cache.pop(itemid)
67     with open(cachepath,"wb") as f:
68         pickle.dump(cache,f)
69
70 def get_ex_event_by_itemid(calendar,itemid):
71     return calendar.get(item_id=itemid)
72
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]
76
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()
79
80 def get_gcal_recur_instance(gcal_acct,gcal_master,start,gcal_id="primary"):
81     ans = gcal_acct.events().instances(calendarId=gcal_id,
82                                        eventId=gcal_master,
83                                        originalStart=start.isoformat(),
84                                        showDeleted=True).execute()
85     if len(ans['items']) != 1:
86         logger.error("Searching for recurrance instance returned %d events" % \
87                      len(ans['items']))
88         return None
89     return ans['items'][0]
90
91 def get_ex_cred(username="SANGER\mv3",password=None):
92     if password is None:
93         password = getpass.getpass(prompt="Password for user %s: " % username)
94     return exchangelib.ServiceAccount(username,password)
95
96 def ex_login(emailaddr,ad_cache_path=None):
97     global exchange_credential
98     autodiscover = True
99     if exchange_credential is None:
100         exchange_credential = get_ex_cred()
101     if ad_cache_path is not None:
102         try:
103             with open(ad_cache_path,"rb") as f:
104                 url,auth_type = pickle.load(f)
105                 autodiscover = False
106         except FileNotFoundError:
107             pass
108
109     if autodiscover:
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:
117                 pickle.dump(cache,f)
118     else:
119         ex_conf = exchangelib.Configuration(service_endpoint=url,
120                                             credentials=exchange_credential,
121                                             auth_type=auth_type)
122         ex_ac = exchangelib.Account(emailaddr,
123                                     config=ex_conf,
124                                     autodiscover=False,
125                                     access_type=exchangelib.DELEGATE)
126
127     return ex_ac
128
129 def get_ex_events(calendar):
130     ans={}
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))
136     return ans
137
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)
143     changed = []
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),
149                                                               len(added),
150                                                               len(deleted)))
151     return added, deleted, changed
152
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]
158
159 #for monthly patterns, we want the week (or -1 for last) combined with each
160 #day specified
161 def rr_daystr_monthly(p):
162     if p.week_number == 5:
163         wn = "-1"
164     else:
165         wn = str(p.week_number)
166     return ",".join([wn + rr_daystr_from_int(x) for x in p.weekdays])
167
168 def rrule_from_ex(event,gcal_tz):
169     if event.type != "RecurringMaster":
170         logger.error("Cannot make recurrence from not-recurring event")
171         return None
172     if event.recurrence is None:
173         logger.error("Empty recurrence structure")
174         return None
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))
204     else:
205         logger.error("Recurrence %s not supported" % event.recurrence)
206         return None
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
213     else:
214         logger.error("Recurrence %s not supported" % event.recurrence)
215         return None
216     return [rr]
217
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
225                 return
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'),
230                                                body=gevent,
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,
237                                                d.start,gcal_id)
238             if instance is None: #give up after any failure
239                 return
240             if instance["status"] != "cancelled":
241                 instance["status"]="cancelled"
242                 gcal_acct.events().update(calendarId=gcal_id,
243                                           eventId=instance.get('id'),
244                                           body=instance,
245                                           sendUpdates="none").execute()
246
247 def build_gcal_event_from_ex(event,gcal_tz):
248     gevent={}
249     gevent["summary"]=event.subject
250     if event.is_all_day:
251         gevent["end"]={"date": str(event.end.astimezone(gcal_tz).date())}
252         gevent["start"]={"date": str(event.start.astimezone(gcal_tz).date())}
253     else:
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}}
263     return gevent
264
265 def add_ex_to_gcal(ex_acct,
266                    gcal_acct,gcal_tz,events,
267                    added,
268                    gcal_id="primary"):
269     for ev_id in added:
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)
274             if rr is not None:
275                 gevent["recurrence"] = rr
276                 print(gevent)
277             else:
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
289             event.refresh()
290         events[event.item_id] = events[event.item_id]._replace(changekey=event.changekey,gcal_link=event.gcal_link)
291         
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,
297                                                gcal_id)
298             if gevent["status"] != "cancelled":
299                 gcal_acct.events().delete(calendarId=gcal_id,
300                                           eventId=events[ev_id].gcal_link,
301                                           sendUpdates="none").execute()
302
303 def update_ex_to_gcal(ex_acct,
304                       gcal_acct,gcal_tz,
305                       events,changed,
306                       gcal_id="primary"):
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)
312             if rr is not None:
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)
320             else:
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,
325                                                body=gevent,
326                                                sendUpdates="none").execute()
327
328 def match_ex_to_gcal(ex_acct,gcal_acct,gcal_tz,events,gcal_id="primary"):
329     recur = 0
330     matched = 0
331     skipped = 0
332     for ev_id in events:
333         event = get_ex_event_by_itemid(ex_acct.calendar,ev_id)
334         if event.is_recurring:
335             recur += 1
336             continue
337         elif event.gcal_link is not None:
338             skipped += 1
339             continue
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)
350                 gevent = {}
351                 gevent["start"] = ge["start"]
352                 gevent["end"] = ge["end"]
353                 gevent["extendedProperties"]={"shared": {"ex_id": event.item_id}}
354                 try:
355                     gcal_acct.events().update(calendarId=gcal_id,
356                                               eventId=event.gcal_link,
357                                               body=gevent,
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:
362                         pass
363                 matched += 1
364                 break
365     logger.info("Matched %d events, skipped %d with existing link, and %d recurring ones" % (matched,skipped,recur))
366     
367 def get_gcal_cred():
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,
376                                                       storage,
377                                                       oauth2client.tools.argparser.parse_args())
378     return gcal_credential
379
380 def gcal_login():
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)
386
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'])
390
391 def main():
392     try:
393         with open(cachepath,"rb") as f:
394             cache = pickle.load(f)
395     except FileNotFoundError:
396         cache = None
397
398     ex_account = ex_login("mv3@sanger.ac.uk",".gooswapper_exch_conf.dat")
399     current = get_ex_events(ex_account.calendar)
400
401     gcal_account = gcal_login()
402     gcal_tz = get_gcal_timezone(gcal_account)
403     
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)
411     else:
412         match_ex_to_gcal(ex_account,gcal_account,gcal_tz,current)
413         
414     with open(cachepath,"wb") as f:
415         pickle.dump(current,f)
416
417 if __name__ == "__main__":
418     main()
419
420