X-Git-Url: http://www.chiark.greenend.org.uk/ucgi/~matthewv/git?p=gooswapper;a=blobdiff_plain;f=gooswapper.py;h=a9da3680a7b86c095193aa641ef4bb87129d3ecc;hp=a4029f0971779d625a847e7338f31d363cea1c8e;hb=9b499a6a0e82d88812fd184aa52d3ba5647f83bd;hpb=34452e21069ff73bd7ad22f94f7adfff74d881bb diff --git a/gooswapper.py b/gooswapper.py index a4029f0..a9da368 100644 --- a/gooswapper.py +++ b/gooswapper.py @@ -1,10 +1,30 @@ #!/usr/bin/env python3 +# Copyright (C) 2018 Genome Research Limited +# +# Author: Matthew Vernon +# +# 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 . + 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) @@ -39,10 +59,10 @@ flow = oauth2client.client.OAuth2WebServerFlow(gcal_client_id, 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 @@ -54,7 +74,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 +94,38 @@ 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) -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 +178,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") @@ -135,31 +203,102 @@ 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, + 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(gcal_tz).date())} - gevent["start"]={"date": str(event.start.astimezone(gcal_tz).date())} + 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(gcal_tz).isoformat(), - "timeZone": str(gcal_tz)} - gevent["start"]={"dateTime": event.start.astimezone(gcal_tz).isoformat(), - "timeZone": str(gcal_tz)} + 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: @@ -180,19 +319,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, @@ -200,32 +350,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'] @@ -245,10 +413,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() @@ -258,11 +430,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) @@ -273,30 +445,67 @@ 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 - - ex_account = ex_login("mv3@sanger.ac.uk",".gooswapper_exch_conf.dat") - current = get_ex_events(ex_account.calendar) - - 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) + 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: - match_ex_to_gcal(ex_account,gcal_account,gcal_tz,current) + gcal_id = args.gcalid + + #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('/','_')) + + #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 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()