#!/usr/bin/env python3
+# Copyright (C) 2018,2021 Genome Research Limited
+#
+# Author: Matthew Vernon <mv3@sanger.ac.uk>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
import sys
import getpass
import os
+import os.path
import pickle
+import collections
+import argparse
+import time
import logging
logger = logging.getLogger('gooswapper')
logger.setLevel(logging.INFO)
import apiclient.discovery
import oauth2client
import oauth2client.file
+import oauth2client.tools
import oauth2client.client
+import googleapiclient.errors
#Not sure what the distribution approach here is...
gcal_client_id = '805127902516-ptbbtgpq9o8pjr6r3k6hsm60j589o85u.apps.googleusercontent.com'
gcal_client_secret,
scope)
+gsdir = os.path.expanduser("~/.gooswapper")
+gcal_authpath = gsdir + "/.gooswap_gcal_creds.dat"
-gcal_authpath=".gooswap_gcal_creds.dat"
-
-cachepath=".gooswapcache"
+cachepath=None
exchange_credential = None
+CachedExEvent=collections.namedtuple('CachedExEvent',
+ ['changekey','gcal_link'])
+
class ex_gcal_link(exchangelib.ExtendedProperty):
distinguished_property_set_id = 'PublicStrings'
- #property_set_id = '12345678-1234-1234-1234-123456781234'
- # property_set_id = "gooswapper"
property_name = "google calendar event id"
property_type = 'String'
-exchangelib.CalendarItem.register('gcal_link',ex_gcal_link)
-
-#see docs for exchangelib.UID for why this is needed
-class GlobalObjectId(exchangelib.ExtendedProperty):
- distinguished_property_set_id = 'Meeting'
- property_id = 3
- property_type = 'Binary'
+try:
+ exchangelib.CalendarItem.get_field_by_fieldname('gcal_link')
+except ValueError:
+ exchangelib.CalendarItem.register('gcal_link',ex_gcal_link)
-exchangelib.CalendarItem.register('global_object_id', GlobalObjectId)
+#useful if you want to replay an event
+def drop_from_ex_cache(itemid):
+ with open(cachepath,"rb") as f:
+ cache = pickle.load(f)
+ cache.pop(itemid)
+ with open(cachepath,"wb") as f:
+ pickle.dump(cache,f)
-def get_ex_event_by_uid(calendar,uid):
- return calendar.get(global_object_id=GlobalObjectId(exchangelib.UID(uid)))
+def get_ex_event_by_itemid(calendar,itemid):
+ return calendar.get(item_id=itemid)
def get_ex_event_by_id_and_changekey(acct,itemid,changekey):
l=list(acct.fetch([(itemid,changekey)]))
return list(acct.fetch([(itemid,changekey)]))[0]
+def get_gcal_event_by_eventid(gcal_acct,eventId,gcal_id="primary"):
+ return gcal_acct.events().get(calendarId=gcal_id,eventId=eventId).execute()
+
+def get_gcal_recur_instance(gcal_acct,gcal_master,start,is_all_day,stz,
+ gcal_id="primary"):
+ if gcal_master is None:
+ logger.warning("Cannot get recurrences from event with null gcal id")
+ return None
+ if is_all_day:
+ os = str(start.astimezone(stz).date())
+ else:
+ os = start.astimezone(stz).isoformat()
+ ans = gcal_acct.events().instances(calendarId=gcal_id,
+ eventId=gcal_master,
+ originalStart=os,
+ showDeleted=True).execute()
+ if len(ans['items']) != 1:
+ logger.error("Searching for recurrance instance returned %d events" % \
+ len(ans['items']))
+ return None
+ return ans['items'][0]
+
def get_ex_cred(username="SANGER\mv3",password=None):
if password is None:
password = getpass.getpass(prompt="Password for user %s: " % username)
return exchangelib.ServiceAccount(username,password)
-# return exchangelib.Credentials(username,password)
-def ex_login(emailaddr,autodiscover=True):
+def ex_login(username,emailaddr,ad_cache_path=None):
global exchange_credential
+ autodiscover = True
if exchange_credential is None:
- exchange_credential = get_ex_cred()
- return exchangelib.Account(emailaddr,
- credentials = exchange_credential,
- autodiscover = autodiscover)
+ exchange_credential = get_ex_cred(username)
+ if ad_cache_path is not None:
+ try:
+ with open(ad_cache_path,"rb") as f:
+ url,auth_type = pickle.load(f)
+ autodiscover = False
+ except FileNotFoundError:
+ pass
+
+ if autodiscover:
+ ex_ac = exchangelib.Account(emailaddr,
+ credentials = exchange_credential,
+ autodiscover = autodiscover)
+ if ad_cache_path is not None:
+ cache=(ex_ac.protocol.service_endpoint,
+ ex_ac.protocol.auth_type)
+ with open(ad_cache_path,"wb") as f:
+ pickle.dump(cache,f)
+ else:
+ ex_conf = exchangelib.Configuration(service_endpoint=url,
+ credentials=exchange_credential,
+ auth_type=auth_type)
+ ex_ac = exchangelib.Account(emailaddr,
+ config=ex_conf,
+ autodiscover=False,
+ access_type=exchangelib.DELEGATE)
+
+ return ex_ac
def get_ex_events(calendar):
ans={}
- itemids={}
- for event in calendar.all().only('uid','changekey','item_id','gcal_link'):
- if event.gcal_link is not None:
-# event.delete()
- continue
- if event.uid in ans:
- logger.warning("Event uid %s was duplicated!" % event.uid)
- ans[event.uid] = event.changekey
- itemids[event.uid] = event.item_id
+ for event in calendar.all().only('changekey','item_id','gcal_link'):
+ if event.item_id in ans:
+ logger.warning("Event item_id %s was duplicated!" % event.item_id)
+ ans[event.item_id] = CachedExEvent(event.changekey,event.gcal_link)
logger.info("%d events found" % len(ans))
- return (ans,itemids)
+ return ans
def ex_event_changes(old,new):
olds = set(old.keys())
changed = []
#intersection - i.e. common to both sets
for event in olds & news:
- if old[event] != new[event]:
+ if old[event].changekey != new[event].changekey:
changed.append(event)
logger.info("%d events updated, %d added, %d deleted" % (len(changed),
len(added),
len(deleted)))
return added, deleted, changed
-#XXX doesn't work - cf https://github.com/ecederstrand/exchangelib/issues/492
-def add_ex_to_gcal_needs_ev_id(ex_cal,events):
- for ev_id in events:
- print(ev_id)
- event = get_ex_event_by_uid(ex_cal,ev_id)
- event.gcal_link = "Testing"
- event.save()
+#exchangelib gives us days in recurrence patterns as integers,
+#RFC5545 wants SU,MO,TU,WE,TH,FR,SA
+#it has a utility function to convert to Monday, Tuesday, ...
+def rr_daystr_from_int(i):
+ return exchangelib.recurrence._weekday_to_str(i).upper()[:2]
+
+#for monthly patterns, we want the week (or -1 for last) combined with each
+#day specified
+def rr_daystr_monthly(p):
+ if p.week_number == 5:
+ wn = "-1"
+ else:
+ wn = str(p.week_number)
+ return ",".join([wn + rr_daystr_from_int(x) for x in p.weekdays])
+
+def rrule_from_ex(event,gcal_tz):
+ if event.type != "RecurringMaster":
+ logger.error("Cannot make recurrence from not-recurring event")
+ return None
+ if event.recurrence is None:
+ logger.error("Empty recurrence structure")
+ return None
+ if isinstance(event.recurrence.pattern,
+ exchangelib.recurrence.DailyPattern):
+ rr = "RRULE:FREQ=DAILY;INTERVAL=%d" % event.recurrence.pattern.interval
+ elif isinstance(event.recurrence.pattern,
+ exchangelib.recurrence.WeeklyPattern):
+ rr = "RRULE:FREQ=WEEKLY;INTERVAL=%d;BYDAY=%s;WKST=%s" % \
+ (event.recurrence.pattern.interval,
+ ",".join([rr_daystr_from_int(x) for x in event.recurrence.pattern.weekdays]),
+ rr_daystr_from_int(event.recurrence.pattern.first_day_of_week) )
+ elif isinstance(event.recurrence.pattern,
+ exchangelib.recurrence.RelativeMonthlyPattern):
+ rr = "RRULE:FREQ=MONTHLY;INTERVAL=%d;BYDAY=%s" % \
+ (event.recurrence.pattern.interval,
+ rr_daystr_monthly(event.recurrence.pattern))
+ elif isinstance(event.recurrence.pattern,
+ exchangelib.recurrence.AbsoluteMonthlyPattern):
+ rr = "RRULE:FREQ=MONTHLY;INTERVAL=%d;BYMONTHDAY=%d" % \
+ (event.recurrence.pattern.interval,
+ event.recurrence.pattern.day_of_month)
+ elif isinstance(event.recurrence.pattern,
+ exchangelib.recurrence.AbsoluteYearlyPattern):
+ rr = "RRULE:FREQ=YEARLY;BYMONTH=%d;BYMONTHDAY=%d" % \
+ (event.recurrence.pattern.month,
+ event.recurrence.pattern.day_of_month)
+ elif isinstance(event.recurrence.pattern,
+ exchangelib.recurrence.RelativeYearlyPattern):
+ rr = "RRULE:FREQ=YEARLY;BYMONTH=%d;BYDAY=%s" % \
+ (event.recurrence.pattern.month,
+ rr_daystr_monthly(event.recurrence.pattern))
+ else:
+ logger.error("Recurrence %s not supported" % event.recurrence)
+ return None
+ if isinstance(event.recurrence.boundary,
+ exchangelib.recurrence.EndDatePattern):
+ rr += ";UNTIL={0:%Y}{0:%m}{0:%d}".format(event.recurrence.boundary.end)
+ elif isinstance(event.recurrence.boundary,
+ exchangelib.recurrence.NoEndPattern):
+ pass #no end date to set
+ else:
+ logger.error("Recurrence %s not supported" % event.recurrence)
+ return None
+ return [rr]
+
+def modify_recurring(ex_acct,gcal_acct,gcal_tz,
+ events,master,gcal_id="primary"):
+ if master.modified_occurrences is not None:
+ for mod in master.modified_occurrences:
+ instance = get_gcal_recur_instance(gcal_acct,master.gcal_link,
+ mod.original_start,
+ master.is_all_day,
+ master._start_timezone,
+ gcal_id)
+ if instance is None: #give up after first failure
+ return
+ mod_event = get_ex_event_by_itemid(ex_acct.calendar,mod.item_id)
+ gevent = build_gcal_event_from_ex(mod_event,gcal_tz)
+ gevent = gcal_acct.events().update(calendarId=gcal_id,
+ eventId=instance.get('id'),
+ body=gevent,
+ sendUpdates="none").execute()
+ mod_event.gcal_link = gevent.get("id")
+ mod_event.save(update_fields=["gcal_link"])
+ if master.deleted_occurrences is not None:
+ for d in master.deleted_occurrences:
+ instance = get_gcal_recur_instance(gcal_acct,master.gcal_link,
+ d.start,
+ master.is_all_day,
+ master._start_timezone,
+ gcal_id)
+ if instance is None: #give up after any failure
+ return
+ if instance["status"] != "cancelled":
+ instance["status"]="cancelled"
+ gcal_acct.events().update(calendarId=gcal_id,
+ eventId=instance.get('id'),
+ body=instance,
+ sendUpdates="none").execute()
+
+def build_gcal_event_from_ex(event,gcal_tz):
+ gevent={}
+ gevent["summary"]=event.subject
+ #Use the event's timezones if possible, otherwise fall back to the
+ #target calendar timezone
+ if event._start_timezone is not None:
+ stz = event._start_timezone
+ else:
+ stz = gcal_tz
+ if event._end_timezone is not None:
+ etz = event._end_timezone
+ else:
+ etz = gcal_tz
+ if event.is_all_day:
+ gevent["end"]={"date": str(event.end.astimezone(etz).date())}
+ gevent["start"]={"date": str(event.start.astimezone(stz).date())}
+ else:
+ gevent["end"]={"dateTime": event.end.astimezone(etz).isoformat(),
+ "timeZone": str(etz)}
+ gevent["start"]={"dateTime": event.start.astimezone(stz).isoformat(),
+ "timeZone": str(stz)}
+ if event.text_body is not None and event.text_body.strip() != '':
+ gevent["description"] = event.text_body
+ if event.location is not None:
+ gevent["location"] = event.location
+ gevent["extendedProperties"]={"shared": {"ex_id": event.item_id}}
+ return gevent
def add_ex_to_gcal(ex_acct,
gcal_acct,gcal_tz,events,
- itemids,added,
+ added,
gcal_id="primary"):
for ev_id in added:
- event = get_ex_event_by_id_and_changekey(ex_acct,
- itemids[ev_id],events[ev_id])
- if event.is_all_day:
- gevent={}
- gevent["summary"]=event.subject
- gevent["end"]={"date": str(event.end.astimezone(gcal_tz).date())}
- gevent["start"]={"date": str(event.start.astimezone(gcal_tz).date())}
- if event.text_body.strip() != '':
- gevent["description"] = event.text_body
- if event.location is not None:
- gevent["location"] = event.location
- gevent["extended_properties"]={"shared": {"ex_id": event.item_id}}
- gevent=gcal_acct.events().insert(calendarId=gcal_id, body=gevent).execute()
- event.gcal_link = gevent.get("id")
- event.save()
- events[event.uid] = event.changekey
- else:
- logger.warning("only all-day events supported")
-
-def get_gcal_cred():
+ event = get_ex_event_by_itemid(ex_acct.calendar,ev_id)
+ gevent = build_gcal_event_from_ex(event,gcal_tz)
+ if event.type=="RecurringMaster":
+ rr = rrule_from_ex(event,gcal_tz)
+ if rr is not None:
+ gevent["recurrence"] = rr
+ print(gevent)
+ else:
+ logger.warning("Unable to set recurrence for %s" % event.item_id)
+ continue #don't make the gcal event
+ gevent = gcal_acct.events().insert(calendarId=gcal_id,
+ body=gevent).execute()
+ event.gcal_link = gevent.get("id")
+ event.save(update_fields=["gcal_link"])
+ if event.type=="RecurringMaster" and (event.deleted_occurrences or \
+ event.modified_occurrences):
+ modify_recurring(ex_acct,gcal_acct,gcal_tz,
+ events,event,gcal_id)
+ #changekey is updated by the above
+ event.refresh()
+ events[event.item_id] = events[event.item_id]._replace(changekey=event.changekey,gcal_link=event.gcal_link)
+
+def del_ex_to_gcal(ex_acct, gcal_acct, events, deleted, gcal_id="primary"):
+ for ev_id in deleted:
+ if events[ev_id].gcal_link is not None:
+ gevent = get_gcal_event_by_eventid(gcal_acct,
+ events[ev_id].gcal_link,
+ gcal_id)
+ if gevent["status"] != "cancelled":
+ gcal_acct.events().delete(calendarId=gcal_id,
+ eventId=events[ev_id].gcal_link,
+ sendUpdates="none").execute()
+
+def update_ex_to_gcal(ex_acct,
+ gcal_acct,gcal_tz,
+ events,changed,
+ gcal_id="primary"):
+ for ev_id in changed:
+ event = get_ex_event_by_itemid(ex_acct.calendar,ev_id)
+ if event.gcal_link is None:
+ logger.warning("Cannot apply update where event has no gcal link")
+ continue
+ gevent = build_gcal_event_from_ex(event,gcal_tz)
+ if event.type=="RecurringMaster":
+ rr = rrule_from_ex(event,gcal_tz)
+ if rr is not None:
+ gevent["recurrence"] = rr
+ if event.deleted_occurrences or \
+ event.modified_occurrences:
+ modify_recurring(ex_acct,gcal_acct,gcal_tz,
+ events,event,gcal_id)
+ event.refresh() #changekey is updated by the above
+ events[event.item_id] = events[event.item_id]._replace(changekey=event.changekey,gcal_link=event.gcal_link)
+ else:
+ logger.warning("Unable to set recurrence for %s" % event.item_id)
+ continue #don't make the gcal event
+ try: #may fail if we don't own the event
+ gevent = gcal_acct.events().update(calendarId=gcal_id,
+ eventId=event.gcal_link,
+ body=gevent,
+ sendUpdates="none").execute()
+ except googleapiclient.errors.HttpError as err:
+ if err.resp.status == 403:
+ pass
+
+def match_ex_to_gcal(ex_acct,gcal_acct,gcal_tz,events,gcal_id="primary",ignore_link=True):
+ recur = 0
+ matched = 0
+ skipped = 0
+ toadd = []
+ for ev_id in events:
+ event = get_ex_event_by_itemid(ex_acct.calendar,ev_id)
+ if event.gcal_link is not None and ignore_link is False:
+ skipped += 1
+ continue
+ missing=True
+ matches = gcal_acct.events().list(calendarId=gcal_id,
+ timeMin=event.start.isoformat(),
+ timeMax=event.end.isoformat()).execute()
+ for ge in matches['items']:
+ if ( ge.get("summary") is None and event.subject is None ) or \
+ ( ge.get("summary") is not None and event.subject is not None \
+ and ge['summary'].strip()==event.subject.strip()):
+ logger.info("Matching '%s' starting at %s" % (event.subject,
+ event.start.isoformat()))
+ event.gcal_link = ge['id']
+ event.save(update_fields=["gcal_link"])
+ events[event.item_id] = events[event.item_id]._replace(changekey=event.changekey,gcal_link=event.gcal_link)
+ gevent = {}
+ gevent["start"] = ge["start"]
+ gevent["end"] = ge["end"]
+ gevent["extendedProperties"]={"shared": {"ex_id": event.item_id}}
+ try:
+ gcal_acct.events().update(calendarId=gcal_id,
+ eventId=event.gcal_link,
+ body=gevent,
+ sendUpdates="none").execute()
+ #this may fail if we don't own the event
+ except googleapiclient.errors.HttpError as err:
+ if err.resp.status == 403:
+ pass
+ matched += 1
+ missing = False
+ break
+ if missing == True:
+ toadd.append(ev_id)
+ logger.info("Matched %d events, skipped %d with existing link, and %d recurring ones" % (matched,skipped,recur))
+ return toadd
+
+def get_gcal_cred(args):
#each such file can only store a single credential
storage = oauth2client.file.Storage(gcal_authpath)
gcal_credential = storage.get()
if gcal_credential is None or gcal_credential.invalid:
gcal_credential = oauth2client.tools.run_flow(flow,
storage,
- oauth2client.tools.argparser.parse_args())
+ args)
return gcal_credential
-def gcal_login():
- gcal_credential = get_gcal_cred()
+def gcal_login(args):
+ gcal_credential = get_gcal_cred(args)
# Object to handle http requests; could add proxy details
http = httplib2.Http()
http = gcal_credential.authorize(http)
return exchangelib.EWSTimeZone.timezone(gcal['timeZone'])
def main():
- try:
- with open(cachepath,"rb") as f:
- cache = pickle.load(f)
- except FileNotFoundError:
- cache = None
+ ap=argparse.ArgumentParser(description="Gooswapper calendar sync",
+ parents=[oauth2client.tools.argparser])
+ ap.add_argument("exchuser",help="Exchange user e.g. 'SANGER\mv3'")
+ ap.add_argument("exchemail",
+ help="Exchange calendar email e.g. ISGGroup@sanger.ac.uk")
+ ap.add_argument("-g","--gcalid",help="google Calendar ID")
+ ap.add_argument("-l","--loop",help="keep running indefinitely",
+ action="store_true")
+ args = ap.parse_args()
+ if args.gcalid is None:
+ gcal_id = "primary"
+ else:
+ gcal_id = args.gcalid
- ex_account = ex_login("mv3@sanger.ac.uk")
- current,itemids = get_ex_events(ex_account.calendar)
+ #Make our config dir if it doesn't exist
+ if not os.path.exists(gsdir):
+ os.mkdir(gsdir,0o700)
+ #Cache file is specific to the Exchange calendar
+ global cachepath
+ cachepath = gsdir + "/.cache-%s" % \
+ (args.exchemail.replace('@','_').replace('/','_'))
- gcal_account = gcal_login()
- gcal_tz = get_gcal_timezone(gcal_account)
-
- if cache is not None:
- added,deleted,changed = ex_event_changes(cache,current)
- add_ex_to_gcal(ex_account,gcal_account,gcal_tz,current,
- itemids,added)
+ #log in to the accounts
+ ex_account = ex_login(args.exchuser,args.exchemail,
+ gsdir+"/.gooswapper_exch_conf.dat")
+ gcal_account = gcal_login(args)
+ gcal_tz = get_gcal_timezone(gcal_account,gcal_id)
+
+ #Main loop (broken at the end if loop is false)
+ while True:
+ try:
+ with open(cachepath,"rb") as f:
+ cache = pickle.load(f)
+ except FileNotFoundError:
+ cache = None
+
+ current = get_ex_events(ex_account.calendar)
+
+ if cache is not None:
+ added,deleted,changed = ex_event_changes(cache,current)
+ add_ex_to_gcal(ex_account,gcal_account,gcal_tz,current,
+ added,gcal_id)
+ #delete op needs the "cache" set, as that has the link ids in
+ #for events that are now deleted
+ del_ex_to_gcal(ex_account,gcal_account,cache,deleted,gcal_id)
+ update_ex_to_gcal(ex_account,gcal_account,gcal_tz,current,
+ changed,gcal_id)
+ else:
+ toadd = match_ex_to_gcal(ex_account,gcal_account,gcal_tz,current,
+ gcal_id)
+ add_ex_to_gcal(ex_account,gcal_account,gcal_tz,current,
+ toadd,gcal_id)
- with open(cachepath,"wb") as f:
- pickle.dump(current,f)
+ with open(cachepath,"wb") as f:
+ pickle.dump(current,f)
+
+ #If not looping, break here (after 1 run)
+ if args.loop==False:
+ break
+ #otherwise, wait 10 minutes, then go round again
+ time.sleep(600)
if __name__ == "__main__":
main()