chiark / gitweb /
f3c73a0610e4c4dcaab4faeb39f249957a1954ac
[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 exchangelib.CalendarItem.register('gcal_link',ex_gcal_link)
58
59 #useful if you want to replay an event
60 def drop_from_ex_cache(itemid):
61     with open(cachepath,"rb") as f:
62         cache = pickle.load(f)
63     cache.pop(itemid)
64     with open(cachepath,"wb") as f:
65         pickle.dump(cache,f)
66
67 def get_ex_event_by_itemid(calendar,itemid):
68     return calendar.get(item_id=itemid)
69
70 def get_ex_event_by_id_and_changekey(acct,itemid,changekey):
71     l=list(acct.fetch([(itemid,changekey)]))
72     return list(acct.fetch([(itemid,changekey)]))[0]
73
74 def get_ex_cred(username="SANGER\mv3",password=None):
75     if password is None:
76         password = getpass.getpass(prompt="Password for user %s: " % username)
77     return exchangelib.ServiceAccount(username,password)
78
79 def ex_login(emailaddr,ad_cache_path=None):
80     global exchange_credential
81     autodiscover = True
82     if exchange_credential is None:
83         exchange_credential = get_ex_cred()
84     if ad_cache_path is not None:
85         try:
86             with open(ad_cache_path,"rb") as f:
87                 url,auth_type = pickle.load(f)
88                 autodiscover = False
89         except FileNotFoundError:
90             pass
91
92     if autodiscover:
93         ex_ac = exchangelib.Account(emailaddr,
94                                     credentials = exchange_credential,
95                                     autodiscover = autodiscover)
96         if ad_cache_path is not None:
97             cache=(ex_ac.protocol.service_endpoint,
98                    ex_ac.protocol.auth_type)
99             with open(ad_cache_path,"wb") as f:
100                 pickle.dump(cache,f)
101     else:
102         ex_conf = exchangelib.Configuration(service_endpoint=url,
103                                             credentials=exchange_credential,
104                                             auth_type=auth_type)
105         ex_ac = exchangelib.Account(emailaddr,
106                                     config=ex_conf,
107                                     autodiscover=False,
108                                     access_type=exchangelib.DELEGATE)
109
110     return ex_ac
111
112 def get_ex_events(calendar):
113     ans={}
114     for event in calendar.all().only('changekey','item_id','gcal_link'):
115         if event.item_id in ans:
116             logger.warning("Event item_id %s was duplicated!" % event.item_id)
117         ans[event.item_id] = CachedExEvent(event.changekey,event.gcal_link)
118     logger.info("%d events found" % len(ans))
119     return ans
120
121 def ex_event_changes(old,new):
122     olds = set(old.keys())
123     news = set(new.keys())
124     added = list(news-olds)
125     deleted = list(olds-news)
126     changed = []
127     #intersection - i.e. common to both sets
128     for event in olds & news:
129         if old[event].changekey != new[event].changekey:
130             changed.append(event)
131     logger.info("%d events updated, %d added, %d deleted" % (len(changed),
132                                                               len(added),
133                                                               len(deleted)))
134     return added, deleted, changed
135
136 def rrule_from_ex(event,gcal_tz):
137     if event.type != "RecurringMaster":
138         logger.error("Cannot make recurrence from not-recurring event")
139         return None
140     if event.recurrence is None:
141         logger.error("Empty recurrence structure")
142         return None
143     if isinstance(event.recurrence.pattern,
144                   exchangelib.recurrence.DailyPattern):
145         rr = "RRULE:FREQ=DAILY;INTERVAL=%d" % event.recurrence.pattern.interval
146     else:
147         logger.error("Recurrence %s not supported" % event.recurrence)
148         return None
149     if isinstance(event.recurrence.boundary,
150                   exchangelib.recurrence.EndDatePattern):
151         rr += ";UNTIL={0:%Y}{0:%m}{0:%d}".format(event.recurrence.boundary.end)
152     else:
153         logger.error("Recurrence %s not supported" % event.recurrence)
154         return None
155     if event.modified_occurrences is not None or \
156        event.deleted_occurrences is not None:
157         logger.warning("Modified/Deleted recurrences not supported")
158     return [rr]
159
160 def build_gcal_event_from_ex(event,gcal_tz):
161     gevent={}
162     gevent["summary"]=event.subject
163     if event.is_all_day:
164         gevent["end"]={"date": str(event.end.astimezone(gcal_tz).date())}
165         gevent["start"]={"date": str(event.start.astimezone(gcal_tz).date())}
166     else:
167         gevent["end"]={"dateTime": event.end.astimezone(gcal_tz).isoformat(),
168                        "timeZone": str(gcal_tz)}
169         gevent["start"]={"dateTime": event.start.astimezone(gcal_tz).isoformat(),
170                          "timeZone": str(gcal_tz)}
171     if event.text_body is not None and event.text_body.strip() != '':
172         gevent["description"] = event.text_body
173     if event.location is not None:
174         gevent["location"] = event.location
175     gevent["extendedProperties"]={"shared": {"ex_id": event.item_id}}
176     return gevent
177
178 def add_ex_to_gcal(ex_acct,
179                    gcal_acct,gcal_tz,events,
180                    added,
181                    gcal_id="primary"):
182     for ev_id in added:
183         event = get_ex_event_by_itemid(ex_acct.calendar,ev_id)
184         gevent = build_gcal_event_from_ex(event,gcal_tz)
185         if event.type=="RecurringMaster":
186             rr = rrule_from_ex(event,gcal_tz)
187             if rr is not None:
188                 gevent["recurrence"] = rr
189                 print(gevent)
190             else:
191                 logger.warning("Unable to set recurrence for %s" % event.item_id)
192                 continue #don't make the gcal event
193         gevent = gcal_acct.events().insert(calendarId=gcal_id,
194                                            body=gevent).execute()
195         event.gcal_link = gevent.get("id")
196         event.save(update_fields=["gcal_link"])
197         events[event.item_id] = events[event.item_id]._replace(changekey=event.changekey,gcal_link=event.gcal_link)
198
199 def del_ex_to_gcal(ex_acct, gcal_acct, events, deleted, gcal_id="primary"):
200     for ev_id in deleted:
201         if events[ev_id].gcal_link is not None:
202             gcal_acct.events().delete(calendarId=gcal_id,
203                                       eventId=events[ev_id].gcal_link,
204                                       sendUpdates="none").execute()
205
206 def update_ex_to_gcal(ex_acct,
207                       gcal_acct,gcal_tz,
208                       events,changed,
209                       gcal_id="primary"):
210     for ev_id in changed:
211         event = get_ex_event_by_itemid(ex_acct.calendar,ev_id)
212         if not event.is_recurring:
213             gevent = build_gcal_event_from_ex(event,gcal_tz)
214             gevent = gcal_acct.events().update(calendarId=gcal_id,
215                                                eventId=event.gcal_link,
216                                                body=gevent,
217                                                sendUpdates="none").execute()
218         else:
219             logger.warning("recurring events not yet supported")
220
221 def match_ex_to_gcal(ex_acct,gcal_acct,gcal_tz,events,gcal_id="primary"):
222     recur = 0
223     matched = 0
224     skipped = 0
225     for ev_id in events:
226         event = get_ex_event_by_itemid(ex_acct.calendar,ev_id)
227         if event.is_recurring:
228             recur += 1
229             continue
230         elif event.gcal_link is not None:
231             skipped += 1
232             continue
233         matches = gcal_acct.events().list(calendarId=gcal_id,
234                                           timeMin=event.start.isoformat(),
235                                           timeMax=event.end.isoformat()).execute()
236         for ge in matches['items']:
237             if ge['summary'].strip()==event.subject.strip():
238                 logger.info("Matching '%s' starting at %s" % (event.subject,
239                                                               event.start.isoformat()))
240                 event.gcal_link = ge['id']
241                 event.save(update_fields=["gcal_link"])
242                 events[event.item_id] = events[event.item_id]._replace(changekey=event.changekey,gcal_link=event.gcal_link)
243                 gevent = {}
244                 gevent["start"] = ge["start"]
245                 gevent["end"] = ge["end"]
246                 gevent["extendedProperties"]={"shared": {"ex_id": event.item_id}}
247                 try:
248                     gcal_acct.events().update(calendarId=gcal_id,
249                                               eventId=event.gcal_link,
250                                               body=gevent,
251                                               sendUpdates="none").execute()
252                 #this may fail if we don't own the event
253                 except googleapiclient.errors.HttpError as err:
254                     if err.resp.status == 403:
255                         pass
256                 matched += 1
257                 break
258     logger.info("Matched %d events, skipped %d with existing link, and %d recurring ones" % (matched,skipped,recur))
259     
260 def get_gcal_cred():
261     #each such file can only store a single credential
262     storage = oauth2client.file.Storage(gcal_authpath)
263     gcal_credential = storage.get()
264     #if no credential found, or they're invalid (e.g. expired),
265     #then get a new one; pass --noauth_local_webserver on the command line
266     #if you don't want it to spawn a browser
267     if gcal_credential is None or gcal_credential.invalid:
268         gcal_credential = oauth2client.tools.run_flow(flow,
269                                                       storage,
270                                                       oauth2client.tools.argparser.parse_args())
271     return gcal_credential
272
273 def gcal_login():
274     gcal_credential = get_gcal_cred()
275     # Object to handle http requests; could add proxy details
276     http = httplib2.Http()
277     http = gcal_credential.authorize(http)
278     return apiclient.discovery.build('calendar', 'v3', http=http)
279
280 def get_gcal_timezone(gcal_account,calendarid="primary"):
281     gcal = gcal_account.calendars().get(calendarId=calendarid).execute()
282     return exchangelib.EWSTimeZone.timezone(gcal['timeZone'])
283
284 def main():
285     try:
286         with open(cachepath,"rb") as f:
287             cache = pickle.load(f)
288     except FileNotFoundError:
289         cache = None
290
291     ex_account = ex_login("mv3@sanger.ac.uk",".gooswapper_exch_conf.dat")
292     current = get_ex_events(ex_account.calendar)
293
294     gcal_account = gcal_login()
295     gcal_tz = get_gcal_timezone(gcal_account)
296     
297     if cache is not None:
298         added,deleted,changed = ex_event_changes(cache,current)
299         add_ex_to_gcal(ex_account,gcal_account,gcal_tz,current,added)
300         #delete op needs the "cache" set, as that has the link ids in
301         #for events that are now deleted
302         del_ex_to_gcal(ex_account,gcal_account,cache,deleted)
303         update_ex_to_gcal(ex_account,gcal_account,gcal_tz,current,changed)
304     else:
305         match_ex_to_gcal(ex_account,gcal_account,gcal_tz,current)
306         
307     with open(cachepath,"wb") as f:
308         pickle.dump(current,f)
309
310 if __name__ == "__main__":
311     main()
312
313