X-Git-Url: http://www.chiark.greenend.org.uk/ucgi/~matthewv/git?a=blobdiff_plain;f=gooswapper.py;h=1742b558624120b2972a6b1285b4e34d4edae899;hb=7d840c555b66ef989628b5a23bec51a62fd60133;hp=b14a6954d5865547df89141990ccc921da4edf79;hpb=6443975aaa3facb98cae1e65914aa2eb52d8cc11;p=gooswapper diff --git a/gooswapper.py b/gooswapper.py index b14a695..1742b55 100644 --- a/gooswapper.py +++ b/gooswapper.py @@ -5,6 +5,8 @@ import getpass import os import pickle import collections +import argparse +import time import logging logger = logging.getLogger('gooswapper') logger.setLevel(logging.INFO) @@ -54,7 +56,10 @@ class ex_gcal_link(exchangelib.ExtendedProperty): property_name = "google calendar event id" property_type = 'String' -exchangelib.CalendarItem.register('gcal_link',ex_gcal_link) +try: + exchangelib.CalendarItem.get_field_by_fieldname('gcal_link') +except ValueError: + exchangelib.CalendarItem.register('gcal_link',ex_gcal_link) #useful if you want to replay an event def drop_from_ex_cache(itemid): @@ -71,16 +76,33 @@ 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,gcal_id="primary"): + if gcal_master is None: + logger.warning("Cannot get recurrences from event with null gcal id") + return None + ans = gcal_acct.events().instances(calendarId=gcal_id, + eventId=gcal_master, + originalStart=start.isoformat(), + 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) -def ex_login(emailaddr,ad_cache_path=None): +def ex_login(username,emailaddr,ad_cache_path=None): global exchange_credential autodiscover = True if exchange_credential is None: - exchange_credential = get_ex_cred() + exchange_credential = get_ex_cred(username) if ad_cache_path is not None: try: with open(ad_cache_path,"rb") as f: @@ -133,6 +155,21 @@ def ex_event_changes(old,new): len(deleted))) return added, deleted, changed +#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") @@ -143,20 +180,75 @@ def rrule_from_ex(event,gcal_tz): 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 - if event.modified_occurrences is not None or \ - event.deleted_occurrences is not None: - logger.warning("Modified/Deleted recurrences not supported") 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,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,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 @@ -188,19 +280,30 @@ def add_ex_to_gcal(ex_acct, gevent["recurrence"] = rr print(gevent) else: - logger.warning("Unable to set recurrence") + 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: - gcal_acct.events().delete(calendarId=gcal_id, - eventId=events[ev_id].gcal_link, - sendUpdates="none").execute() + 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, @@ -208,32 +311,50 @@ def update_ex_to_gcal(ex_acct, gcal_id="primary"): for ev_id in changed: event = get_ex_event_by_itemid(ex_acct.calendar,ev_id) - if not event.is_recurring: - gevent = build_gcal_event_from_ex(event,gcal_tz) + 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() - else: - logger.warning("recurring events not yet supported") + 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"): +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.is_recurring: - recur += 1 - continue - elif event.gcal_link is not None: + 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['summary'].strip()==event.subject.strip(): + 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'] @@ -253,10 +374,14 @@ def match_ex_to_gcal(ex_acct,gcal_acct,gcal_tz,events,gcal_id="primary"): 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(): +def get_gcal_cred(args): #each such file can only store a single credential storage = oauth2client.file.Storage(gcal_authpath) gcal_credential = storage.get() @@ -266,11 +391,11 @@ def get_gcal_cred(): 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) @@ -281,30 +406,59 @@ def get_gcal_timezone(gcal_account,calendarid="primary"): 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",".gooswapper_exch_conf.dat") - current = get_ex_events(ex_account.calendar) + #log in to the accounts + ex_account = ex_login(args.exchuser,args.exchemail, + ".gooswapper_exch_conf.dat") + gcal_account = gcal_login(args) + gcal_tz = get_gcal_timezone(gcal_account,gcal_id) - 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,added) - #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) - update_ex_to_gcal(ex_account,gcal_account,gcal_tz,current,changed) - else: - match_ex_to_gcal(ex_account,gcal_account,gcal_tz,current) + #Main loop (broken at the end if login 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()