view multimerge.py @ 25:d32e4d4ef163

Add calendar data in events.
author Matti Hamalainen <ccr@tnsp.org>
date Mon, 04 Jul 2016 15:19:53 +0300
parents 1bce0c6a673c
children 1267d61f6224
line wrap: on
line source

#!/usr/bin/python
# coding=utf-8
###
### Google Calendar MultiMerge v0.000001
### (C) 2016 Matti 'ccr' Hamalainen <ccr@tnsp.org>
###
### Python 2.7 <= x < 3 required! Please refer to
### README.txt for information on other depencies.
###
import os
import sys
import signal
import re
import time
import datetime

import smtplib
from email.mime.text import MIMEText

import httplib2
import ConfigParser

import oauth2client
from oauth2client import client
from oauth2client import tools
from oauth2client import file
from googleapiclient import discovery


###
### Misc. helper functions
###

## Wrapper for print() that does not break when redirecting stdin/out
## because of piped output not having a defined encoding. We default
## to UTF-8 encoding in output here.
def gcm_print(smsg):
    gcm_msgbuf.append(smsg.encode("UTF-8"))
    if sys.stdout.encoding != None:
        print(smsg.encode(sys.stdout.encoding))
    else:
        print(smsg.encode("UTF-8"))


## Fatal errors
def gcm_fatal(smsg):
    gcm_print(u"ERROR: "+ smsg)
    if cfg.email_ok and cfg.email:
        ## If e-mail is set, send e-mail
        msg = MIMEText(("\n".join(gcm_msgbuf)).encode("UTF-8"), "plain")
        msg.set_charset("UTF-8")
        msg["Subject"] = cfg.email_subject
        msg["From"] = cfg.email_sender
        msg["To"] = ",".join(cfg.email_to)
        try:
            smtpH = smtplib.SMTP('localhost')
            smtpH.sendmail(cfg.email_sender, cfg.email_to, msg.as_string())
            smtpH.quit()
        except:
            gcm_print("FATAL: Oh crap, e-mail sending failed.")
    sys.exit(1)


## Debug messages
def gcm_debug(smsg):
    if cfg.debug:
        gcm_print(u"DBG: "+ smsg)
    else:
        gcm_msgbuf.append(u"DBG: "+ smsg.encode("UTF-8"))


## Handler for SIGINT signals
def gcm_signal_handler(signal, frame):
    gcm_print("\nQuitting due to SIGINT / Ctrl+C!")
    sys.exit(0)


## Function for handling Google API credentials
def gcm_get_credentials(mcfg):
    store = oauth2client.file.Storage(mcfg.credential_file)
    credentials = store.get()
    if not credentials or credentials.invalid:
        flow = client.flow_from_clientsecrets(mcfg.secret_file, mcfg.scope)
        flow.user_agent = mcfg.app_name
        credentials = tools.run_flow(flow, store, mcfg)
    if not credentials or credentials.invalid:
        gcm_fatal("Failed to authenticate / invalid credentials.")
    return credentials


def gcm_dump_events(events):
    for event in events:
        ev_start = event["start"].get("dateTime", event["start"].get("date"))
        ev_end = event["end"].get("dateTime", event["end"].get("date"))
        gcm_print(u"{0:25} - {1:25} : {2}".format(ev_start, ev_end, event["summary"]))


##
## Class for handling configuration / settings
##
class GCMSettings(dict):
    def __init__(self):
        self.m_data = {}
        self.m_saveable = {}
        self.m_validate = {}
        self.m_translate = {}

    def __getattr__(self, name):
        if name in self.m_data:
            return self.m_data[name]
        else:
            gcm_fatal("GCMSettings.__getattr__(): No such attribute '"+ name +"'.")

    def mvalidate(self, name, value):
        if name in self.m_validate and self.m_validate[name]:
            if not self.m_validate[name](value):
                gcm_fatal("GCMSettings.mvalidate(): Invalid value for attribute '{0}': {1}".format(name, value))

    def mtranslate(self, name, value):
        if name in self.m_translate and self.m_translate[name]:
            return self.m_translate[name](value)
        else:
            return value

    def mdef(self, name, saveable, validate, translate, value):
        self.mvalidate(name, value)
        self.m_saveable[name] = saveable
        self.m_validate[name] = validate
        self.m_translate[name] = translate
        self.m_data[name] = self.mtranslate(name, value)

    def mset(self, name, value):
        self.mvalidate(name, value)
        if name in self.m_data:
            self.m_data[name] = self.mtranslate(name, value)
        else:
            gcm_fatal("GCMSettings.mset(): No such attribute '"+ name +"'.")

    def mget(self, name):
        if name in self.m_data:
            return self.m_data[name]
        else:
            return None

    def mread(self, cfgparser, sect):
        for name in self.m_saveable:
            if cfgparser.has_option(sect, name):
                value = cfgparser.get(sect, name)
                self.mset(name, value)
                gcm_debug("{0} -> '{1}' == {2}".format(name, value, self.mget(name)))

    def is_str(self, mvalue):
        return isinstance(mvalue, basestring)

    def is_string(self, mvalue):
        return mvalue == None or self.is_str(mvalue)

    def is_log_level(self, mvalue):
        if not self.is_str(mvalue):
            return False
        else:
            return mvalue.upper() in ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"]

    def trans_log_level(self, mvalue):
        return mvalue.upper()

    def is_filename(self, mvalue):
        if not self.is_str(mvalue):
            return False
        else:
            return re.match("^[a-z0-9][a-z0-9\.\_\-]+$", mvalue, flags=re.IGNORECASE)

    def trans_bool(self, mvalue):
        if self.is_str(mvalue):
            if re.match("^\s*(true|1|on|yes)\s*$", mvalue, re.IGNORECASE):
                mvalue = True
            elif re.match("^\s*(false|0|off|no)\s*$", mvalue, re.IGNORECASE):
                mvalue = False
            else:
                return None
        return mvalue

    def is_bool(self, mvalue):
        mval = self.trans_bool(mvalue)
        if not isinstance(mval, bool):
            gcm_fatal("GCMSettings.is_bool(): Invalid boolean value '{0}', should be true|false|1|0|on|off|yes|no.".format(mvalue))
        else:
            return True

    def trans_list(self, mvalue):
        morig = mvalue
        if self.is_str(mvalue):
            mvalue = re.split("\s*,\s*", mvalue, flags=re.IGNORECASE)
            if not isinstance(mvalue, list):
                gcm_fatal("GCMSettings.trans_list(): Could not parse list '{0}'.".format(mvalue))
        elif not isinstance(mvalue, list):
            gcm_fatal("GCMSettings.trans_list(): Invalid value '{0}'.".format(mvalue))
        return mvalue

    def is_list(self, mvalue):
        return self.trans_list(mvalue)

    def is_email(self, mvalue):
        if not self.is_string(mvalue):
            return False
        else:
            return re.match("^.*?\s+<[a-z0-9]+[a-z0-9\.\+\-]*\@[a-z0-9]+[a-z0-9\.\-]+>\s*$|[a-z0-9]+[a-z0-9\.\+\-]*\@[a-z0-9]+[a-z0-9\.\-]+", mvalue, flags=re.IGNORECASE)

    def trans_email_list(self, mvalue):
        if mvalue == None:
            return mvalue
        else:
            return self.trans_list(mvalue.strip())

    def is_email_list(self, mvalue):
        mvalue = self.trans_email_list(mvalue)
        if mvalue != None:
            for email in mvalue:
                if not self.is_email(email):
                    gcm_fatal("Invalid e-mail address '{0}' in list {1}.".format(email, ", ".join(mvalue)))
        return True


###
### Main program starts
###
gcm_msgbuf = []
signal.signal(signal.SIGINT, gcm_signal_handler)


## Settings
cfg = GCMSettings()

cfg.mdef("debug", True, cfg.is_bool, cfg.trans_bool, False)

cfg.mdef("email_ok", False, None, None, False)
cfg.mdef("email", True, cfg.is_bool, cfg.trans_bool, False)
cfg.mdef("email_to", True, cfg.is_email_list, cfg.trans_email_list, None)
cfg.mdef("email_sender", True, cfg.is_email, None, None)
cfg.mdef("email_subject", True, cfg.is_string, None, "Google Calendar MultiMerge status")

cfg.mdef("source_regex", True, cfg.is_string, None, "^R:\s*(.*?)\s*\(\s*(.+?)\s*\)\s*$")
cfg.mdef("source_regmap", False, cfg.is_list, cfg.trans_list, [1, 2])
cfg.mdef("source_regmap_len", False, None, None, len(cfg.source_regmap))

cfg.mdef("dest_name", True, cfg.is_string, None, u"Raahen kansainvälisyystoiminta")
cfg.mdef("dest_id", True, cfg.is_string, None, None)

cfg.mdef("noauth_local_webserver", False, None, None, True)
#cfg.mdef("auth_host_name", False, None, None, "localhost")
#cfg.mdef("auth_host_port", False, None, None, [8080, 8090])
cfg.mdef("logging_level", True, cfg.is_log_level, cfg.trans_log_level, "ERROR")

# No need to touch these
cfg.mdef("app_name", False, None, None, "Google Calendar MultiMerge")
cfg.mdef("scope", False, None, None, "https://www.googleapis.com/auth/calendar")
#cfg.mdef("scope", False, None, None, "https://www.googleapis.com/auth/calendar.readonly")
cfg.mdef("secret_file", True, cfg.is_filename, None, "client_secret.json")
cfg.mdef("credential_file", True, cfg.is_filename, None, "client_credentials.json")


## Read, parse and validate configuration file
if len(sys.argv) > 1:
    gcm_debug("Reading configuration from '{0}'.".format(sys.argv[1]))
    try:
        cfgparser = ConfigParser.RawConfigParser()
        cfgparser.read(sys.argv[1])
    except Exception as e:
        gcm_fatal("Failed to read configuration file '{0}': {1}".format(sys.argv[1], str(e)))

    # Check that the required section exists
    section = "gcm"
    if not cfgparser.has_section(section):
        gcm_fatal("Invalid configuration, missing '{0}' section.".format(section))

    # Debug setting is a special case, we need to get it
    # set before everything else, so do it here ..
    if cfgparser.has_option(section, "debug"):
        cfg.mset("debug", cfgparser.get(section, "debug"))

    # Parse the settings and validate
    cfg.mread(cfgparser, section)


## Validate settings
if cfg.email:
    if cfg.email_subject == None or len(cfg.email_subject) == 0:
        gcm_fatal("E-mail enabled but email_subject not set.")
    elif cfg.email_sender == None:
        gcm_fatal("E-mail enabled but email_sender not set.")
    elif cfg.email_to == None:
        gcm_fatal("E-mail enabled but email_to not set.")
    else:
        cfg.mset("email_ok", True)


if len(cfg.source_regmap) != cfg.source_regmap_len:
    gcm_fatal("Setting source_regmap list must be {0} items.".format(cfg.source_regmap_len))
else:
    # Force to integers
    try:
        cfg.source_regmap = map(lambda x: int(x), cfg.source_regmap)
    except Exception as e:
        gcm_fatal("Invalid source_regmap: {0}".format(str(e)))


if not cfg.dest_name and not cfg.dest_id:
    gcm_fatal("Target calendar ID or name required, but not set.")


if cfg.dest_name:
    cfg.mset("dest_name", cfg.mget("dest_name").strip())


## Initialize and authorize API connection
credentials = gcm_get_credentials(cfg)
http = credentials.authorize(httplib2.Http())
service = discovery.build("calendar", "v3", http=http)


## Fetch complete calendar list
gcm_debug("Fetching available calendars ..")
calendars = []
cal_token = None
while True:
    # We want everything except deleted and hidden calendars
    result = service.calendarList().list(
        showHidden=False,
        showDeleted=False,
        pageToken=cal_token
        ).execute()

    calendars.extend(result.get("items", []))
    cal_token = result.get("nextPageToken")
    if not cal_token:
        break

if len(calendars) == 0:
    gcm_fatal("No calendars found?")


## Filter desired SOURCE calendars based on specified regexp
src_re = re.compile(cfg.source_regex)
src_calendars = []
for calendar in calendars:
    if "summary" in calendar:
        if not cfg.dest_id and cfg.dest_name == calendar["summary"].strip():
            cfg.mset("dest_id", calendar["id"])

        mre = src_re.match(calendar["summary"])
        if mre:
            calendar["gcm_title"] = mre.group(cfg.source_regmap[0])
            calendar["gcm_id"] = mre.group(cfg.source_regmap[1])
            src_calendars.append(calendar)


## Check if we have target ID
if not cfg.dest_id:
    gcm_fatal(u"Could not find target/destination calendar ID for '"+ cfg.dest_name +"'.")


## Now, we fetch and collect events
gcm_debug(u"Fetching calendar events .. ")
src_events = []
for calendar in src_calendars:
    gcm_debug("- "+calendar["id"])
    result = service.events().list(
        timeZone="EEST",
        calendarId=calendar["id"],
        singleEvents=True,
        showDeleted=False,
#        orderBy="startTime",
        ).execute()

    # Add events, if any, to main list
    events = result.get("items", [])
    if events:
        # Add calendar data to events
        for ev in events:
            ev["cal_id"] = calendar["id"]
            ev["gcm_id"] = "{0}_{1}".format(calendar["id"], ev["id"])

        src_events.extend(events)

        if cfg.debug:
            gcm_dump_events(events)


## Get current events
gcm_debug(u"Fetching current target calendar events {0}".format(cfg.dest_id))
result = service.events().list(
    calendarId=cfg.dest_id,
    singleEvents=True,
    showDeleted=True).execute()

dst_events = result.get("items", [])
if dst_events:
    gcm_debug(u"Found {0} event(s).".format(len(dst_events)))
else:
    gcm_debug(u"No current events.")