X-Git-Url: http://www.chiark.greenend.org.uk/ucgi/~matthewv/git?a=blobdiff_plain;f=gooswapper.py;h=75edd3febb2c54bcd77979cfadd513b4268f169e;hb=3ab26f0d9fff9530325a1c227f4f2e8da81b6f58;hp=2c32deb1e1b0d32d54fc884f348d0d52a9e88b5d;hpb=11372bccdd7496cd1d3bf4dffde3d3d58ae17fd0;p=gooswapper diff --git a/gooswapper.py b/gooswapper.py index 2c32deb..75edd3f 100644 --- a/gooswapper.py +++ b/gooswapper.py @@ -4,6 +4,7 @@ import sys import getpass import os import pickle +import collections import logging logger = logging.getLogger('gooswapper') logger.setLevel(logging.INFO) @@ -25,6 +26,7 @@ import apiclient.discovery import oauth2client import oauth2client.file import oauth2client.client +import googleapiclient.errors #Not sure what the distribution approach here is... gcal_client_id = '805127902516-ptbbtgpq9o8pjr6r3k6hsm60j589o85u.apps.googleusercontent.com' @@ -44,23 +46,29 @@ cachepath=".gooswapcache" exchange_credential = None +CachedExEvent=collections.namedtuple('CachedExEvent', + ['changekey','gcal_link']) + class ex_gcal_link(exchangelib.ExtendedProperty): distinguished_property_set_id = 'PublicStrings' 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)])) @@ -71,27 +79,47 @@ def get_ex_cred(username="SANGER\mv3",password=None): password = getpass.getpass(prompt="Password for user %s: " % username) return exchangelib.ServiceAccount(username,password) -def ex_login(emailaddr,autodiscover=True): +def ex_login(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) + 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()) @@ -101,45 +129,137 @@ def ex_event_changes(old,new): 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() +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 + 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) + 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 build_gcal_event_from_ex(event,gcal_tz): + gevent={} + gevent["summary"]=event.subject + 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())} + 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)} + 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 + 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"]) + 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() + +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 not event.is_recurring: + gevent = build_gcal_event_from_ex(event,gcal_tz) + gevent = gcal_acct.events().update(calendarId=gcal_id, + eventId=event.gcal_link, + body=gevent, + sendUpdates="none").execute() else: - logger.warning("only all-day events supported") - + logger.warning("recurring events not yet supported") + +def match_ex_to_gcal(ex_acct,gcal_acct,gcal_tz,events,gcal_id="primary"): + recur = 0 + matched = 0 + skipped = 0 + 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: + skipped += 1 + continue + 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(): + 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 + break + logger.info("Matched %d events, skipped %d with existing link, and %d recurring ones" % (matched,skipped,recur)) + def get_gcal_cred(): #each such file can only store a single credential storage = oauth2client.file.Storage(gcal_authpath) @@ -171,16 +291,21 @@ def main(): except FileNotFoundError: cache = None - ex_account = ex_login("mv3@sanger.ac.uk") - current,itemids = get_ex_events(ex_account.calendar) + 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, - itemids,added) + 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) with open(cachepath,"wb") as f: pickle.dump(current,f)