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'
57 exchangelib.CalendarItem.register('gcal_link',ex_gcal_link)
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)
64 with open(cachepath,"wb") as f:
67 def get_ex_event_by_itemid(calendar,itemid):
68 return calendar.get(item_id=itemid)
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]
74 def get_ex_cred(username="SANGER\mv3",password=None):
76 password = getpass.getpass(prompt="Password for user %s: " % username)
77 return exchangelib.ServiceAccount(username,password)
79 def ex_login(emailaddr,ad_cache_path=None):
80 global exchange_credential
82 if exchange_credential is None:
83 exchange_credential = get_ex_cred()
84 if ad_cache_path is not None:
86 with open(ad_cache_path,"rb") as f:
87 url,auth_type = pickle.load(f)
89 except FileNotFoundError:
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:
102 ex_conf = exchangelib.Configuration(service_endpoint=url,
103 credentials=exchange_credential,
105 ex_ac = exchangelib.Account(emailaddr,
108 access_type=exchangelib.DELEGATE)
112 def get_ex_events(calendar):
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))
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)
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),
134 return added, deleted, changed
136 def rrule_from_ex(event,gcal_tz):
137 if event.type != "RecurringMaster":
138 logger.error("Cannot make recurrence from not-recurring event")
140 if event.recurrence is None:
141 logger.error("Empty recurrence structure")
143 if isinstance(event.recurrence.pattern,
144 exchangelib.recurrence.DailyPattern):
145 rr = "RRULE:FREQ=DAILY;INTERVAL=%d" % event.recurrence.pattern.interval
147 logger.error("Recurrence %s not supported" % event.recurrence)
149 if isinstance(event.recurrence.boundary,
150 exchangelib.recurrence.EndDatePattern):
151 rr += ";UNTIL={0:%Y}{0:%m}{0:%d}".format(event.recurrence.boundary.end)
153 logger.error("Recurrence %s not supported" % event.recurrence)
155 if event.modified_occurrences is not None or \
156 event.deleted_occurrences is not None:
157 logger.warning("Modified/Deleted recurrences not supported")
160 def build_gcal_event_from_ex(event,gcal_tz):
162 gevent["summary"]=event.subject
164 gevent["end"]={"date": str(event.end.astimezone(gcal_tz).date())}
165 gevent["start"]={"date": str(event.start.astimezone(gcal_tz).date())}
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}}
178 def add_ex_to_gcal(ex_acct,
179 gcal_acct,gcal_tz,events,
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)
188 gevent["recurrence"] = rr
191 logger.warning("Unable to set recurrence")
192 gevent = gcal_acct.events().insert(calendarId=gcal_id,
193 body=gevent).execute()
194 event.gcal_link = gevent.get("id")
195 event.save(update_fields=["gcal_link"])
196 events[event.item_id] = events[event.item_id]._replace(changekey=event.changekey,gcal_link=event.gcal_link)
198 def del_ex_to_gcal(ex_acct, gcal_acct, events, deleted, gcal_id="primary"):
199 for ev_id in deleted:
200 if events[ev_id].gcal_link is not None:
201 gcal_acct.events().delete(calendarId=gcal_id,
202 eventId=events[ev_id].gcal_link,
203 sendUpdates="none").execute()
205 def update_ex_to_gcal(ex_acct,
209 for ev_id in changed:
210 event = get_ex_event_by_itemid(ex_acct.calendar,ev_id)
211 if not event.is_recurring:
212 gevent = build_gcal_event_from_ex(event,gcal_tz)
213 gevent = gcal_acct.events().update(calendarId=gcal_id,
214 eventId=event.gcal_link,
216 sendUpdates="none").execute()
218 logger.warning("recurring events not yet supported")
220 def match_ex_to_gcal(ex_acct,gcal_acct,gcal_tz,events,gcal_id="primary"):
225 event = get_ex_event_by_itemid(ex_acct.calendar,ev_id)
226 if event.is_recurring:
229 elif event.gcal_link is not None:
232 matches = gcal_acct.events().list(calendarId=gcal_id,
233 timeMin=event.start.isoformat(),
234 timeMax=event.end.isoformat()).execute()
235 for ge in matches['items']:
236 if ge['summary'].strip()==event.subject.strip():
237 logger.info("Matching '%s' starting at %s" % (event.subject,
238 event.start.isoformat()))
239 event.gcal_link = ge['id']
240 event.save(update_fields=["gcal_link"])
241 events[event.item_id] = events[event.item_id]._replace(changekey=event.changekey,gcal_link=event.gcal_link)
243 gevent["start"] = ge["start"]
244 gevent["end"] = ge["end"]
245 gevent["extendedProperties"]={"shared": {"ex_id": event.item_id}}
247 gcal_acct.events().update(calendarId=gcal_id,
248 eventId=event.gcal_link,
250 sendUpdates="none").execute()
251 #this may fail if we don't own the event
252 except googleapiclient.errors.HttpError as err:
253 if err.resp.status == 403:
257 logger.info("Matched %d events, skipped %d with existing link, and %d recurring ones" % (matched,skipped,recur))
260 #each such file can only store a single credential
261 storage = oauth2client.file.Storage(gcal_authpath)
262 gcal_credential = storage.get()
263 #if no credential found, or they're invalid (e.g. expired),
264 #then get a new one; pass --noauth_local_webserver on the command line
265 #if you don't want it to spawn a browser
266 if gcal_credential is None or gcal_credential.invalid:
267 gcal_credential = oauth2client.tools.run_flow(flow,
269 oauth2client.tools.argparser.parse_args())
270 return gcal_credential
273 gcal_credential = get_gcal_cred()
274 # Object to handle http requests; could add proxy details
275 http = httplib2.Http()
276 http = gcal_credential.authorize(http)
277 return apiclient.discovery.build('calendar', 'v3', http=http)
279 def get_gcal_timezone(gcal_account,calendarid="primary"):
280 gcal = gcal_account.calendars().get(calendarId=calendarid).execute()
281 return exchangelib.EWSTimeZone.timezone(gcal['timeZone'])
285 with open(cachepath,"rb") as f:
286 cache = pickle.load(f)
287 except FileNotFoundError:
290 ex_account = ex_login("mv3@sanger.ac.uk",".gooswapper_exch_conf.dat")
291 current = get_ex_events(ex_account.calendar)
293 gcal_account = gcal_login()
294 gcal_tz = get_gcal_timezone(gcal_account)
296 if cache is not None:
297 added,deleted,changed = ex_event_changes(cache,current)
298 add_ex_to_gcal(ex_account,gcal_account,gcal_tz,current,added)
299 #delete op needs the "cache" set, as that has the link ids in
300 #for events that are now deleted
301 del_ex_to_gcal(ex_account,gcal_account,cache,deleted)
302 update_ex_to_gcal(ex_account,gcal_account,gcal_tz,current,changed)
304 match_ex_to_gcal(ex_account,gcal_account,gcal_tz,current)
306 with open(cachepath,"wb") as f:
307 pickle.dump(current,f)
309 if __name__ == "__main__":