chiark / gitweb /
Split out building gcal event structure (no functional change)
[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
30 #Not sure what the distribution approach here is...
31 gcal_client_id = '805127902516-ptbbtgpq9o8pjr6r3k6hsm60j589o85u.apps.googleusercontent.com'
32 gcal_client_secret = '8hpdxV3MauorryTDoZ1YK8JO'
33
34 #scope URL for r/w calendar access
35 scope = 'https://www.googleapis.com/auth/calendar'
36 #flow object, for doing OAuth2.0 stuff
37 flow = oauth2client.client.OAuth2WebServerFlow(gcal_client_id,
38                                                gcal_client_secret,
39                                                scope)
40
41
42 gcal_authpath=".gooswap_gcal_creds.dat"
43
44 cachepath=".gooswapcache"
45
46 exchange_credential = None
47
48 CachedExEvent=collections.namedtuple('CachedExEvent',
49                                      ['changekey','gcal_link'])
50
51 class ex_gcal_link(exchangelib.ExtendedProperty):
52     distinguished_property_set_id = 'PublicStrings'
53     property_name = "google calendar event id"
54     property_type = 'String'
55
56 exchangelib.CalendarItem.register('gcal_link',ex_gcal_link)
57
58 def get_ex_event_by_itemid(calendar,itemid):
59     return calendar.get(item_id=itemid)
60
61 def get_ex_event_by_id_and_changekey(acct,itemid,changekey):
62     l=list(acct.fetch([(itemid,changekey)]))
63     return list(acct.fetch([(itemid,changekey)]))[0]
64
65 def get_ex_cred(username="SANGER\mv3",password=None):
66     if password is None:
67         password = getpass.getpass(prompt="Password for user %s: " % username)
68     return exchangelib.ServiceAccount(username,password)
69
70 def ex_login(emailaddr,ad_cache_path=None):
71     global exchange_credential
72     autodiscover = True
73     if exchange_credential is None:
74         exchange_credential = get_ex_cred()
75     if ad_cache_path is not None:
76         try:
77             with open(ad_cache_path,"rb") as f:
78                 url,auth_type = pickle.load(f)
79                 autodiscover = False
80         except FileNotFoundError:
81             pass
82
83     if autodiscover:
84         ex_ac = exchangelib.Account(emailaddr,
85                                     credentials = exchange_credential,
86                                     autodiscover = autodiscover)
87         if ad_cache_path is not None:
88             cache=(ex_ac.protocol.service_endpoint,
89                    ex_ac.protocol.auth_type)
90             with open(ad_cache_path,"wb") as f:
91                 pickle.dump(cache,f)
92     else:
93         ex_conf = exchangelib.Configuration(service_endpoint=url,
94                                             credentials=exchange_credential,
95                                             auth_type=auth_type)
96         ex_ac = exchangelib.Account(emailaddr,
97                                     config=ex_conf,
98                                     autodiscover=False,
99                                     access_type=exchangelib.DELEGATE)
100
101     return ex_ac
102
103 def get_ex_events(calendar):
104     ans={}
105     for event in calendar.all().only('changekey','item_id','gcal_link'):
106         if event.item_id in ans:
107             logger.warning("Event item_id %s was duplicated!" % event.item_id)
108         ans[event.item_id] = CachedExEvent(event.changekey,event.gcal_link)
109     logger.info("%d events found" % len(ans))
110     return ans
111
112 def ex_event_changes(old,new):
113     olds = set(old.keys())
114     news = set(new.keys())
115     added = list(news-olds)
116     deleted = list(olds-news)
117     changed = []
118     #intersection - i.e. common to both sets
119     for event in olds & news:
120         if old[event].changekey != new[event].changekey:
121             changed.append(event)
122     logger.info("%d events updated, %d added, %d deleted" % (len(changed),
123                                                               len(added),
124                                                               len(deleted)))
125     return added, deleted, changed
126
127 def build_gcal_event_from_ex(event):
128     gevent={}
129     gevent["summary"]=event.subject
130     if event.is_all_day:
131         gevent["end"]={"date": str(event.end.astimezone(gcal_tz).date())}
132         gevent["start"]={"date": str(event.start.astimezone(gcal_tz).date())}
133     else:
134         gevent["end"]={"dateTime": event.end.isoformat(),
135                        "timeZone": event.end.tzname()}
136         gevent["start"]={"dateTime": event.start.isoformat(),
137                          "timeZone": event.start.tzname()}
138     if event.text_body.strip() != '':
139         gevent["description"] = event.text_body
140     if event.location is not None:
141         gevent["location"] = event.location
142     gevent["extendedProperties"]={"shared": {"ex_id": event.item_id}}
143     return gevent
144
145 def add_ex_to_gcal(ex_acct,
146                    gcal_acct,gcal_tz,events,
147                    added,
148                    gcal_id="primary"):
149     for ev_id in added:
150         event = get_ex_event_by_itemid(ex_acct.calendar,ev_id)
151         if not event.is_recurring:
152             gevent = build_gcal_event_from_ex(event)
153             gevent = gcal_acct.events().insert(calendarId=gcal_id,
154                                                body=gevent).execute()
155             event.gcal_link = gevent.get("id")
156             event.save()
157             events[event.item_id] = events[event.item_id]._replace(changekey=event.changekey,gcal_link=event.gcal_link)
158         else:
159             logger.warning("recurring events not yet supported")
160
161 def del_ex_to_gcal(ex_acct, gcal_acct, events, deleted, gcal_id="primary"):
162     for ev_id in deleted:
163         if events[ev_id].gcal_link is not None:
164             gcal_acct.events().delete(calendarId=gcal_id,
165                                       eventId=events[ev_id].gcal_link,
166                                       sendUpdates="none").execute()
167     
168 def get_gcal_cred():
169     #each such file can only store a single credential
170     storage = oauth2client.file.Storage(gcal_authpath)
171     gcal_credential = storage.get()
172     #if no credential found, or they're invalid (e.g. expired),
173     #then get a new one; pass --noauth_local_webserver on the command line
174     #if you don't want it to spawn a browser
175     if gcal_credential is None or gcal_credential.invalid:
176         gcal_credential = oauth2client.tools.run_flow(flow,
177                                                       storage,
178                                                       oauth2client.tools.argparser.parse_args())
179     return gcal_credential
180
181 def gcal_login():
182     gcal_credential = get_gcal_cred()
183     # Object to handle http requests; could add proxy details
184     http = httplib2.Http()
185     http = gcal_credential.authorize(http)
186     return apiclient.discovery.build('calendar', 'v3', http=http)
187
188 def get_gcal_timezone(gcal_account,calendarid="primary"):
189     gcal = gcal_account.calendars().get(calendarId=calendarid).execute()
190     return exchangelib.EWSTimeZone.timezone(gcal['timeZone'])
191
192 def main():
193     try:
194         with open(cachepath,"rb") as f:
195             cache = pickle.load(f)
196     except FileNotFoundError:
197         cache = None
198
199     ex_account = ex_login("mv3@sanger.ac.uk",".gooswapper_exch_conf.dat")
200     current = get_ex_events(ex_account.calendar)
201
202     gcal_account = gcal_login()
203     gcal_tz = get_gcal_timezone(gcal_account)
204     
205     if cache is not None:
206         added,deleted,changed = ex_event_changes(cache,current)
207         add_ex_to_gcal(ex_account,gcal_account,gcal_tz,current,added)
208         #delete op needs the "cache" set, as that has the link ids in
209         #for events that are now deleted
210         del_ex_to_gcal(ex_account,gcal_account,cache,deleted)
211         
212     with open(cachepath,"wb") as f:
213         pickle.dump(current,f)
214
215 if __name__ == "__main__":
216     main()
217
218