X-Git-Url: http://www.chiark.greenend.org.uk/ucgi/~matthewv/git?a=blobdiff_plain;f=gooswapper.py;h=406b6f8ce57105a0abcfacb75c70398fac24f235;hb=67e904e86a4cf72320f5c67c1d45a8dd9a3c78fe;hp=692c3525fcae9be3f3d935ee5fc0e51581a9ba7f;hpb=6530ef3a6217da7bf10980b0a506163436868e8f;p=gooswapper diff --git a/gooswapper.py b/gooswapper.py index 692c352..406b6f8 100644 --- a/gooswapper.py +++ b/gooswapper.py @@ -5,6 +5,7 @@ import getpass import os import pickle import collections +import argparse import logging logger = logging.getLogger('gooswapper') logger.setLevel(logging.INFO) @@ -54,7 +55,18 @@ 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): + 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_itemid(calendar,itemid): return calendar.get(item_id=itemid) @@ -63,16 +75,30 @@ 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"): + 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: @@ -125,6 +151,100 @@ 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") + 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,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 @@ -132,11 +252,11 @@ def build_gcal_event_from_ex(event,gcal_tz): gevent["end"]={"date": str(event.end.astimezone(gcal_tz).date())} gevent["start"]={"date": str(event.start.astimezone(gcal_tz).date())} else: - gevent["end"]={"dateTime": event.end.isoformat(), - "timeZone": event.end.tzname()} - gevent["start"]={"dateTime": event.start.isoformat(), - "timeZone": event.start.tzname()} - if event.text_body.strip() != '': + gevent["end"]={"dateTime": event.end.astimezone(gcal_tz).isoformat(), + "timeZone": str(gcal_tz)} + gevent["start"]={"dateTime": event.start.astimezone(gcal_tz).isoformat(), + "timeZone": str(gcal_tz)} + 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 @@ -149,22 +269,37 @@ def add_ex_to_gcal(ex_acct, gcal_id="primary"): for ev_id in added: 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) - gevent = gcal_acct.events().insert(calendarId=gcal_id, - body=gevent).execute() - event.gcal_link = gevent.get("id") - event.save() - events[event.item_id] = events[event.item_id]._replace(changekey=event.changekey,gcal_link=event.gcal_link) - else: - logger.warning("recurring events not yet supported") - + 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: - 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, @@ -172,32 +307,43 @@ 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) - gevent = gcal_acct.events().update(calendarId=gcal_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 + 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 + 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") -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'] @@ -217,10 +363,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() @@ -230,11 +380,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) @@ -245,27 +395,44 @@ def get_gcal_timezone(gcal_account,calendarid="primary"): return exchangelib.EWSTimeZone.timezone(gcal['timeZone']) def main(): + 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 try: with open(cachepath,"rb") as f: cache = pickle.load(f) except FileNotFoundError: cache = None - ex_account = ex_login("mv3@sanger.ac.uk",".gooswapper_exch_conf.dat") + ex_account = ex_login(args.exchuser,args.exchemail, + ".gooswapper_exch_conf.dat") current = get_ex_events(ex_account.calendar) - gcal_account = gcal_login() - gcal_tz = get_gcal_timezone(gcal_account) + gcal_account = gcal_login(args) + gcal_tz = get_gcal_timezone(gcal_account,gcal_id) 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) + 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) - update_ex_to_gcal(ex_account,gcal_account,gcal_tz,current,changed) + 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: - match_ex_to_gcal(ex_account,gcal_account,gcal_tz,current) + 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)