diff --git a/AUTHORS.rst b/AUTHORS.rst index 8829a34..3afd6a5 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -11,6 +11,7 @@ The application was inspired by and uses ideas from Analytical_, Joshua Krall's all-purpose analytics front-end for Rails. The work on Crazy Egg was made possible by `Bateau Knowledge`_. +The work on Intercom was made possible by `GreenKahuna`_. .. _`Joost Cassee`: mailto:joost@cassee.net .. _`Eric Davis`: https://github.com/edavis @@ -24,5 +25,6 @@ The work on Crazy Egg was made possible by `Bateau Knowledge`_. .. _`Philippe O. Wagner`: mailto:admin@arteria.ch .. _`Max Arnold`: https://github.com/max-arnold .. _`Martín Gaitán`: https://github.com/mgaitan -.. _Analytical: https://github.com/jkrall/analytical +.. _`Analytical`: https://github.com/jkrall/analytical .. _`Bateau Knowledge`: http://www.bateauknowledge.nl/ +.. _`GreenKahuna`: http://www.greenkahuna.com/ diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 98283dd..eb332ca 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,7 @@ Version 0.17.0 -------------- * Update UserVoice support (Martín Gaitán) +* Add support for Intercom.io (Steven Skoczen) Version 0.16.0 -------------- diff --git a/README.rst b/README.rst index 6f7fa1f..a690b02 100644 --- a/README.rst +++ b/README.rst @@ -27,6 +27,7 @@ Currently supported services: * `Google Analytics`_ traffic analysis * `GoSquared`_ traffic monitoring * `HubSpot`_ inbound marketing +* `Intercom`_ live chat and support * `KISSinsights`_ feedback surveys * `KISSmetrics`_ funnel analysis * `Mixpanel`_ event tracking @@ -59,6 +60,7 @@ an issue to discuss your plans. .. _`Google Analytics`: http://www.google.com/analytics/ .. _`GoSquared`: http://www.gosquared.com/ .. _`HubSpot`: http://www.hubspot.com/ +.. _`Intercom`: http://www.intercom.io/ .. _`KISSinsights`: http://www.kissinsights.com/ .. _`KISSmetrics`: http://www.kissmetrics.com/ .. _`Mixpanel`: http://www.mixpanel.com/ diff --git a/analytical/__init__.py b/analytical/__init__.py index 07cae5d..3faa945 100644 --- a/analytical/__init__.py +++ b/analytical/__init__.py @@ -10,6 +10,6 @@ Django_ project. See the ``docs`` directory for more information. __author__ = "Joost Cassee" __email__ = "joost@cassee.net" -__version__ = "0.16.0" +__version__ = "0.17.0" __copyright__ = "Copyright (C) 2011-2012 Joost Cassee and others" __license__ = "MIT License" diff --git a/analytical/templatetags/analytical.py b/analytical/templatetags/analytical.py index 51ee382..d0cb1fc 100644 --- a/analytical/templatetags/analytical.py +++ b/analytical/templatetags/analytical.py @@ -23,6 +23,7 @@ TAG_MODULES = [ 'analytical.google_analytics', 'analytical.gosquared', 'analytical.hubspot', + 'analytical.intercom', 'analytical.kiss_insights', 'analytical.kiss_metrics', 'analytical.mixpanel', diff --git a/analytical/templatetags/chartbeat.py b/analytical/templatetags/chartbeat.py index a9baf1b..26d2c7d 100644 --- a/analytical/templatetags/chartbeat.py +++ b/analytical/templatetags/chartbeat.py @@ -86,7 +86,7 @@ class ChartbeatBottomNode(Node): domain = _get_domain(context) if domain is not None: config['domain'] = domain - html = SETUP_CODE % {'config': json.dumps(config)} + html = SETUP_CODE % {'config': json.dumps(config, sort_keys=True)} if is_internal_ip(context, 'CHARTBEAT'): html = disable_html(html, 'Chartbeat') return html diff --git a/analytical/templatetags/clicky.py b/analytical/templatetags/clicky.py index ce150eb..a2bf1c3 100644 --- a/analytical/templatetags/clicky.py +++ b/analytical/templatetags/clicky.py @@ -66,7 +66,7 @@ class ClickyNode(Node): custom.setdefault('session', {})['username'] = identity html = TRACKING_CODE % {'site_id': self.site_id, - 'custom': json.dumps(custom)} + 'custom': json.dumps(custom, sort_keys=True)} if is_internal_ip(context, 'CLICKY'): html = disable_html(html, 'Clicky') return html diff --git a/analytical/templatetags/intercom.py b/analytical/templatetags/intercom.py new file mode 100644 index 0000000..aa83828 --- /dev/null +++ b/analytical/templatetags/intercom.py @@ -0,0 +1,88 @@ +""" +intercom.io template tags and filters. +""" + +from __future__ import absolute_import +import json +import time +import re + +from django.template import Library, Node, TemplateSyntaxError + +from analytical.utils import disable_html, get_required_setting, is_internal_ip,\ + get_user_from_context, get_identity + +APP_ID_RE = re.compile(r'[\da-f]+$') +TRACKING_CODE = """ + + +""" + +register = Library() + + +@register.tag +def intercom(parser, token): + """ + Intercom.io template tag. + + Renders Javascript code to intercom.io testing. You must supply + your APP ID account number in the ``INTERCOM_APP_ID`` + setting. + """ + bits = token.split_contents() + if len(bits) > 1: + raise TemplateSyntaxError("'%s' takes no arguments" % bits[0]) + return IntercomNode() + + +class IntercomNode(Node): + def __init__(self): + self.app_id = get_required_setting( + 'INTERCOM_APP_ID', APP_ID_RE, + "must be a string looking like 'XXXXXXX'") + + def _identify(self, user): + name = user.get_full_name() + if not name: + name = user.username + return name + + def _get_custom_attrs(self, context): + vars = {} + for dict_ in context: + for var, val in dict_.items(): + if var.startswith('intercom_'): + vars[var[9:]] = val + + user = get_user_from_context(context) + if user is not None and user.is_authenticated(): + if 'full_name' not in vars: + vars['full_name'] = get_identity(context, 'intercom', self._identify, user) + if 'email' not in vars and user.email: + vars['email'] = user.email + + vars['created_at'] = int(time.mktime(user.date_joined.timetuple())) + else: + vars['created_at'] = None + + return vars + + def render(self, context): + html = "" + user = get_user_from_context(context) + vars = self._get_custom_attrs(context) + vars["app_id"] = self.app_id + html = TRACKING_CODE % {"settings_json": json.dumps(vars, sort_keys=True)} + + if is_internal_ip(context, 'INTERCOM') or not user or not user.is_authenticated(): + # Intercom is disabled for non-logged in users. + html = disable_html(html, 'Intercom') + return html + + +def contribute_to_analytical(add_node): + IntercomNode() + add_node('body_bottom', IntercomNode) diff --git a/analytical/templatetags/kiss_metrics.py b/analytical/templatetags/kiss_metrics.py index 8f234ac..d53d11c 100644 --- a/analytical/templatetags/kiss_metrics.py +++ b/analytical/templatetags/kiss_metrics.py @@ -78,13 +78,13 @@ class KissMetricsNode(Node): try: name, properties = context[EVENT_CONTEXT_KEY] commands.append(EVENT_CODE % {'name': name, - 'properties': json.dumps(properties)}) + 'properties': json.dumps(properties, sort_keys=True)}) except KeyError: pass try: properties = context[PROPERTY_CONTEXT_KEY] commands.append(PROPERTY_CODE % { - 'properties': json.dumps(properties)}) + 'properties': json.dumps(properties, sort_keys=True)}) except KeyError: pass html = TRACKING_CODE % {'api_key': self.api_key, diff --git a/analytical/templatetags/mixpanel.py b/analytical/templatetags/mixpanel.py index 5d7d532..fff984b 100644 --- a/analytical/templatetags/mixpanel.py +++ b/analytical/templatetags/mixpanel.py @@ -56,7 +56,7 @@ class MixpanelNode(Node): try: name, properties = context[EVENT_CONTEXT_KEY] commands.append(EVENT_CODE % {'name': name, - 'properties': json.dumps(properties)}) + 'properties': json.dumps(properties, sort_keys=True)}) except KeyError: pass html = TRACKING_CODE % {'token': self.token, diff --git a/analytical/templatetags/olark.py b/analytical/templatetags/olark.py index c9e9232..a42ae8c 100644 --- a/analytical/templatetags/olark.py +++ b/analytical/templatetags/olark.py @@ -64,7 +64,7 @@ class OlarkNode(Node): extra_code.append(NICKNAME_CODE % identity) try: extra_code.append(STATUS_CODE % - json.dumps(context[STATUS_CONTEXT_KEY])) + json.dumps(context[STATUS_CONTEXT_KEY], sort_keys=True)) except KeyError: pass extra_code.extend(self._get_configuration(context)) diff --git a/analytical/templatetags/reinvigorate.py b/analytical/templatetags/reinvigorate.py index aeda778..dd16c95 100644 --- a/analytical/templatetags/reinvigorate.py +++ b/analytical/templatetags/reinvigorate.py @@ -65,7 +65,7 @@ class ReinvigorateNode(Node): email = get_identity(context, 'reinvigorate', lambda u: u.email) if email is not None: re_vars['context'] = email - tags = " ".join("var re_%s_tag = %s;" % (tag, json.dumps(value)) + tags = " ".join("var re_%s_tag = %s;" % (tag, json.dumps(value, sort_keys=True)) for tag, value in re_vars.items()) html = TRACKING_CODE % {'tracking_id': self.tracking_id, diff --git a/analytical/templatetags/uservoice.py b/analytical/templatetags/uservoice.py index b4c74ef..7970ee7 100644 --- a/analytical/templatetags/uservoice.py +++ b/analytical/templatetags/uservoice.py @@ -66,7 +66,7 @@ class UserVoiceNode(Node): getattr(settings, 'USERVOICE_ADD_TRIGGER', True)) html = TRACKING_CODE % {'widget_key': widget_key, - 'options': json.dumps(options), + 'options': json.dumps(options, sort_keys=True), 'trigger': TRIGGER if trigger else ''} return html diff --git a/analytical/templatetags/woopra.py b/analytical/templatetags/woopra.py index d70ed5b..8018e78 100644 --- a/analytical/templatetags/woopra.py +++ b/analytical/templatetags/woopra.py @@ -57,8 +57,8 @@ class WoopraNode(Node): visitor = self._get_visitor(context) html = TRACKING_CODE % { - 'settings': json.dumps(settings), - 'visitor': json.dumps(visitor), + 'settings': json.dumps(settings, sort_keys=True), + 'visitor': json.dumps(visitor, sort_keys=True), } if is_internal_ip(context, 'WOOPRA'): html = disable_html(html, 'Woopra') diff --git a/analytical/tests/__init__.py b/analytical/tests/__init__.py index 634d29d..13f7b5f 100644 --- a/analytical/tests/__init__.py +++ b/analytical/tests/__init__.py @@ -11,6 +11,7 @@ from analytical.tests.test_tag_gauges import * from analytical.tests.test_tag_google_analytics import * from analytical.tests.test_tag_gosquared import * from analytical.tests.test_tag_hubspot import * +from analytical.tests.test_tag_intercom import * from analytical.tests.test_tag_kiss_insights import * from analytical.tests.test_tag_kiss_metrics import * from analytical.tests.test_tag_mixpanel import * diff --git a/analytical/tests/test_tag_intercom.py b/analytical/tests/test_tag_intercom.py new file mode 100644 index 0000000..edd69ba --- /dev/null +++ b/analytical/tests/test_tag_intercom.py @@ -0,0 +1,94 @@ +""" +Tests for the intercom template tags and filters. +""" + +import datetime + +from django.contrib.auth.models import User, AnonymousUser +from django.http import HttpRequest +from django.template import Context + +from analytical.templatetags.intercom import IntercomNode +from analytical.tests.utils import TagTestCase, override_settings, SETTING_DELETED +from analytical.utils import AnalyticalException + + +@override_settings(INTERCOM_APP_ID='1234567890abcdef0123456789') +class IntercomTagTestCase(TagTestCase): + """ + Tests for the ``intercom`` template tag. + """ + + def test_tag(self): + rendered_tag = self.render_tag('intercom', 'intercom') + self.assertTrue(rendered_tag.startswith(' + {% intercom %} + + + ... + + +.. _intercom-configuration: + +Configuration +============= + +Before you can use the Intercom.io integration, you must first set your +app id. + + +.. _intercom-site-id: + +Setting the app id +-------------------------- + +Intercom.io gives you a unique app id, and the :ttag:`intercom` +tag will include it in the rendered Javascript code. You can find your +app id by clicking the *Tracking Code* link when logged into +the on the intercom.io website. A page will display containing +HTML code looking like this:: + + + + +The code ``XXXXXXXXXXXXXXXXXXXXXXX`` is your app id. Set +:const:`INTERCOM_APP_ID` in the project :file:`settings.py` +file:: + + INTERCOM_APP_ID = 'XXXXXXXXXXXXXXXXXXXXXXX' + +If you do not set an app id, the Javascript code will not be +rendered. + + +Custom data +----------- + +As described in the Intercom documentation on `custom visitor data`_, +the data that is tracked by Intercom can be customized. Using template +context variables, you can let the :ttag:`intercom` tag pass custom data +to Intercom automatically. You can set the context variables in your view +when your render a template containing the tracking code:: + + context = RequestContext({'intercom_cart_value': cart.total_price}) + return some_template.render(context) + +For some data, it is annoying to do this for every view, so you may want +to set variables in a context processor that you add to the +:data:`TEMPLATE_CONTEXT_PROCESSORS` list in :file:`settings.py`:: + + from django.utils.hashcompat import md5_constructor as md5 + + GRAVATAR_URL = 'http://www.gravatar.com/avatar/' + + def intercom_custom_data(request): + try: + email = request.user.email + except AttributeError: + return {} + email_hash = md5(email).hexdigest() + avatar_url = "%s%s" % (GRAVATAR_URL, email_hash) + return {'intercom_avatar': avatar_url} + +Just remember that if you set the same context variable in the +:class:`~django.template.context.RequestContext` constructor and in a +context processor, the latter clobbers the former. + +Standard variables that will be displayed in the Intercom live visitor +data are listed in the table below, but you can define any ``intercom_*`` +variable you like and have that detail passed from within the visitor +live stream data when viewing Intercom. + +==================== =========================================== +Context variable Description +==================== =========================================== +``intercom_name`` The visitor's full name. +-------------------- ------------------------------------------- +``intercom_email`` The visitor's email address. +-------------------- ------------------------------------------- +``created_at`` The date the visitor created an account +==================== =========================================== + + +.. _`custom visitor data`: http://docs.intercom.io/custom-data/adding-custom-data + + +Identifying authenticated users +------------------------------- + +If you have not set the ``intercom_name`` or ``intercom_email`` variables +explicitly, the username and email address of an authenticated user are +passed to Intercom automatically. See :ref:`identifying-visitors`. + +.. _intercom-internal-ips: + +Internal IP addresses +--------------------- + +Usually you do not want to track clicks from your development or +internal IP addresses. By default, if the tags detect that the client +comes from any address in the :const:`ANALYTICAL_INTERNAL_IPS` setting +(which is :const:`INTERNAL_IPS` by default,) the tracking code is +commented out. See :ref:`identifying-visitors` for important information +about detecting the visitor IP address.