chiark / gitweb /
Use a per-exchange-calendar cache file
[gooswapper] / gooswapper.py
index 89ec41e882609ac4c4025bfbb96e3e79714e1862..24b7a2fd229c59fc400ee7b6852557f79ce4e95e 100644 (file)
@@ -1,10 +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)
@@ -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
 
@@ -78,6 +98,9 @@ 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(),
@@ -93,11 +116,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:
@@ -306,6 +329,9 @@ 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 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)
@@ -320,29 +346,33 @@ def update_ex_to_gcal(ex_acct,
             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,
+        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.is_recurring:
-#            recur += 1
-#            continue
-#next line needs to be elif if above uncommented
         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']
@@ -362,10 +392,14 @@ def match_ex_to_gcal(ex_acct,gcal_acct,gcal_tz,events,gcal_id="primary",ignore_l
                     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()
@@ -375,11 +409,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)
@@ -390,30 +424,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()