chiark / gitweb /
Use start timezone for finding recurrences, and date only for all-days
[gooswapper] / gooswapper.py
index 0a8a050e9c0ca57741f188789d4ae7c7feb024ae..a9da3680a7b86c095193aa641ef4bb87129d3ecc 100644 (file)
@@ -1,9 +1,30 @@
 #!/usr/bin/env python3
 
+# Copyright (C) 2018 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)
@@ -25,6 +46,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'
@@ -37,30 +59,33 @@ 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
 
+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'
-
-exchangelib.CalendarItem.register('global_object_id', GlobalObjectId)
+try:
+    exchangelib.CalendarItem.get_field_by_fieldname('gcal_link')
+except ValueError:
+    exchangelib.CalendarItem.register('gcal_link',ex_gcal_link)
 
-def get_ex_event_by_uid(calendar,uid):
-    return calendar.get(global_object_id=GlobalObjectId(exchangelib.UID(uid)))
+#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)
@@ -69,28 +94,72 @@ 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,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={}
     for event in calendar.all().only('changekey','item_id','gcal_link'):
-        if event.gcal_link is not None:
-#            event.delete()
-            continue
         if event.item_id in ans:
             logger.warning("Event item_id %s was duplicated!" % event.item_id)
-        ans[event.item_id] = event.changekey
+        ans[event.item_id] = CachedExEvent(event.changekey,event.gcal_link)
     logger.info("%d events found" % len(ans))
     return ans
 
@@ -102,20 +171,140 @@ 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()
+#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,
@@ -123,24 +312,115 @@ 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 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.item_id] = event.changekey
-        else:
-            logger.warning("only all-day events supported")
-            
-def get_gcal_cred():
+        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()
@@ -150,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)
@@ -165,24 +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
+    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 = 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,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 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()