chiark / gitweb /
Bugfix: handle events with no gcal_id link
[gooswapper] / gooswapper.py
index f54038cc1e2efa526a7ac31ff134738e4c781456..41dd8075abcd20ba04c6dd44cb74564e1d963751 100644 (file)
@@ -5,6 +5,7 @@ import getpass
 import os
 import pickle
 import collections
+import argparse
 import logging
 logger = logging.getLogger('gooswapper')
 logger.setLevel(logging.INFO)
@@ -74,7 +75,13 @@ 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(),
@@ -90,11 +97,11 @@ 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,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:
@@ -153,6 +160,15 @@ def ex_event_changes(old,new):
 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")
@@ -169,18 +185,38 @@ def rrule_from_ex(event,gcal_tz):
                           (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,
@@ -190,7 +226,7 @@ def modify_recurring(ex_acct,gcal_acct,gcal_tz,
             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 events
+                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,
@@ -199,24 +235,18 @@ def modify_recurring(ex_acct,gcal_acct,gcal_tz,
                                                sendUpdates="none").execute()
             mod_event.gcal_link = gevent.get("id")
             mod_event.save(update_fields=["gcal_link"])
-            if mod_event.item_id in events:
-                events[mod_event.item_id] = events[mod_event.item_id]._replace(changekey=mod_event.changekey,gcal_link=mod_event.gcal_link)
-            else:
-                events[mod_event.item_id] = CachedExEvent(mod_event.changekey,
-                                                      mod_event.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 events
+                return
             if instance["status"] != "cancelled":
                 instance["status"]="cancelled"
                 gcal_acct.events().update(calendarId=gcal_id,
                                           eventId=instance.get('id'),
                                           body=instance,
                                           sendUpdates="none").execute()
-    return events
 
 def build_gcal_event_from_ex(event,gcal_tz):
     gevent={}
@@ -255,18 +285,24 @@ def add_ex_to_gcal(ex_acct,
                                            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)
         if event.type=="RecurringMaster" and (event.deleted_occurrences or \
                                               event.modified_occurrences):
-            events = modify_recurring(ex_acct,gcal_acct,gcal_tz,
-                                      events,event,gcal_id)
+            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,
@@ -274,32 +310,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']
@@ -319,10 +373,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()
@@ -332,11 +390,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)
@@ -347,27 +405,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)