├── .pre-commit-config.yaml ├── analytical ├── templatetags │ ├── __init__.py │ ├── optimizely.py │ ├── hotjar.py │ ├── luckyorange.py │ ├── gauges.py │ ├── hubspot.py │ ├── clickmap.py │ ├── heap.py │ ├── crazy_egg.py │ ├── kiss_insights.py │ ├── rating_mailru.py │ ├── performable.py │ ├── google_analytics_gtag.py │ ├── gosquared.py │ ├── clicky.py │ ├── facebook_pixel.py │ ├── analytical.py │ ├── uservoice.py │ ├── spring_metrics.py │ ├── yandex_metrica.py │ ├── kiss_metrics.py │ ├── mixpanel.py │ ├── chartbeat.py │ ├── matomo.py │ ├── intercom.py │ ├── olark.py │ └── woopra.py ├── __init__.py └── models.py ├── tests ├── testproject │ ├── templatetags │ │ ├── __init__.py │ │ └── dummy.py │ └── settings.py └── unit │ ├── utils.py │ ├── test_tag_analytical.py │ ├── test_tag_clickmap.py │ ├── test_tag_rating_mailru.py │ ├── test_tag_yandex_metrica.py │ ├── test_tag_optimizely.py │ ├── test_tag_heap.py │ ├── test_tag_hubspot.py │ ├── test_tag_crazy_egg.py │ ├── test_tag_kiss_insights.py │ ├── test_tag_performable.py │ ├── test_tag_gauges.py │ ├── test_tag_clicky.py │ ├── test_tag_uservoice.py │ ├── test_tag_gosquared.py │ ├── test_tag_spring_metrics.py │ ├── test_tag_mixpanel.py │ ├── test_tag_hotjar.py │ ├── test_tag_luckyorange.py │ ├── test_tag_kiss_metrics.py │ ├── test_tag_olark.py │ ├── test_tag_chartbeat.py │ └── test_tag_facebook_pixel.py ├── MANIFEST.in ├── .gitignore ├── CONTRIBUTING.rst ├── docs ├── license.rst ├── _ext │ └── local.py ├── services.rst ├── index.rst ├── settings.rst ├── conf.py ├── history.rst ├── services │ ├── heap.rst │ ├── hotjar.rst │ ├── luckyorange.rst │ ├── rating_mailru.rst │ ├── clickmap.rst │ ├── hubspot.rst │ ├── optimizely.rst │ ├── facebook_pixel.rst │ ├── gauges.rst │ ├── yandex_metrica.rst │ ├── gosquared.rst │ ├── crazy_egg.rst │ ├── chartbeat.rst │ ├── kiss_insights.rst │ └── performable.rst ├── features.rst └── tutorial.rst ├── .readthedocs.yaml ├── .github └── workflows │ ├── check.yml │ ├── release.yml │ └── test.yml ├── LICENSE.txt ├── CODE_OF_CONDUCT.md ├── tox.ini └── pyproject.toml /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: [] 2 | -------------------------------------------------------------------------------- /analytical/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/testproject/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.txt *.rst 2 | recursive-include docs *.rst *.py 3 | recursive-include tests *.py 4 | -------------------------------------------------------------------------------- /analytical/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Analytics service integration for Django projects. 3 | """ 4 | 5 | __version__ = '3.2.0' 6 | -------------------------------------------------------------------------------- /analytical/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | Models for the django-analytical Django application. 3 | 4 | This application currently does not use models. 5 | """ 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .*.sw? 2 | /*.geany 3 | /.idea 4 | /.tox 5 | /.vscode 6 | 7 | *.pyc 8 | *.pyo 9 | 10 | /.coverage 11 | /coverage.xml 12 | /tests/*-report.json 13 | /tests/*-report.xml 14 | 15 | /build 16 | /dist 17 | /docs/_build 18 | /docs/_templates/layout.html 19 | /MANIFEST 20 | *.egg-info 21 | 22 | /requirements.txt 23 | /uv.lock 24 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://jazzband.co/static/img/jazzband.svg 2 | :target: https://jazzband.co/ 3 | :alt: Jazzband 4 | 5 | This is a `Jazzband `_ project. By contributing you agree to abide by the `Contributor Code of Conduct `_ and follow the `guidelines `_. 6 | -------------------------------------------------------------------------------- /docs/license.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | License 3 | ======= 4 | 5 | The django-analytical package is distributed under the `MIT License`_. 6 | The complete license term are included below. The copyright of the 7 | integration code snippets of individual services rest solely with the 8 | respective service providers. 9 | 10 | .. _`MIT License`: http://en.wikipedia.org/wiki/MIT_License 11 | 12 | 13 | License terms 14 | ============= 15 | 16 | .. include:: ../LICENSE.txt 17 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file 2 | # https://docs.readthedocs.io/en/stable/config-file/v2.html 3 | 4 | version: 2 5 | 6 | build: 7 | os: ubuntu-24.04 8 | tools: 9 | python: "3.12" 10 | 11 | sphinx: 12 | configuration: docs/conf.py 13 | 14 | # We recommend specifying your dependencies to enable reproducible builds: 15 | # https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html 16 | # python: 17 | # install: 18 | # - requirements: docs/requirements.txt 19 | -------------------------------------------------------------------------------- /docs/_ext/local.py: -------------------------------------------------------------------------------- 1 | def setup(app): 2 | app.add_crossref_type( 3 | directivename='setting', 4 | rolename='setting', 5 | indextemplate='pair: %s; setting', 6 | ) 7 | app.add_crossref_type( 8 | directivename='templatetag', 9 | rolename='ttag', 10 | indextemplate='pair: %s; template tag', 11 | ) 12 | app.add_crossref_type( 13 | directivename='templatefilter', 14 | rolename='tfilter', 15 | indextemplate='pair: %s; template filter', 16 | ) 17 | app.add_crossref_type( 18 | directivename='fieldlookup', 19 | rolename='lookup', 20 | indextemplate='pair: %s; field lookup type', 21 | ) 22 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: Check 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | env: 18 | - lint 19 | - format 20 | - audit 21 | - package 22 | - docs 23 | 24 | steps: 25 | - uses: actions/checkout@v4 26 | 27 | - uses: actions/setup-python@v5 28 | with: 29 | python-version: '3.12' 30 | 31 | - name: Install prerequisites 32 | run: python -m pip install tox 33 | 34 | - name: Run ${{ matrix.env }} 35 | run: tox -e ${{ matrix.env }} 36 | -------------------------------------------------------------------------------- /docs/services.rst: -------------------------------------------------------------------------------- 1 | .. _services: 2 | 3 | ======== 4 | Services 5 | ======== 6 | 7 | This section describes what features are supported by the different 8 | analytics services. To start using a service, you can either use the 9 | generic installation instructions (see :doc:`install`), or add 10 | service-specific tags to your templates. 11 | 12 | If you would like to have another analytics service supported by 13 | django-analytical, please create an issue on the project 14 | `issue tracker`_. See also :ref:`helping-out`. 15 | 16 | .. _`issue tracker`: http://github.com/jazzband/django-analytical/issues 17 | 18 | 19 | Currently supported services: 20 | 21 | .. toctree:: 22 | :maxdepth: 1 23 | :glob: 24 | 25 | services/* 26 | -------------------------------------------------------------------------------- /tests/testproject/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | django-analytical testing settings. 3 | """ 4 | 5 | DATABASES = { 6 | 'default': { 7 | 'ENGINE': 'django.db.backends.sqlite3', 8 | 'NAME': ':memory:', 9 | } 10 | } 11 | 12 | INSTALLED_APPS = [ 13 | 'django.contrib.sites', 14 | 'django.contrib.contenttypes', 15 | 'django.contrib.auth', 16 | 'analytical', 17 | ] 18 | 19 | SECRET_KEY = 'testing' 20 | 21 | MIDDLEWARE_CLASSES = ( 22 | 'django.middleware.common.CommonMiddleware', 23 | 'django.middleware.csrf.CsrfViewMiddleware', 24 | ) 25 | 26 | TEMPLATES = [ 27 | { 28 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 29 | 'APP_DIRS': True, 30 | }, 31 | ] 32 | 33 | USE_TZ = False 34 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ================= 2 | django-analytical 3 | ================= 4 | 5 | The django-analytical application integrates analytics services into a 6 | Django_ project. 7 | 8 | .. _Django: https://www.djangoproject.com/ 9 | 10 | :Package: https://pypi.org/project/django-analytical/ 11 | :Source: https://github.com/jazzband/django-analytical 12 | 13 | 14 | Overview 15 | ======== 16 | 17 | .. include:: ../README.rst 18 | :start-after: .. start docs include 19 | :end-before: .. end docs include 20 | 21 | To get a feel of how django-analytical works, check out the 22 | :doc:`tutorial`. 23 | 24 | 25 | Contents 26 | ======== 27 | 28 | .. toctree:: 29 | :maxdepth: 2 30 | 31 | tutorial 32 | install 33 | features 34 | services 35 | settings 36 | history 37 | license 38 | -------------------------------------------------------------------------------- /docs/settings.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Settings 3 | ======== 4 | 5 | Here's a full list of all available settings, in alphabetical order, and 6 | their default values. 7 | 8 | 9 | .. data:: ANALYTICAL_AUTO_IDENTIFY 10 | 11 | Default: ``True`` 12 | 13 | Automatically identify logged in users by their username. See 14 | :ref:`identifying-visitors`. 15 | 16 | 17 | .. data:: ANALYTICAL_INTERNAL_IPS 18 | 19 | Default: :data:`INTERNAL_IPS` 20 | 21 | A list or tuple of internal IP addresses. Tracking code will be 22 | commented out for visitors from any of these addresses. You can 23 | configure this setting for each service individually by substituting 24 | ``ANALYTICAL`` for the upper-case service name. For example, set 25 | ``GOOGLE_ANALYTICS_INTERNAL_IPS`` to configure for Google Analytics. 26 | 27 | See :ref:`internal-ips`. 28 | -------------------------------------------------------------------------------- /tests/unit/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Testing utilities. 3 | """ 4 | 5 | from django.template import Context, RequestContext, Template 6 | from django.test.testcases import TestCase 7 | 8 | 9 | class TagTestCase(TestCase): 10 | """ 11 | Tests for a template tag. 12 | 13 | Adds support methods for testing template tags. 14 | """ 15 | 16 | def render_tag(self, library, tag, vars=None, request=None): 17 | if vars is None: 18 | vars = {} 19 | t = Template('{%% load %s %%}{%% %s %%}' % (library, tag)) 20 | if request is not None: 21 | context = RequestContext(request, vars) 22 | else: 23 | context = Context(vars) 24 | return t.render(context) 25 | 26 | def render_template(self, template, vars=None, request=None): 27 | if vars is None: 28 | vars = {} 29 | t = Template(template) 30 | if request is not None: 31 | context = RequestContext(request, vars) 32 | else: 33 | context = Context(vars) 34 | return t.render(context) 35 | -------------------------------------------------------------------------------- /tests/testproject/templatetags/dummy.py: -------------------------------------------------------------------------------- 1 | """ 2 | Dummy testing template tags and filters. 3 | """ 4 | 5 | from django.template import Library, Node, TemplateSyntaxError 6 | 7 | from analytical.templatetags.analytical import TAG_LOCATIONS 8 | 9 | register = Library() 10 | 11 | 12 | def _location_node(location): 13 | class DummyNode(Node): 14 | def render(self, context): 15 | return '' % location 16 | 17 | return DummyNode 18 | 19 | 20 | _location_nodes = {loc: _location_node(loc) for loc in TAG_LOCATIONS} 21 | 22 | 23 | def _location_tag(location): 24 | def dummy_tag(parser, token): 25 | bits = token.split_contents() 26 | if len(bits) > 1: 27 | raise TemplateSyntaxError("'%s' tag takes no arguments" % bits[0]) 28 | return _location_nodes[location] 29 | 30 | return dummy_tag 31 | 32 | 33 | for loc in TAG_LOCATIONS: 34 | register.tag('dummy_%s' % loc, _location_tag(loc)) 35 | 36 | 37 | def contribute_to_analytical(add_node_cls): 38 | for location in TAG_LOCATIONS: 39 | add_node_cls(location, _location_nodes[location]) 40 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (C) 2011-2019 Joost Cassee and contributors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | env: 9 | PIP_DISABLE_PIP_VERSION_CHECK: '1' 10 | PY_COLORS: '1' 11 | 12 | jobs: 13 | build: 14 | if: github.repository == 'jazzband/django-analytical' 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 21 | 22 | - name: Set up Python 23 | uses: actions/setup-python@v5 24 | with: 25 | python-version: '3.12' 26 | cache: pip 27 | cache-dependency-path: | 28 | **/pyproject.toml 29 | 30 | - name: Install dependencies 31 | run: | 32 | python -m pip install tox 33 | 34 | - name: Build package 35 | run: | 36 | tox -e package 37 | 38 | - name: Upload packages to Jazzband 39 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 40 | uses: pypa/gh-action-pypi-publish@release/v1 41 | with: 42 | user: jazzband 43 | password: ${{ secrets.JAZZBAND_RELEASE_KEY }} 44 | repository_url: https://jazzband.co/projects/django-analytical/upload 45 | -------------------------------------------------------------------------------- /tests/unit/test_tag_analytical.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the generic template tags and filters. 3 | """ 4 | 5 | from django.template import Context, Template 6 | from utils import TagTestCase 7 | 8 | from analytical.templatetags import analytical 9 | 10 | 11 | class AnalyticsTagTestCase(TagTestCase): 12 | """ 13 | Tests for the ``analytical`` template tags. 14 | """ 15 | 16 | def setUp(self): 17 | super().setUp() 18 | self._tag_modules = analytical.TAG_MODULES 19 | analytical.TAG_MODULES = ['tests.testproject.dummy'] 20 | analytical.template_nodes = analytical._load_template_nodes() 21 | 22 | def tearDown(self): 23 | analytical.TAG_MODULES = self._tag_modules 24 | analytical.template_nodes = analytical._load_template_nodes() 25 | super().tearDown() 26 | 27 | def render_location_tag(self, location, vars=None): 28 | if vars is None: 29 | vars = {} 30 | t = Template('{%% load analytical %%}{%% analytical_%s %%}' % location) 31 | return t.render(Context(vars)) 32 | 33 | def test_location_tags(self): 34 | for loc in ['head_top', 'head_bottom', 'body_top', 'body_bottom']: 35 | r = self.render_location_tag(loc) 36 | assert f'dummy_{loc}' in r 37 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is execfile()d with the current directory set to its containing 3 | # directory. 4 | 5 | import os 6 | import sys 7 | 8 | sys.path.append(os.path.join(os.path.abspath('.'), '_ext')) 9 | sys.path.append(os.path.dirname(os.path.abspath('.'))) 10 | 11 | import analytical # noqa 12 | 13 | # -- General configuration -------------------------------------------------- 14 | 15 | project = 'django-analytical' 16 | copyright = '2011, Joost Cassee ' 17 | 18 | release = analytical.__version__ 19 | # The short X.Y version. 20 | version = release.rsplit('.', 1)[0] 21 | 22 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'local'] 23 | templates_path = ['_templates'] 24 | source_suffix = {'.rst': 'restructuredtext'} 25 | master_doc = 'index' 26 | 27 | add_function_parentheses = True 28 | pygments_style = 'sphinx' 29 | 30 | intersphinx_mapping = { 31 | 'python': ('https://docs.python.org/3.13', None), 32 | 'django': ('https://docs.djangoproject.com/en/stable', None), 33 | } 34 | 35 | 36 | # -- Options for HTML output ------------------------------------------------ 37 | 38 | html_theme = 'default' 39 | htmlhelp_basename = 'analyticaldoc' 40 | 41 | 42 | # -- Options for LaTeX output ----------------------------------------------- 43 | 44 | latex_documents = [ 45 | ( 46 | 'index', 47 | 'django-analytical.tex', 48 | 'Documentation for django-analytical', 49 | 'Joost Cassee', 50 | 'manual', 51 | ), 52 | ] 53 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | branches: 9 | - main 10 | 11 | env: 12 | PIP_DISABLE_PIP_VERSION_CHECK: '1' 13 | PY_COLORS: '1' 14 | 15 | jobs: 16 | python-django: 17 | runs-on: ubuntu-latest 18 | strategy: 19 | max-parallel: 5 20 | matrix: 21 | python-version: 22 | - '3.10' 23 | - '3.11' 24 | - '3.12' 25 | - '3.13' 26 | django-version: 27 | - '4.2' 28 | - '5.1' 29 | - '5.2' 30 | include: 31 | - { python-version: '3.9', django-version: '4.2' } 32 | exclude: 33 | - { python-version: '3.13', django-version: '4.2' } 34 | 35 | steps: 36 | - uses: actions/checkout@v4 37 | 38 | - name: Set up Python ${{ matrix.python-version }} 39 | uses: actions/setup-python@v5 40 | with: 41 | python-version: ${{ matrix.python-version }} 42 | cache: pip 43 | cache-dependency-path: | 44 | **/pyproject.toml 45 | 46 | - name: Install dependencies 47 | run: | 48 | python -m pip install tox tox-gh-actions 49 | 50 | - name: Tox tests (Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }}) 51 | run: tox 52 | env: 53 | DJANGO: ${{ matrix.django-version }} 54 | 55 | - name: Upload coverage 56 | uses: codecov/codecov-action@v5 57 | with: 58 | name: Python ${{ matrix.python-version }} 59 | -------------------------------------------------------------------------------- /analytical/templatetags/optimizely.py: -------------------------------------------------------------------------------- 1 | """ 2 | Optimizely template tags and filters. 3 | """ 4 | 5 | import re 6 | 7 | from django.template import Library, Node, TemplateSyntaxError 8 | 9 | from analytical.utils import disable_html, get_required_setting, is_internal_ip 10 | 11 | ACCOUNT_NUMBER_RE = re.compile(r'^\d+$') 12 | SETUP_CODE = """""" 13 | 14 | 15 | register = Library() 16 | 17 | 18 | @register.tag 19 | def optimizely(parser, token): 20 | """ 21 | Optimizely template tag. 22 | 23 | Renders JavaScript code to set-up A/B testing. You must supply 24 | your Optimizely account number in the ``OPTIMIZELY_ACCOUNT_NUMBER`` 25 | setting. 26 | """ 27 | bits = token.split_contents() 28 | if len(bits) > 1: 29 | raise TemplateSyntaxError("'%s' takes no arguments" % bits[0]) 30 | return OptimizelyNode() 31 | 32 | 33 | class OptimizelyNode(Node): 34 | def __init__(self): 35 | self.account_number = get_required_setting( 36 | 'OPTIMIZELY_ACCOUNT_NUMBER', 37 | ACCOUNT_NUMBER_RE, 38 | "must be a string looking like 'XXXXXXX'", 39 | ) 40 | 41 | def render(self, context): 42 | html = SETUP_CODE % {'account_number': self.account_number} 43 | if is_internal_ip(context, 'OPTIMIZELY'): 44 | html = disable_html(html, 'Optimizely') 45 | return html 46 | 47 | 48 | def contribute_to_analytical(add_node): 49 | OptimizelyNode() # ensure properly configured 50 | add_node('head_top', OptimizelyNode) 51 | -------------------------------------------------------------------------------- /tests/unit/test_tag_clickmap.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the Clickmap template tags and filters. 3 | """ 4 | 5 | import pytest 6 | from django.http import HttpRequest 7 | from django.template import Context 8 | from django.test.utils import override_settings 9 | from utils import TagTestCase 10 | 11 | from analytical.templatetags.clickmap import ClickmapNode 12 | from analytical.utils import AnalyticalException 13 | 14 | 15 | @override_settings(CLICKMAP_TRACKER_ID='12345ABC') 16 | class ClickmapTagTestCase(TagTestCase): 17 | """ 18 | Tests for the ``clickmap`` template tag. 19 | """ 20 | 21 | def test_tag(self): 22 | r = self.render_tag('clickmap', 'clickmap') 23 | assert "tracker: '12345ABC', version:'2'};" in r 24 | 25 | def test_node(self): 26 | r = ClickmapNode().render(Context({})) 27 | assert "tracker: '12345ABC', version:'2'};" in r 28 | 29 | @override_settings(CLICKMAP_TRACKER_ID=None) 30 | def test_no_site_id(self): 31 | with pytest.raises(AnalyticalException): 32 | ClickmapNode() 33 | 34 | @override_settings(CLICKMAP_TRACKER_ID='ab#c') 35 | def test_wrong_site_id(self): 36 | with pytest.raises(AnalyticalException): 37 | ClickmapNode() 38 | 39 | @override_settings(ANALYTICAL_INTERNAL_IPS=['1.1.1.1']) 40 | def test_render_internal_ip(self): 41 | req = HttpRequest() 42 | req.META['REMOTE_ADDR'] = '1.1.1.1' 43 | context = Context({'request': req}) 44 | r = ClickmapNode().render(context) 45 | assert r.startswith('') 47 | -------------------------------------------------------------------------------- /tests/unit/test_tag_rating_mailru.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the Rating@Mail.ru template tags and filters. 3 | """ 4 | 5 | import pytest 6 | from django.http import HttpRequest 7 | from django.template import Context 8 | from django.test.utils import override_settings 9 | from utils import TagTestCase 10 | 11 | from analytical.templatetags.rating_mailru import RatingMailruNode 12 | from analytical.utils import AnalyticalException 13 | 14 | 15 | @override_settings(RATING_MAILRU_COUNTER_ID='1234567') 16 | class RatingMailruTagTestCase(TagTestCase): 17 | """ 18 | Tests for the ``rating_mailru`` template tag. 19 | """ 20 | 21 | def test_tag(self): 22 | r = self.render_tag('rating_mailru', 'rating_mailru') 23 | assert 'counter?id=1234567;js=na' in r 24 | 25 | def test_node(self): 26 | r = RatingMailruNode().render(Context({})) 27 | assert 'counter?id=1234567;js=na' in r 28 | 29 | @override_settings(RATING_MAILRU_COUNTER_ID=None) 30 | def test_no_site_id(self): 31 | with pytest.raises(AnalyticalException): 32 | RatingMailruNode() 33 | 34 | @override_settings(RATING_MAILRU_COUNTER_ID='1234abc') 35 | def test_wrong_site_id(self): 36 | with pytest.raises(AnalyticalException): 37 | RatingMailruNode() 38 | 39 | @override_settings(ANALYTICAL_INTERNAL_IPS=['1.1.1.1']) 40 | def test_render_internal_ip(self): 41 | req = HttpRequest() 42 | req.META['REMOTE_ADDR'] = '1.1.1.1' 43 | context = Context({'request': req}) 44 | r = RatingMailruNode().render(context) 45 | assert r.startswith('') 47 | -------------------------------------------------------------------------------- /tests/unit/test_tag_yandex_metrica.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the Yandex.Metrica template tags and filters. 3 | """ 4 | 5 | import pytest 6 | from django.http import HttpRequest 7 | from django.template import Context 8 | from django.test.utils import override_settings 9 | from utils import TagTestCase 10 | 11 | from analytical.templatetags.yandex_metrica import YandexMetricaNode 12 | from analytical.utils import AnalyticalException 13 | 14 | 15 | @override_settings(YANDEX_METRICA_COUNTER_ID='12345678') 16 | class YandexMetricaTagTestCase(TagTestCase): 17 | """ 18 | Tests for the ``yandex_metrica`` template tag. 19 | """ 20 | 21 | def test_tag(self): 22 | r = self.render_tag('yandex_metrica', 'yandex_metrica') 23 | assert 'w.yaCounter12345678 = new Ya.Metrika' in r 24 | 25 | def test_node(self): 26 | r = YandexMetricaNode().render(Context({})) 27 | assert 'w.yaCounter12345678 = new Ya.Metrika' in r 28 | 29 | @override_settings(YANDEX_METRICA_COUNTER_ID=None) 30 | def test_no_site_id(self): 31 | with pytest.raises(AnalyticalException): 32 | YandexMetricaNode() 33 | 34 | @override_settings(YANDEX_METRICA_COUNTER_ID='1234abcd') 35 | def test_wrong_site_id(self): 36 | with pytest.raises(AnalyticalException): 37 | YandexMetricaNode() 38 | 39 | @override_settings(ANALYTICAL_INTERNAL_IPS=['1.1.1.1']) 40 | def test_render_internal_ip(self): 41 | req = HttpRequest() 42 | req.META['REMOTE_ADDR'] = '1.1.1.1' 43 | context = Context({'request': req}) 44 | r = YandexMetricaNode().render(context) 45 | assert r.startswith('') 47 | -------------------------------------------------------------------------------- /tests/unit/test_tag_optimizely.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the Optimizely template tags and filters. 3 | """ 4 | 5 | import pytest 6 | from django.http import HttpRequest 7 | from django.template import Context 8 | from django.test.utils import override_settings 9 | from utils import TagTestCase 10 | 11 | from analytical.templatetags.optimizely import OptimizelyNode 12 | from analytical.utils import AnalyticalException 13 | 14 | 15 | @override_settings(OPTIMIZELY_ACCOUNT_NUMBER='1234567') 16 | class OptimizelyTagTestCase(TagTestCase): 17 | """ 18 | Tests for the ``optimizely`` template tag. 19 | """ 20 | 21 | def test_tag(self): 22 | expected = '' 23 | assert self.render_tag('optimizely', 'optimizely') == expected 24 | 25 | def test_node(self): 26 | expected = '' 27 | assert OptimizelyNode().render(Context()) == expected 28 | 29 | @override_settings(OPTIMIZELY_ACCOUNT_NUMBER=None) 30 | def test_no_account_number(self): 31 | with pytest.raises(AnalyticalException): 32 | OptimizelyNode() 33 | 34 | @override_settings(OPTIMIZELY_ACCOUNT_NUMBER='123abc') 35 | def test_wrong_account_number(self): 36 | with pytest.raises(AnalyticalException): 37 | OptimizelyNode() 38 | 39 | @override_settings(ANALYTICAL_INTERNAL_IPS=['1.1.1.1']) 40 | def test_render_internal_ip(self): 41 | req = HttpRequest() 42 | req.META['REMOTE_ADDR'] = '1.1.1.1' 43 | context = Context({'request': req}) 44 | r = OptimizelyNode().render(context) 45 | assert r.startswith('') 47 | -------------------------------------------------------------------------------- /tests/unit/test_tag_heap.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the Heap template tags and filters. 3 | """ 4 | 5 | import pytest 6 | from django.http import HttpRequest 7 | from django.template import Context, Template, TemplateSyntaxError 8 | from django.test.utils import override_settings 9 | from utils import TagTestCase 10 | 11 | from analytical.templatetags.heap import HeapNode 12 | from analytical.utils import AnalyticalException 13 | 14 | 15 | @override_settings(HEAP_TRACKER_ID='123456789') 16 | class HeapTagTestCase(TagTestCase): 17 | """ 18 | Tests for the ``heap`` template tag. 19 | """ 20 | 21 | def test_tag(self): 22 | r = self.render_tag('heap', 'heap') 23 | assert '123456789' in r 24 | 25 | def test_node(self): 26 | r = HeapNode().render(Context({})) 27 | assert '123456789' in r 28 | 29 | def test_tags_take_no_args(self): 30 | with pytest.raises(TemplateSyntaxError, match="'heap' takes no arguments"): 31 | Template('{% load heap %}{% heap "arg" %}').render(Context({})) 32 | 33 | @override_settings(HEAP_TRACKER_ID=None) 34 | def test_no_site_id(self): 35 | with pytest.raises(AnalyticalException): 36 | HeapNode() 37 | 38 | @override_settings(HEAP_TRACKER_ID='abcdefg') 39 | def test_wrong_site_id(self): 40 | with pytest.raises(AnalyticalException): 41 | HeapNode() 42 | 43 | @override_settings(ANALYTICAL_INTERNAL_IPS=['1.1.1.1']) 44 | def test_render_internal_ip(self): 45 | req = HttpRequest() 46 | req.META['REMOTE_ADDR'] = '1.1.1.1' 47 | context = Context({'request': req}) 48 | r = HeapNode().render(context) 49 | assert r.startswith('') 51 | -------------------------------------------------------------------------------- /analytical/templatetags/hotjar.py: -------------------------------------------------------------------------------- 1 | """ 2 | Hotjar template tags and filters. 3 | """ 4 | 5 | import re 6 | 7 | from django.template import Library, Node, TemplateSyntaxError 8 | 9 | from analytical.utils import disable_html, get_required_setting, is_internal_ip 10 | 11 | HOTJAR_TRACKING_CODE = """\ 12 | 22 | """ 23 | 24 | 25 | register = Library() 26 | 27 | 28 | def _validate_no_args(token): 29 | bits = token.split_contents() 30 | if len(bits) > 1: 31 | raise TemplateSyntaxError("'%s' takes no arguments" % bits[0]) 32 | 33 | 34 | @register.tag 35 | def hotjar(parser, token): 36 | """ 37 | Hotjar template tag. 38 | """ 39 | _validate_no_args(token) 40 | return HotjarNode() 41 | 42 | 43 | class HotjarNode(Node): 44 | def __init__(self): 45 | self.site_id = get_required_setting( 46 | 'HOTJAR_SITE_ID', 47 | re.compile(r'^\d+$'), 48 | 'must be (a string containing) a number', 49 | ) 50 | 51 | def render(self, context): 52 | html = HOTJAR_TRACKING_CODE % {'HOTJAR_SITE_ID': self.site_id} 53 | if is_internal_ip(context, 'HOTJAR'): 54 | return disable_html(html, 'Hotjar') 55 | else: 56 | return html 57 | 58 | 59 | def contribute_to_analytical(add_node): 60 | # ensure properly configured 61 | HotjarNode() 62 | add_node('head_bottom', HotjarNode) 63 | -------------------------------------------------------------------------------- /tests/unit/test_tag_hubspot.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the HubSpot template tags and filters. 3 | """ 4 | 5 | import pytest 6 | from django.http import HttpRequest 7 | from django.template import Context 8 | from django.test.utils import override_settings 9 | from utils import TagTestCase 10 | 11 | from analytical.templatetags.hubspot import HubSpotNode 12 | from analytical.utils import AnalyticalException 13 | 14 | 15 | @override_settings(HUBSPOT_PORTAL_ID='1234') 16 | class HubSpotTagTestCase(TagTestCase): 17 | """ 18 | Tests for the ``hubspot`` template tag. 19 | """ 20 | 21 | def test_tag(self): 22 | r = self.render_tag('hubspot', 'hubspot') 23 | assert ( 24 | "n.id=i;n.src='//js.hs-analytics.net/analytics/'" 25 | "+(Math.ceil(new Date()/r)*r)+'/1234.js';" 26 | ) in r 27 | 28 | def test_node(self): 29 | r = HubSpotNode().render(Context()) 30 | assert ( 31 | "n.id=i;n.src='//js.hs-analytics.net/analytics/'" 32 | "+(Math.ceil(new Date()/r)*r)+'/1234.js';" 33 | ) in r 34 | 35 | @override_settings(HUBSPOT_PORTAL_ID=None) 36 | def test_no_portal_id(self): 37 | with pytest.raises(AnalyticalException): 38 | HubSpotNode() 39 | 40 | @override_settings(HUBSPOT_PORTAL_ID='wrong') 41 | def test_wrong_portal_id(self): 42 | with pytest.raises(AnalyticalException): 43 | HubSpotNode() 44 | 45 | @override_settings(ANALYTICAL_INTERNAL_IPS=['1.1.1.1']) 46 | def test_render_internal_ip(self): 47 | req = HttpRequest() 48 | req.META['REMOTE_ADDR'] = '1.1.1.1' 49 | context = Context({'request': req}) 50 | r = HubSpotNode().render(context) 51 | assert r.startswith('') 53 | -------------------------------------------------------------------------------- /analytical/templatetags/luckyorange.py: -------------------------------------------------------------------------------- 1 | """ 2 | Lucky Orange template tags and filters. 3 | """ 4 | 5 | import re 6 | 7 | from django.template import Library, Node, TemplateSyntaxError 8 | 9 | from analytical.utils import disable_html, get_required_setting, is_internal_ip 10 | 11 | LUCKYORANGE_TRACKING_CODE = """\ 12 | 20 | """ 21 | 22 | 23 | register = Library() 24 | 25 | 26 | def _validate_no_args(token): 27 | bits = token.split_contents() 28 | if len(bits) > 1: 29 | raise TemplateSyntaxError("'%s' takes no arguments" % bits[0]) 30 | 31 | 32 | @register.tag 33 | def luckyorange(parser, token): 34 | """ 35 | Lucky Orange template tag. 36 | """ 37 | _validate_no_args(token) 38 | return LuckyOrangeNode() 39 | 40 | 41 | class LuckyOrangeNode(Node): 42 | def __init__(self): 43 | self.site_id = get_required_setting( 44 | 'LUCKYORANGE_SITE_ID', 45 | re.compile(r'^\d+$'), 46 | 'must be (a string containing) a number', 47 | ) 48 | 49 | def render(self, context): 50 | html = LUCKYORANGE_TRACKING_CODE % {'LUCKYORANGE_SITE_ID': self.site_id} 51 | if is_internal_ip(context, 'LUCKYORANGE'): 52 | return disable_html(html, 'Lucky Orange') 53 | else: 54 | return html 55 | 56 | 57 | def contribute_to_analytical(add_node): 58 | # ensure properly configured 59 | LuckyOrangeNode() 60 | add_node('head_bottom', LuckyOrangeNode) 61 | -------------------------------------------------------------------------------- /docs/history.rst: -------------------------------------------------------------------------------- 1 | =================== 2 | History and credits 3 | =================== 4 | 5 | Changelog 6 | ========= 7 | 8 | The project follows the `Semantic Versioning`_ specification for its 9 | version numbers. Patch-level increments indicate bug fixes, minor 10 | version increments indicate new functionality and major version 11 | increments indicate backwards incompatible changes. 12 | 13 | Version 1.0.0 is the last to support Django < 1.7. Users of older Django 14 | versions should stick to 1.0.0, and are encouraged to upgrade their setups. 15 | Starting with 2.0.0, dropping support for obsolete Django versions is not 16 | considered to be a backward-incompatible change. 17 | 18 | .. _`Semantic Versioning`: http://semver.org/ 19 | 20 | .. include:: ../CHANGELOG.rst 21 | 22 | 23 | Credits 24 | ======= 25 | 26 | The django-analytical package was originally written by `Joost Cassee`_ 27 | and is now maintained by `Peter Bittner`_ and the `Jazzband community`_. 28 | All known contributors are listed as ``authors`` in the `project metadata`_. 29 | 30 | Included JavaScript code snippets for integration of the analytics services 31 | were written by the respective service providers. 32 | 33 | The application was inspired by and uses ideas from Analytical_, Joshua 34 | Krall's all-purpose analytics front-end for Rails. 35 | 36 | .. _`Joost Cassee`: https://github.com/jcassee 37 | .. _`Peter Bittner`: https://github.com/bittner 38 | .. _`Jazzband community`: https://jazzband.co/ 39 | .. _`project metadata`: https://github.com/jazzband/django-analytical/blob/main/pyproject.toml#L15-L60 40 | .. _`Analytical`: https://github.com/jkrall/analytical 41 | 42 | .. _helping-out: 43 | 44 | Helping out 45 | =========== 46 | 47 | .. include:: ../README.rst 48 | :start-after: .. start contribute include 49 | :end-before: .. end contribute include 50 | 51 | -------------------------------------------------------------------------------- /analytical/templatetags/gauges.py: -------------------------------------------------------------------------------- 1 | """ 2 | Gaug.es template tags and filters. 3 | """ 4 | 5 | import re 6 | 7 | from django.template import Library, Node, TemplateSyntaxError 8 | 9 | from analytical.utils import disable_html, get_required_setting, is_internal_ip 10 | 11 | SITE_ID_RE = re.compile(r'[\da-f]+$') 12 | TRACKING_CODE = """ 13 | 26 | """ 27 | 28 | register = Library() 29 | 30 | 31 | @register.tag 32 | def gauges(parser, token): 33 | """ 34 | Gaug.es template tag. 35 | 36 | Renders JavaScript code to gaug.es testing. You must supply 37 | your Site ID account number in the ``GAUGES_SITE_ID`` 38 | setting. 39 | """ 40 | bits = token.split_contents() 41 | if len(bits) > 1: 42 | raise TemplateSyntaxError("'%s' takes no arguments" % bits[0]) 43 | return GaugesNode() 44 | 45 | 46 | class GaugesNode(Node): 47 | def __init__(self): 48 | self.site_id = get_required_setting( 49 | 'GAUGES_SITE_ID', SITE_ID_RE, "must be a string looking like 'XXXXXXX'" 50 | ) 51 | 52 | def render(self, context): 53 | html = TRACKING_CODE % {'site_id': self.site_id} 54 | if is_internal_ip(context, 'GAUGES'): 55 | html = disable_html(html, 'Gauges') 56 | return html 57 | 58 | 59 | def contribute_to_analytical(add_node): 60 | GaugesNode() 61 | add_node('head_bottom', GaugesNode) 62 | -------------------------------------------------------------------------------- /tests/unit/test_tag_crazy_egg.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the Crazy Egg template tags and filters. 3 | """ 4 | 5 | import pytest 6 | from django.http import HttpRequest 7 | from django.template import Context 8 | from django.test.utils import override_settings 9 | from utils import TagTestCase 10 | 11 | from analytical.templatetags.crazy_egg import CrazyEggNode 12 | from analytical.utils import AnalyticalException 13 | 14 | 15 | @override_settings(CRAZY_EGG_ACCOUNT_NUMBER='12345678') 16 | class CrazyEggTagTestCase(TagTestCase): 17 | """ 18 | Tests for the ``crazy_egg`` template tag. 19 | """ 20 | 21 | def test_tag(self): 22 | r = self.render_tag('crazy_egg', 'crazy_egg') 23 | assert '/1234/5678.js' in r 24 | 25 | def test_node(self): 26 | r = CrazyEggNode().render(Context()) 27 | assert '/1234/5678.js' in r 28 | 29 | @override_settings(CRAZY_EGG_ACCOUNT_NUMBER=None) 30 | def test_no_account_number(self): 31 | with pytest.raises(AnalyticalException): 32 | CrazyEggNode() 33 | 34 | @override_settings(CRAZY_EGG_ACCOUNT_NUMBER='123abc') 35 | def test_wrong_account_number(self): 36 | with pytest.raises(AnalyticalException): 37 | CrazyEggNode() 38 | 39 | def test_uservars(self): 40 | context = Context({'crazy_egg_var1': 'foo', 'crazy_egg_var2': 'bar'}) 41 | r = CrazyEggNode().render(context) 42 | assert "CE2.set(1, 'foo');" in r 43 | assert "CE2.set(2, 'bar');" in r 44 | 45 | @override_settings(ANALYTICAL_INTERNAL_IPS=['1.1.1.1']) 46 | def test_render_internal_ip(self): 47 | req = HttpRequest() 48 | req.META['REMOTE_ADDR'] = '1.1.1.1' 49 | context = Context({'request': req}) 50 | r = CrazyEggNode().render(context) 51 | assert r.startswith('') 53 | -------------------------------------------------------------------------------- /docs/services/heap.rst: -------------------------------------------------------------------------------- 1 | ===================================== 2 | Heap -- analytics and events tracking 3 | ===================================== 4 | 5 | `Heap`_ automatically captures all user interactions on your site, from the moment of installation forward. 6 | 7 | .. _`Heap`: https://heap.io/ 8 | 9 | 10 | .. heap-installation: 11 | 12 | Installation 13 | ============ 14 | 15 | To start using the Heap integration, you must have installed the 16 | django-analytical package and have added the ``analytical`` application 17 | to :const:`INSTALLED_APPS` in your project :file:`settings.py` file. 18 | See :doc:`../install` for details. 19 | 20 | .. _heap-configuration: 21 | 22 | Configuration 23 | ============= 24 | 25 | Before you can use the Heap integration, you must first get your 26 | Heap Tracker ID. If you don't have a Heap account yet, 27 | `sign up`_ to get your Tracker ID. 28 | 29 | .. _`sign up`: https://heap.io/ 30 | 31 | 32 | .. _heap-tracker-id: 33 | 34 | Setting the Tracker ID 35 | ---------------------- 36 | 37 | Heap gives you a unique ID. You can find this ID on the Projects page 38 | of your Heap account. Set :const:`HEAP_TRACKER_ID` in the project 39 | :file:`settings.py` file:: 40 | 41 | HEAP_TRACKER_ID = 'XXXXXXXX' 42 | 43 | If you do not set an Tracker ID, the tracking code will not be 44 | rendered. 45 | 46 | The tracking code will be added just before the closing head tag. 47 | 48 | 49 | .. _heap-internal-ips: 50 | 51 | Internal IP addresses 52 | --------------------- 53 | 54 | Usually you do not want to track clicks from your development or 55 | internal IP addresses. By default, if the tags detect that the client 56 | comes from any address in the :const:`ANALYTICAL_INTERNAL_IPS` setting 57 | (which is :const:`INTERNAL_IPS` by default,) the tracking code is 58 | commented out. See :ref:`identifying-visitors` for important information 59 | about detecting the visitor IP address. 60 | -------------------------------------------------------------------------------- /analytical/templatetags/hubspot.py: -------------------------------------------------------------------------------- 1 | """ 2 | HubSpot template tags and filters. 3 | """ 4 | 5 | import re 6 | 7 | from django.template import Library, Node, TemplateSyntaxError 8 | 9 | from analytical.utils import disable_html, get_required_setting, is_internal_ip 10 | 11 | PORTAL_ID_RE = re.compile(r'^\d+$') 12 | TRACKING_CODE = """ 13 | 14 | 22 | 23 | """ # noqa 24 | 25 | register = Library() 26 | 27 | 28 | @register.tag 29 | def hubspot(parser, token): 30 | """ 31 | HubSpot tracking template tag. 32 | 33 | Renders JavaScript code to track page visits. You must supply 34 | your portal ID (as a string) in the ``HUBSPOT_PORTAL_ID`` setting. 35 | """ 36 | bits = token.split_contents() 37 | if len(bits) > 1: 38 | raise TemplateSyntaxError("'%s' takes no arguments" % bits[0]) 39 | return HubSpotNode() 40 | 41 | 42 | class HubSpotNode(Node): 43 | def __init__(self): 44 | self.portal_id = get_required_setting( 45 | 'HUBSPOT_PORTAL_ID', PORTAL_ID_RE, 'must be a (string containing a) number' 46 | ) 47 | 48 | def render(self, context): 49 | html = TRACKING_CODE % {'portal_id': self.portal_id} 50 | if is_internal_ip(context, 'HUBSPOT'): 51 | html = disable_html(html, 'HubSpot') 52 | return html 53 | 54 | 55 | def contribute_to_analytical(add_node): 56 | HubSpotNode() # ensure properly configured 57 | add_node('body_bottom', HubSpotNode) 58 | -------------------------------------------------------------------------------- /analytical/templatetags/clickmap.py: -------------------------------------------------------------------------------- 1 | """ 2 | Clickmap template tags and filters. 3 | """ 4 | 5 | import re 6 | 7 | from django.template import Library, Node, TemplateSyntaxError 8 | 9 | from analytical.utils import disable_html, get_required_setting, is_internal_ip 10 | 11 | CLICKMAP_TRACKER_ID_RE = re.compile(r'^\w+$') 12 | TRACKING_CODE = """ 13 | 23 | """ 24 | 25 | register = Library() 26 | 27 | 28 | @register.tag 29 | def clickmap(parser, token): 30 | """ 31 | Clickmap tracker template tag. 32 | 33 | Renders JavaScript code to track page visits. You must supply 34 | your clickmap tracker ID (as a string) in the ``CLICKMAP_TRACKER_ID`` 35 | setting. 36 | """ 37 | bits = token.split_contents() 38 | if len(bits) > 1: 39 | raise TemplateSyntaxError("'%s' takes no arguments" % bits[0]) 40 | return ClickmapNode() 41 | 42 | 43 | class ClickmapNode(Node): 44 | def __init__(self): 45 | self.tracker_id = get_required_setting( 46 | 'CLICKMAP_TRACKER_ID', 47 | CLICKMAP_TRACKER_ID_RE, 48 | 'must be an alphanumeric string', 49 | ) 50 | 51 | def render(self, context): 52 | html = TRACKING_CODE % {'tracker_id': self.tracker_id} 53 | if is_internal_ip(context, 'CLICKMAP'): 54 | html = disable_html(html, 'Clickmap') 55 | return html 56 | 57 | 58 | def contribute_to_analytical(add_node): 59 | ClickmapNode() # ensure properly configured 60 | add_node('body_bottom', ClickmapNode) 61 | -------------------------------------------------------------------------------- /analytical/templatetags/heap.py: -------------------------------------------------------------------------------- 1 | """ 2 | Heap template tags and filters. 3 | """ 4 | 5 | import re 6 | 7 | from django.template import Library, Node, TemplateSyntaxError 8 | 9 | from analytical.utils import disable_html, get_required_setting, is_internal_ip 10 | 11 | HEAP_TRACKER_ID_RE = re.compile(r'^\d+$') 12 | TRACKING_CODE = """ 13 | 17 | 18 | """ # noqa 19 | 20 | register = Library() 21 | 22 | 23 | def _validate_no_args(token): 24 | bits = token.split_contents() 25 | if len(bits) > 1: 26 | raise TemplateSyntaxError("'%s' takes no arguments" % bits[0]) 27 | 28 | 29 | @register.tag 30 | def heap(parser, token): 31 | """ 32 | Heap tracker template tag. 33 | 34 | Renders JavaScript code to track page visits. You must supply 35 | your heap tracker ID (as a string) in the ``HEAP_TRACKER_ID`` 36 | setting. 37 | """ 38 | _validate_no_args(token) 39 | return HeapNode() 40 | 41 | 42 | class HeapNode(Node): 43 | def __init__(self): 44 | self.tracker_id = get_required_setting( 45 | 'HEAP_TRACKER_ID', HEAP_TRACKER_ID_RE, 'must be an numeric string' 46 | ) 47 | 48 | def render(self, context): 49 | html = TRACKING_CODE % {'tracker_id': self.tracker_id} 50 | if is_internal_ip(context, 'HEAP'): 51 | html = disable_html(html, 'Heap') 52 | return html 53 | 54 | 55 | def contribute_to_analytical(add_node): 56 | HeapNode() # ensure properly configured 57 | add_node('head_bottom', HeapNode) 58 | -------------------------------------------------------------------------------- /analytical/templatetags/crazy_egg.py: -------------------------------------------------------------------------------- 1 | """ 2 | Crazy Egg template tags and filters. 3 | """ 4 | 5 | import re 6 | 7 | from django.template import Library, Node, TemplateSyntaxError 8 | 9 | from analytical.utils import disable_html, get_required_setting, is_internal_ip 10 | 11 | ACCOUNT_NUMBER_RE = re.compile(r'^\d+$') 12 | SETUP_CODE = ''.format( 13 | placeholder_url='//dnn506yrbagrg.cloudfront.net/pages/scripts/' 14 | '%(account_nr_1)s/%(account_nr_2)s.js' 15 | ) 16 | USERVAR_CODE = "CE2.set(%(varnr)d, '%(value)s');" 17 | 18 | 19 | register = Library() 20 | 21 | 22 | @register.tag 23 | def crazy_egg(parser, token): 24 | """ 25 | Crazy Egg tracking template tag. 26 | 27 | Renders JavaScript code to track page clicks. You must supply 28 | your Crazy Egg account number (as a string) in the 29 | ``CRAZY_EGG_ACCOUNT_NUMBER`` setting. 30 | """ 31 | bits = token.split_contents() 32 | if len(bits) > 1: 33 | raise TemplateSyntaxError("'%s' takes no arguments" % bits[0]) 34 | return CrazyEggNode() 35 | 36 | 37 | class CrazyEggNode(Node): 38 | def __init__(self): 39 | self.account_nr = get_required_setting( 40 | 'CRAZY_EGG_ACCOUNT_NUMBER', 41 | ACCOUNT_NUMBER_RE, 42 | 'must be (a string containing) a number', 43 | ) 44 | 45 | def render(self, context): 46 | html = SETUP_CODE % { 47 | 'account_nr_1': self.account_nr[:4], 48 | 'account_nr_2': self.account_nr[4:], 49 | } 50 | values = (context.get('crazy_egg_var%d' % i) for i in range(1, 6)) 51 | params = [(i, v) for i, v in enumerate(values, 1) if v is not None] 52 | if params: 53 | js = ' '.join( 54 | USERVAR_CODE 55 | % { 56 | 'varnr': varnr, 57 | 'value': value, 58 | } 59 | for (varnr, value) in params 60 | ) 61 | html = '%s\n' % (html, js) 62 | if is_internal_ip(context, 'CRAZY_EGG'): 63 | html = disable_html(html, 'Crazy Egg') 64 | return html 65 | 66 | 67 | def contribute_to_analytical(add_node): 68 | CrazyEggNode() # ensure properly configured 69 | add_node('body_bottom', CrazyEggNode) 70 | -------------------------------------------------------------------------------- /tests/unit/test_tag_kiss_insights.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the KISSinsights template tags and filters. 3 | """ 4 | 5 | import pytest 6 | from django.contrib.auth.models import AnonymousUser, User 7 | from django.template import Context 8 | from django.test.utils import override_settings 9 | from utils import TagTestCase 10 | 11 | from analytical.templatetags.kiss_insights import KissInsightsNode 12 | from analytical.utils import AnalyticalException 13 | 14 | 15 | @override_settings(KISS_INSIGHTS_ACCOUNT_NUMBER='12345', KISS_INSIGHTS_SITE_CODE='abc') 16 | class KissInsightsTagTestCase(TagTestCase): 17 | """ 18 | Tests for the ``kiss_insights`` template tag. 19 | """ 20 | 21 | def test_tag(self): 22 | r = self.render_tag('kiss_insights', 'kiss_insights') 23 | assert '//s3.amazonaws.com/ki.js/12345/abc.js' in r 24 | 25 | def test_node(self): 26 | r = KissInsightsNode().render(Context()) 27 | assert '//s3.amazonaws.com/ki.js/12345/abc.js' in r 28 | 29 | @override_settings(KISS_INSIGHTS_ACCOUNT_NUMBER=None) 30 | def test_no_account_number(self): 31 | with pytest.raises(AnalyticalException): 32 | KissInsightsNode() 33 | 34 | @override_settings(KISS_INSIGHTS_SITE_CODE=None) 35 | def test_no_site_code(self): 36 | with pytest.raises(AnalyticalException): 37 | KissInsightsNode() 38 | 39 | @override_settings(KISS_INSIGHTS_ACCOUNT_NUMBER='abcde') 40 | def test_wrong_account_number(self): 41 | with pytest.raises(AnalyticalException): 42 | KissInsightsNode() 43 | 44 | @override_settings(KISS_INSIGHTS_SITE_CODE='abc def') 45 | def test_wrong_site_id(self): 46 | with pytest.raises(AnalyticalException): 47 | KissInsightsNode() 48 | 49 | @override_settings(ANALYTICAL_AUTO_IDENTIFY=True) 50 | def test_identify(self): 51 | r = KissInsightsNode().render(Context({'user': User(username='test')})) 52 | assert "_kiq.push(['identify', 'test']);" in r 53 | 54 | @override_settings(ANALYTICAL_AUTO_IDENTIFY=True) 55 | def test_identify_anonymous_user(self): 56 | r = KissInsightsNode().render(Context({'user': AnonymousUser()})) 57 | assert "_kiq.push(['identify', " not in r 58 | 59 | def test_show_survey(self): 60 | r = KissInsightsNode().render(Context({'kiss_insights_show_survey': 1234})) 61 | assert "_kiq.push(['showSurvey', 1234]);" in r 62 | -------------------------------------------------------------------------------- /docs/services/hotjar.rst: -------------------------------------------------------------------------------- 1 | ===================================== 2 | Hotjar -- analytics and user feedback 3 | ===================================== 4 | 5 | `Hotjar`_ is a website analytics and user feedback tool. 6 | 7 | .. _`Hotjar`: https://www.hotjar.com/ 8 | 9 | 10 | .. hotjar-installation: 11 | 12 | Installation 13 | ============ 14 | 15 | To start using the Hotjar integration, you must have installed the 16 | django-analytical package and have added the ``analytical`` application 17 | to :const:`INSTALLED_APPS` in your project :file:`settings.py` file. 18 | See :doc:`../install` for details. 19 | 20 | Next you need to add the Hotjar template tag to your templates. 21 | This step is only needed if you are not using the generic 22 | :ttag:`analytical.*` tags. If you are, skip to 23 | :ref:`hotjar-configuration`. 24 | 25 | The Hotjar code is inserted into templates using template tags. 26 | Because every page that you want to track must have the tag, 27 | it is useful to add it to your base template. 28 | At the top of the template, load the :mod:`hotjar` template tag library. 29 | Then insert the :ttag:`hotjar` tag at the bottom of the head section:: 30 | 31 | {% load hotjar %} 32 | 33 | 34 | ... 35 | {% hotjar %} 36 | 37 | ... 38 | 39 | 40 | 41 | .. _hotjar-configuration: 42 | 43 | Configuration 44 | ============= 45 | 46 | Before you can use the Hotjar integration, you must first set your Site ID. 47 | 48 | 49 | .. _hotjar-id: 50 | 51 | Setting the Hotjar Site ID 52 | -------------------------- 53 | 54 | You can find the Hotjar Site ID in the "Sites & Organizations" section of your Hotjar account. 55 | Set :const:`HOTJAR_SITE_ID` in the project :file:`settings.py` file:: 56 | 57 | HOTJAR_SITE_ID = 'XXXXXXXXX' 58 | 59 | If you do not set a Hotjar Site ID, the code will not be rendered. 60 | 61 | 62 | .. _hotjar-internal-ips: 63 | 64 | Internal IP addresses 65 | --------------------- 66 | 67 | Usually you do not want to track clicks from your development or 68 | internal IP addresses. By default, if the tags detect that the client 69 | comes from any address in the :const:`HOTJAR_INTERNAL_IPS` 70 | setting, the tracking code is commented out. It takes the value of 71 | :const:`ANALYTICAL_INTERNAL_IPS` by default (which in turn is 72 | :const:`INTERNAL_IPS` by default). See :ref:`identifying-visitors` for 73 | important information about detecting the visitor IP address. 74 | -------------------------------------------------------------------------------- /analytical/templatetags/kiss_insights.py: -------------------------------------------------------------------------------- 1 | """ 2 | KISSinsights template tags. 3 | """ 4 | 5 | import re 6 | 7 | from django.template import Library, Node, TemplateSyntaxError 8 | 9 | from analytical.utils import get_identity, get_required_setting 10 | 11 | ACCOUNT_NUMBER_RE = re.compile(r'^\d+$') 12 | SITE_CODE_RE = re.compile(r'^[\w]+$') 13 | SETUP_CODE = """ 14 | 15 | 16 | """ # noqa 17 | IDENTIFY_CODE = "_kiq.push(['identify', '%s']);" 18 | SHOW_SURVEY_CODE = "_kiq.push(['showSurvey', %s]);" 19 | SHOW_SURVEY_CONTEXT_KEY = 'kiss_insights_show_survey' 20 | 21 | 22 | register = Library() 23 | 24 | 25 | @register.tag 26 | def kiss_insights(parser, token): 27 | """ 28 | KISSinsights set-up template tag. 29 | 30 | Renders JavaScript code to set-up surveys. You must supply 31 | your account number and site code in the 32 | ``KISS_INSIGHTS_ACCOUNT_NUMBER`` and ``KISS_INSIGHTS_SITE_CODE`` 33 | settings. 34 | """ 35 | bits = token.split_contents() 36 | if len(bits) > 1: 37 | raise TemplateSyntaxError("'%s' takes no arguments" % bits[0]) 38 | return KissInsightsNode() 39 | 40 | 41 | class KissInsightsNode(Node): 42 | def __init__(self): 43 | self.account_number = get_required_setting( 44 | 'KISS_INSIGHTS_ACCOUNT_NUMBER', 45 | ACCOUNT_NUMBER_RE, 46 | 'must be (a string containing) a number', 47 | ) 48 | self.site_code = get_required_setting( 49 | 'KISS_INSIGHTS_SITE_CODE', 50 | SITE_CODE_RE, 51 | 'must be a string containing three characters', 52 | ) 53 | 54 | def render(self, context): 55 | commands = [] 56 | identity = get_identity(context, 'kiss_insights') 57 | if identity is not None: 58 | commands.append(IDENTIFY_CODE % identity) 59 | try: 60 | commands.append(SHOW_SURVEY_CODE % context[SHOW_SURVEY_CONTEXT_KEY]) 61 | except KeyError: 62 | pass 63 | html = SETUP_CODE % { 64 | 'account_number': self.account_number, 65 | 'site_code': self.site_code, 66 | 'commands': ' '.join(commands), 67 | } 68 | return html 69 | 70 | 71 | def contribute_to_analytical(add_node): 72 | KissInsightsNode() # ensure properly configured 73 | add_node('body_top', KissInsightsNode) 74 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | As contributors and maintainers of the Jazzband projects, and in the interest of 4 | fostering an open and welcoming community, we pledge to respect all people who 5 | contribute through reporting issues, posting feature requests, updating documentation, 6 | submitting pull requests or patches, and other activities. 7 | 8 | We are committed to making participation in the Jazzband a harassment-free experience 9 | for everyone, regardless of the level of experience, gender, gender identity and 10 | expression, sexual orientation, disability, personal appearance, body size, race, 11 | ethnicity, age, religion, or nationality. 12 | 13 | Examples of unacceptable behavior by participants include: 14 | 15 | - The use of sexualized language or imagery 16 | - Personal attacks 17 | - Trolling or insulting/derogatory comments 18 | - Public or private harassment 19 | - Publishing other's private information, such as physical or electronic addresses, 20 | without explicit permission 21 | - Other unethical or unprofessional conduct 22 | 23 | The Jazzband roadies have the right and responsibility to remove, edit, or reject 24 | comments, commits, code, wiki edits, issues, and other contributions that are not 25 | aligned to this Code of Conduct, or to ban temporarily or permanently any contributor 26 | for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 27 | 28 | By adopting this Code of Conduct, the roadies commit themselves to fairly and 29 | consistently applying these principles to every aspect of managing the jazzband 30 | projects. Roadies who do not follow or enforce the Code of Conduct may be permanently 31 | removed from the Jazzband roadies. 32 | 33 | This code of conduct applies both within project spaces and in public spaces when an 34 | individual is representing the project or its community. 35 | 36 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by 37 | contacting the roadies at `roadies@jazzband.co`. All complaints will be reviewed and 38 | investigated and will result in a response that is deemed necessary and appropriate to 39 | the circumstances. Roadies are obligated to maintain confidentiality with regard to the 40 | reporter of an incident. 41 | 42 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 43 | 1.3.0, available at [https://contributor-covenant.org/version/1/3/0/][version] 44 | 45 | [homepage]: https://contributor-covenant.org 46 | [version]: https://contributor-covenant.org/version/1/3/0/ 47 | -------------------------------------------------------------------------------- /analytical/templatetags/rating_mailru.py: -------------------------------------------------------------------------------- 1 | """ 2 | Rating@Mail.ru template tags and filters. 3 | """ 4 | 5 | import re 6 | 7 | from django.template import Library, Node, TemplateSyntaxError 8 | 9 | from analytical.utils import disable_html, get_required_setting, is_internal_ip 10 | 11 | COUNTER_ID_RE = re.compile(r'^\d{7}$') 12 | COUNTER_CODE = """ 13 | 24 | 27 | """ # noqa 28 | 29 | 30 | register = Library() 31 | 32 | 33 | @register.tag 34 | def rating_mailru(parser, token): 35 | """ 36 | Rating@Mail.ru counter template tag. 37 | 38 | Renders JavaScript code to track page visits. You must supply 39 | your website counter ID (as a string) in the 40 | ``RATING_MAILRU_COUNTER_ID`` setting. 41 | """ 42 | bits = token.split_contents() 43 | if len(bits) > 1: 44 | raise TemplateSyntaxError("'%s' takes no arguments" % bits[0]) 45 | return RatingMailruNode() 46 | 47 | 48 | class RatingMailruNode(Node): 49 | def __init__(self): 50 | self.counter_id = get_required_setting( 51 | 'RATING_MAILRU_COUNTER_ID', 52 | COUNTER_ID_RE, 53 | "must be (a string containing) a number'", 54 | ) 55 | 56 | def render(self, context): 57 | html = COUNTER_CODE % { 58 | 'counter_id': self.counter_id, 59 | } 60 | if is_internal_ip(context, 'RATING_MAILRU_METRICA'): 61 | html = disable_html(html, 'Rating@Mail.ru') 62 | return html 63 | 64 | 65 | def contribute_to_analytical(add_node): 66 | RatingMailruNode() # ensure properly configured 67 | add_node('head_bottom', RatingMailruNode) 68 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | lint 4 | format 5 | audit 6 | # Python/Django combinations that are officially supported (minus end-of-life Pythons) 7 | py{39,310,311,312}-django{42} 8 | py{310,311,312,313}-django{51} 9 | py{310,311,312,313}-django{52} 10 | package 11 | docs 12 | clean 13 | 14 | [gh-actions] 15 | python = 16 | 3.9: py39 17 | 3.10: py310 18 | 3.11: py311 19 | 3.12: py312 20 | 3.13: py313 21 | 22 | [gh-actions:env] 23 | DJANGO = 24 | 4.2: django42 25 | 5.1: django51 26 | 5.2: django52 27 | 28 | [testenv] 29 | description = Unit tests 30 | deps = 31 | coverage[toml] 32 | pytest-django 33 | django42: Django>=4.2,<5.0 34 | django51: Django>=5.1,<5.2 35 | django52: Django>=5.2,<6.0 36 | commands = 37 | coverage run -m pytest {posargs} 38 | coverage report 39 | coverage xml 40 | 41 | [testenv:audit] 42 | description = Scan for vulnerable dependencies 43 | skip_install = true 44 | deps = 45 | pip-audit 46 | uv 47 | commands = 48 | uv export --no-emit-project --no-hashes -o requirements.txt -q 49 | pip-audit {posargs:-r requirements.txt --progress-spinner off} 50 | 51 | [testenv:bandit] 52 | description = PyCQA security linter 53 | skip_install = true 54 | deps = bandit 55 | commands = bandit {posargs:-r analytical} -v 56 | 57 | [testenv:clean] 58 | description = Clean up bytecode and build artifacts 59 | skip_install = true 60 | deps = pyclean 61 | commands = pyclean {posargs:. --debris cache coverage package pytest mypy --erase requirements.txt uv.lock docs/_build/**/* docs/_build/ tests/unittests-report.xml --yes} 62 | 63 | [testenv:docs] 64 | description = Build the HTML documentation 65 | deps = sphinx 66 | commands = sphinx-build -b html -d docs/_build/doctrees docs docs/_build/html 67 | 68 | [testenv:format] 69 | description = Ensure consistent code style (Ruff) 70 | skip_install = true 71 | deps = ruff 72 | commands = ruff format {posargs:--check --diff .} 73 | 74 | [testenv:lint] 75 | description = Lightening-fast linting (Ruff) 76 | skip_install = true 77 | deps = ruff 78 | commands = ruff check {posargs:--output-format=full .} 79 | 80 | [testenv:mypy] 81 | description = Perform static type checking 82 | deps = mypy 83 | commands = mypy {posargs:.} 84 | 85 | [testenv:package] 86 | description = Build package and check metadata (or upload package) 87 | skip_install = true 88 | deps = 89 | build 90 | twine 91 | commands = 92 | python -m build 93 | twine {posargs:check --strict} dist/* 94 | passenv = 95 | TWINE_USERNAME 96 | TWINE_PASSWORD 97 | TWINE_REPOSITORY_URL 98 | -------------------------------------------------------------------------------- /analytical/templatetags/performable.py: -------------------------------------------------------------------------------- 1 | """ 2 | Performable template tags and filters. 3 | """ 4 | 5 | import re 6 | 7 | from django.template import Library, Node, TemplateSyntaxError 8 | from django.utils.safestring import mark_safe 9 | 10 | from analytical.utils import ( 11 | disable_html, 12 | get_identity, 13 | get_required_setting, 14 | is_internal_ip, 15 | ) 16 | 17 | API_KEY_RE = re.compile(r'^\w+$') 18 | SETUP_CODE = """ 19 | 20 | """ # noqa 21 | IDENTIFY_CODE = """ 22 | 26 | """ 27 | EMBED_CODE = """ 28 | 29 | 36 | """ # noqa 37 | 38 | register = Library() 39 | 40 | 41 | @register.tag 42 | def performable(parser, token): 43 | """ 44 | Performable template tag. 45 | 46 | Renders JavaScript code to set-up Performable tracking. You must 47 | supply your Performable API key in the ``PERFORMABLE_API_KEY`` 48 | setting. 49 | """ 50 | bits = token.split_contents() 51 | if len(bits) > 1: 52 | raise TemplateSyntaxError("'%s' takes no arguments" % bits[0]) 53 | return PerformableNode() 54 | 55 | 56 | class PerformableNode(Node): 57 | def __init__(self): 58 | self.api_key = get_required_setting( 59 | 'PERFORMABLE_API_KEY', API_KEY_RE, "must be a string looking like 'XXXXX'" 60 | ) 61 | 62 | def render(self, context): 63 | html = SETUP_CODE % {'api_key': self.api_key} 64 | identity = get_identity(context, 'performable') 65 | if identity is not None: 66 | html = '%s%s' % (IDENTIFY_CODE % identity, html) 67 | if is_internal_ip(context, 'PERFORMABLE'): 68 | html = disable_html(html, 'Performable') 69 | return html 70 | 71 | 72 | @register.simple_tag 73 | def performable_embed(hostname, page_id): 74 | """ 75 | Include a Performable landing page. 76 | """ 77 | return mark_safe( 78 | EMBED_CODE 79 | % { 80 | 'hostname': hostname, 81 | 'page_id': page_id, 82 | } 83 | ) 84 | 85 | 86 | def contribute_to_analytical(add_node): 87 | PerformableNode() # ensure properly configured 88 | add_node('body_bottom', PerformableNode) 89 | -------------------------------------------------------------------------------- /tests/unit/test_tag_performable.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the Performable template tags and filters. 3 | """ 4 | 5 | import pytest 6 | from django.contrib.auth.models import AnonymousUser, User 7 | from django.http import HttpRequest 8 | from django.template import Context 9 | from django.test.utils import override_settings 10 | from utils import TagTestCase 11 | 12 | from analytical.templatetags.performable import PerformableNode 13 | from analytical.utils import AnalyticalException 14 | 15 | 16 | @override_settings(PERFORMABLE_API_KEY='123ABC') 17 | class PerformableTagTestCase(TagTestCase): 18 | """ 19 | Tests for the ``performable`` template tag. 20 | """ 21 | 22 | def test_tag(self): 23 | r = self.render_tag('performable', 'performable') 24 | assert '/performable/pax/123ABC.js' in r 25 | 26 | def test_node(self): 27 | r = PerformableNode().render(Context()) 28 | assert '/performable/pax/123ABC.js' in r 29 | 30 | @override_settings(PERFORMABLE_API_KEY=None) 31 | def test_no_api_key(self): 32 | with pytest.raises(AnalyticalException): 33 | PerformableNode() 34 | 35 | @override_settings(PERFORMABLE_API_KEY='123 ABC') 36 | def test_wrong_account_number(self): 37 | with pytest.raises(AnalyticalException): 38 | PerformableNode() 39 | 40 | @override_settings(ANALYTICAL_INTERNAL_IPS=['1.1.1.1']) 41 | def test_render_internal_ip(self): 42 | req = HttpRequest() 43 | req.META['REMOTE_ADDR'] = '1.1.1.1' 44 | context = Context({'request': req}) 45 | r = PerformableNode().render(context) 46 | assert r.startswith('') 48 | 49 | @override_settings(ANALYTICAL_AUTO_IDENTIFY=True) 50 | def test_identify(self): 51 | r = PerformableNode().render(Context({'user': User(username='test')})) 52 | assert '_paq.push(["identify", {identity: "test"}]);' in r 53 | 54 | @override_settings(ANALYTICAL_AUTO_IDENTIFY=True) 55 | def test_identify_anonymous_user(self): 56 | r = PerformableNode().render(Context({'user': AnonymousUser()})) 57 | assert '_paq.push(["identify", ' not in r 58 | 59 | 60 | class PerformableEmbedTagTestCase(TagTestCase): 61 | """ 62 | Tests for the ``performable_embed`` template tag. 63 | """ 64 | 65 | def test_tag(self): 66 | domain = 'example.com' 67 | page = 'test' 68 | tag = self.render_tag('performable', f'performable_embed "{domain}" "{page}"') 69 | assert "$f.initialize({'host': 'example.com', 'page': 'test'});" in tag 70 | -------------------------------------------------------------------------------- /analytical/templatetags/google_analytics_gtag.py: -------------------------------------------------------------------------------- 1 | """ 2 | Google Analytics template tags and filters, using the new gtag.js library. 3 | https://developers.google.com/tag-platform/gtagjs/reference 4 | """ 5 | 6 | import json 7 | import re 8 | 9 | from django.template import Library, Node, TemplateSyntaxError 10 | 11 | from analytical.utils import ( 12 | disable_html, 13 | get_identity, 14 | get_required_setting, 15 | is_internal_ip, 16 | ) 17 | 18 | PROPERTY_ID_RE = re.compile( 19 | r'^UA-\d+-\d+$|^G-[a-zA-Z0-9]+$|^AW-[a-zA-Z0-9]+$|^DC-[a-zA-Z0-9]+$' 20 | ) 21 | SETUP_CODE = """ 22 | 23 | 30 | """ 31 | 32 | register = Library() 33 | 34 | 35 | @register.tag 36 | def google_analytics_gtag(parser, token): 37 | """ 38 | Google Analytics tracking template tag. 39 | 40 | Renders JavaScript code to track page visits. You must supply 41 | your website property ID (as a string) in the 42 | ``GOOGLE_ANALYTICS_GTAG_PROPERTY_ID`` setting. 43 | """ 44 | bits = token.split_contents() 45 | if len(bits) > 1: 46 | raise TemplateSyntaxError("'%s' takes no arguments" % bits[0]) 47 | return GoogleAnalyticsGTagNode() 48 | 49 | 50 | class GoogleAnalyticsGTagNode(Node): 51 | def __init__(self): 52 | self.property_id = get_required_setting( 53 | 'GOOGLE_ANALYTICS_GTAG_PROPERTY_ID', 54 | PROPERTY_ID_RE, 55 | """must be a string looking like one of these patterns 56 | ('UA-XXXXXX-Y' , 'AW-XXXXXXXXXX', 57 | 'G-XXXXXXXX', 'DC-XXXXXXXX')""", 58 | ) 59 | 60 | def render(self, context): 61 | custom_dimensions = context.get('google_analytics_custom_dimensions', {}) 62 | 63 | identity = get_identity(context, prefix='google_analytics_gtag') 64 | if identity is not None: 65 | custom_dimensions['user_id'] = identity 66 | 67 | html = SETUP_CODE.format( 68 | property_id=self.property_id, 69 | custom_dimensions=json.dumps(custom_dimensions), 70 | ) 71 | if is_internal_ip(context, 'GOOGLE_ANALYTICS'): 72 | html = disable_html(html, 'Google Analytics') 73 | return html 74 | 75 | 76 | def contribute_to_analytical(add_node): 77 | GoogleAnalyticsGTagNode() # ensure properly configured 78 | add_node('head_top', GoogleAnalyticsGTagNode) 79 | -------------------------------------------------------------------------------- /analytical/templatetags/gosquared.py: -------------------------------------------------------------------------------- 1 | """ 2 | GoSquared template tags and filters. 3 | """ 4 | 5 | import re 6 | 7 | from django.template import Library, Node, TemplateSyntaxError 8 | 9 | from analytical.utils import ( 10 | disable_html, 11 | get_identity, 12 | get_required_setting, 13 | is_internal_ip, 14 | ) 15 | 16 | TOKEN_RE = re.compile(r'^\S+-\S+-\S+$') 17 | TRACKING_CODE = """ 18 | 30 | """ # noqa 31 | TOKEN_CODE = 'GoSquared.acct = "%s";' 32 | IDENTIFY_CODE = 'GoSquared.UserName = "%s";' 33 | 34 | 35 | register = Library() 36 | 37 | 38 | @register.tag 39 | def gosquared(parser, token): 40 | """ 41 | GoSquared tracking template tag. 42 | 43 | Renders JavaScript code to track page visits. You must supply 44 | your GoSquared site token in the ``GOSQUARED_SITE_TOKEN`` setting. 45 | """ 46 | bits = token.split_contents() 47 | if len(bits) > 1: 48 | raise TemplateSyntaxError("'%s' takes no arguments" % bits[0]) 49 | return GoSquaredNode() 50 | 51 | 52 | class GoSquaredNode(Node): 53 | def __init__(self): 54 | self.site_token = get_required_setting( 55 | 'GOSQUARED_SITE_TOKEN', 56 | TOKEN_RE, 57 | 'must be a string looking like XXX-XXXXXX-X', 58 | ) 59 | 60 | def render(self, context): 61 | configs = [TOKEN_CODE % self.site_token] 62 | identity = get_identity(context, 'gosquared', self._identify) 63 | if identity: 64 | configs.append(IDENTIFY_CODE % identity) 65 | html = TRACKING_CODE % { 66 | 'token': self.site_token, 67 | 'config': ' '.join(configs), 68 | } 69 | if is_internal_ip(context, 'GOSQUARED'): 70 | html = disable_html(html, 'GoSquared') 71 | return html 72 | 73 | def _identify(self, user): 74 | name = user.get_full_name() 75 | if not name: 76 | name = user.username 77 | return name 78 | 79 | 80 | def contribute_to_analytical(add_node): 81 | GoSquaredNode() # ensure properly configured 82 | add_node('body_bottom', GoSquaredNode) 83 | -------------------------------------------------------------------------------- /tests/unit/test_tag_gauges.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the Gauges template tags and filters. 3 | """ 4 | 5 | import pytest 6 | from django.http import HttpRequest 7 | from django.template import Context 8 | from django.test.utils import override_settings 9 | from utils import TagTestCase 10 | 11 | from analytical.templatetags.gauges import GaugesNode 12 | from analytical.utils import AnalyticalException 13 | 14 | 15 | @override_settings(GAUGES_SITE_ID='1234567890abcdef0123456789') 16 | class GaugesTagTestCase(TagTestCase): 17 | """ 18 | Tests for the ``gauges`` template tag. 19 | """ 20 | 21 | def test_tag(self): 22 | assert ( 23 | self.render_tag('gauges', 'gauges') 24 | == """ 25 | 38 | """ 39 | ) 40 | 41 | def test_node(self): 42 | assert ( 43 | GaugesNode().render(Context()) 44 | == """ 45 | 58 | """ 59 | ) 60 | 61 | @override_settings(GAUGES_SITE_ID=None) 62 | def test_no_account_number(self): 63 | with pytest.raises(AnalyticalException): 64 | GaugesNode() 65 | 66 | @override_settings(GAUGES_SITE_ID='123abQ') 67 | def test_wrong_account_number(self): 68 | self.assertRaises(AnalyticalException, GaugesNode) 69 | 70 | @override_settings(ANALYTICAL_INTERNAL_IPS=['1.1.1.1']) 71 | def test_render_internal_ip(self): 72 | req = HttpRequest() 73 | req.META['REMOTE_ADDR'] = '1.1.1.1' 74 | context = Context({'request': req}) 75 | r = GaugesNode().render(context) 76 | assert r.startswith('') 78 | -------------------------------------------------------------------------------- /docs/services/luckyorange.rst: -------------------------------------------------------------------------------- 1 | ================================================== 2 | Lucky Orange -- All-in-one conversion optimization 3 | ================================================== 4 | 5 | `Lucky Orange`_ is a website analytics and user feedback tool. 6 | 7 | .. _`Lucky Orange`: https://www.luckyorange.com/ 8 | 9 | 10 | .. luckyorange-installation: 11 | 12 | Installation 13 | ============ 14 | 15 | To start using the Lucky Orange integration, you must have installed the 16 | django-analytical package and have added the ``analytical`` application 17 | to :const:`INSTALLED_APPS` in your project :file:`settings.py` file. 18 | See :doc:`../install` for details. 19 | 20 | Next you need to add the Lucky Orange template tag to your templates. 21 | This step is only needed if you are not using the generic 22 | :ttag:`analytical.*` tags. If you are, skip to 23 | :ref:`luckyorange-configuration`. 24 | 25 | The Lucky Orange tracking code is inserted into templates using template 26 | tags. Because every page that you want to track must have the tag, it 27 | is useful to add it to your base template. At the top of the template, 28 | load the :mod:`luckyorange` template tag library. Then insert the 29 | :ttag:`luckyorange` tag at the bottom of the head section:: 30 | 31 | {% load luckyorange %} 32 | 33 | 34 | ... 35 | {% luckyorange %} 36 | 37 | ... 38 | 39 | 40 | 41 | .. _luckyorange-configuration: 42 | 43 | Configuration 44 | ============= 45 | 46 | Before you can use the Lucky Orange integration, you must first set your 47 | Site ID. 48 | 49 | 50 | .. _luckyorange-id: 51 | 52 | Setting the Lucky Orange Site ID 53 | -------------------------------- 54 | 55 | You can find the Lucky Orange Site ID in the "Settings" of your Lucky 56 | Orange account, reachable via the gear icon on the top right corner. 57 | Set :const:`LUCKYORANGE_SITE_ID` in the project :file:`settings.py` file:: 58 | 59 | LUCKYORANGE_SITE_ID = 'XXXXXX' 60 | 61 | If you do not set a Lucky Orange Site ID, the code will not be rendered. 62 | 63 | 64 | .. _luckyorange-internal-ips: 65 | 66 | Internal IP addresses 67 | --------------------- 68 | 69 | Usually you do not want to track clicks from your development or 70 | internal IP addresses. By default, if the tags detect that the client 71 | comes from any address in the :const:`LUCKYORANGE_INTERNAL_IPS` 72 | setting, the tracking code is commented out. It takes the value of 73 | :const:`ANALYTICAL_INTERNAL_IPS` by default (which in turn is 74 | :const:`INTERNAL_IPS` by default). See :ref:`identifying-visitors` for 75 | important information about detecting the visitor IP address. 76 | -------------------------------------------------------------------------------- /docs/services/rating_mailru.rst: -------------------------------------------------------------------------------- 1 | =================================== 2 | Rating\@Mail.ru -- traffic analysis 3 | =================================== 4 | 5 | `Rating\@Mail.ru`_ is an analytics tool like as google analytics. 6 | 7 | .. _`Rating\@Mail.ru`: http://top.mail.ru/ 8 | 9 | 10 | .. rating-mailru-installation: 11 | 12 | Installation 13 | ============ 14 | 15 | To start using the Rating\@Mail.ru integration, you must have installed the 16 | django-analytical package and have added the ``analytical`` application 17 | to :const:`INSTALLED_APPS` in your project :file:`settings.py` file. 18 | See :doc:`../install` for details. 19 | 20 | Next you need to add the Rating\@Mail.ru template tag to your templates. This 21 | step is only needed if you are not using the generic 22 | :ttag:`analytical.*` tags. If you are, skip to 23 | :ref:`rating-mailru-configuration`. 24 | 25 | The Rating\@Mail.ru counter code is inserted into templates using a template 26 | tag. Load the :mod:`rating_mailru` template tag library and insert the 27 | :ttag:`rating_mailru` tag. Because every page that you want to track must 28 | have the tag, it is useful to add it to your base template. Insert 29 | the tag at the bottom of the HTML head:: 30 | 31 | {% load rating_mailru %} 32 | 33 | 34 | ... 35 | {% rating_mailru %} 36 | 37 | ... 38 | 39 | 40 | .. _rating-mailru-configuration: 41 | 42 | Configuration 43 | ============= 44 | 45 | Before you can use the Rating\@Mail.ru integration, you must first set 46 | your website counter ID. 47 | 48 | 49 | .. _rating-mailru-counter-id: 50 | 51 | Setting the counter ID 52 | ---------------------- 53 | 54 | Every website you track with Rating\@Mail.ru gets its own counter ID, 55 | and the :ttag:`rating_mailru` tag will include it in the rendered 56 | JavaScript code. You can find the web counter ID on the overview page 57 | of your account. Set :const:`RATING_MAILRU_COUNTER_ID` in the 58 | project :file:`settings.py` file:: 59 | 60 | RATING_MAILRU_COUNTER_ID = '1234567' 61 | 62 | If you do not set a counter ID, the counter code will not be rendered. 63 | 64 | Internal IP addresses 65 | --------------------- 66 | 67 | Usually you do not want to track clicks from your development or 68 | internal IP addresses. By default, if the tags detect that the client 69 | comes from any address in the :const:`RATING_MAILRU_INTERNAL_IPS` setting, 70 | the tracking code is commented out. It takes the value of 71 | :const:`ANALYTICAL_INTERNAL_IPS` by default (which in turn is 72 | :const:`INTERNAL_IPS` by default). See :ref:`identifying-visitors` for 73 | important information about detecting the visitor IP address. 74 | -------------------------------------------------------------------------------- /analytical/templatetags/clicky.py: -------------------------------------------------------------------------------- 1 | """ 2 | Clicky template tags and filters. 3 | """ 4 | 5 | import json 6 | import re 7 | 8 | from django.template import Library, Node, TemplateSyntaxError 9 | 10 | from analytical.utils import ( 11 | disable_html, 12 | get_identity, 13 | get_required_setting, 14 | is_internal_ip, 15 | ) 16 | 17 | SITE_ID_RE = re.compile(r'^\d+$') 18 | TRACKING_CODE = """ 19 | 32 | 33 | """ # noqa 34 | 35 | register = Library() 36 | 37 | 38 | @register.tag 39 | def clicky(parser, token): 40 | """ 41 | Clicky tracking template tag. 42 | 43 | Renders JavaScript code to track page visits. You must supply 44 | your Clicky Site ID (as a string) in the ``CLICKY_SITE_ID`` 45 | setting. 46 | """ 47 | bits = token.split_contents() 48 | if len(bits) > 1: 49 | raise TemplateSyntaxError("'%s' takes no arguments" % bits[0]) 50 | return ClickyNode() 51 | 52 | 53 | class ClickyNode(Node): 54 | def __init__(self): 55 | self.site_id = get_required_setting( 56 | 'CLICKY_SITE_ID', SITE_ID_RE, 'must be a (string containing) a number' 57 | ) 58 | 59 | def render(self, context): 60 | custom = {} 61 | for dict_ in context: 62 | for var, val in dict_.items(): 63 | if var.startswith('clicky_'): 64 | custom[var[7:]] = val 65 | if 'username' not in custom.get('session', {}): 66 | identity = get_identity(context, 'clicky') 67 | if identity is not None: 68 | custom.setdefault('session', {})['username'] = identity 69 | 70 | html = TRACKING_CODE % { 71 | 'site_id': self.site_id, 72 | 'custom': json.dumps(custom, sort_keys=True), 73 | } 74 | if is_internal_ip(context, 'CLICKY'): 75 | html = disable_html(html, 'Clicky') 76 | return html 77 | 78 | 79 | def contribute_to_analytical(add_node): 80 | ClickyNode() # ensure properly configured 81 | add_node('body_bottom', ClickyNode) 82 | -------------------------------------------------------------------------------- /tests/unit/test_tag_clicky.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the Clicky template tags and filters. 3 | """ 4 | 5 | import re 6 | 7 | import pytest 8 | from django.contrib.auth.models import AnonymousUser, User 9 | from django.http import HttpRequest 10 | from django.template import Context 11 | from django.test.utils import override_settings 12 | from utils import TagTestCase 13 | 14 | from analytical.templatetags.clicky import ClickyNode 15 | from analytical.utils import AnalyticalException 16 | 17 | 18 | @override_settings(CLICKY_SITE_ID='12345678') 19 | class ClickyTagTestCase(TagTestCase): 20 | """ 21 | Tests for the ``clicky`` template tag. 22 | """ 23 | 24 | def test_tag(self): 25 | r = self.render_tag('clicky', 'clicky') 26 | assert 'clicky_site_ids.push(12345678);' in r 27 | assert 'src="//in.getclicky.com/12345678ns.gif"' in r 28 | 29 | def test_node(self): 30 | r = ClickyNode().render(Context({})) 31 | assert 'clicky_site_ids.push(12345678);' in r 32 | assert 'src="//in.getclicky.com/12345678ns.gif"' in r 33 | 34 | @override_settings(CLICKY_SITE_ID=None) 35 | def test_no_site_id(self): 36 | with pytest.raises(AnalyticalException): 37 | ClickyNode() 38 | 39 | @override_settings(CLICKY_SITE_ID='123abc') 40 | def test_wrong_site_id(self): 41 | with pytest.raises(AnalyticalException): 42 | ClickyNode() 43 | 44 | @override_settings(ANALYTICAL_AUTO_IDENTIFY=True) 45 | def test_identify(self): 46 | r = ClickyNode().render(Context({'user': User(username='test')})) 47 | assert 'var clicky_custom = {"session": {"username": "test"}};' in r 48 | 49 | @override_settings(ANALYTICAL_AUTO_IDENTIFY=True) 50 | def test_identify_anonymous_user(self): 51 | r = ClickyNode().render(Context({'user': AnonymousUser()})) 52 | assert 'var clicky_custom = {"session": {"username":' not in r 53 | 54 | def test_custom(self): 55 | r = ClickyNode().render( 56 | Context( 57 | { 58 | 'clicky_var1': 'val1', 59 | 'clicky_var2': 'val2', 60 | } 61 | ) 62 | ) 63 | assert re.search( 64 | r'var clicky_custom = {.*"var1": "val1", "var2": "val2".*};', r 65 | ) 66 | 67 | @override_settings(ANALYTICAL_INTERNAL_IPS=['1.1.1.1']) 68 | def test_render_internal_ip(self): 69 | req = HttpRequest() 70 | req.META['REMOTE_ADDR'] = '1.1.1.1' 71 | context = Context({'request': req}) 72 | r = ClickyNode().render(context) 73 | assert r.startswith('') 75 | -------------------------------------------------------------------------------- /docs/services/clickmap.rst: -------------------------------------------------------------------------------- 1 | ================================== 2 | Clickmap -- visual click tracking 3 | ================================== 4 | 5 | `Clickmap`_ is a real-time heatmap tool to track mouse clicks and scroll paths of your website visitors. 6 | Gain intelligence about what's hot and what's not, and optimize your conversion with Clickmap. 7 | 8 | .. _`Clickmap`: http://www.clickmap.ch/ 9 | 10 | 11 | .. clickmap-installation: 12 | 13 | Installation 14 | ============ 15 | 16 | To start using the Clickmap integration, you must have installed the 17 | django-analytical package and have added the ``analytical`` application 18 | to :const:`INSTALLED_APPS` in your project :file:`settings.py` file. 19 | See :doc:`../install` for details. 20 | 21 | Next you need to add the Clickmap template tag to your templates. 22 | This step is only needed if you are not using the generic 23 | :ttag:`analytical.*` tags. If you are, skip to 24 | :ref:`clickmap-configuration`. 25 | 26 | The Clickmap JavaScript code is inserted into templates using a template 27 | tag. Load the :mod:`clickmap` template tag library and insert the 28 | :ttag:`clickmap` tag. Because every page that you want to track must 29 | have the tag, it is useful to add it to your base template. Insert 30 | the tag at the bottom of the HTML body:: 31 | 32 | {% load clickmap %} 33 | ... 34 | {% clickmap %} 35 | 36 | 37 | 38 | 39 | .. _clickmap-configuration: 40 | 41 | Configuration 42 | ============= 43 | 44 | Before you can use the Clickmap integration, you must first set your 45 | Clickmap Tracker ID. If you don't have a Clickmap account yet, 46 | `sign up`_ to get your Tracker ID. 47 | 48 | .. _`sign up`: http://www.clickmap.ch/ 49 | 50 | 51 | .. _clickmap-tracker-id: 52 | 53 | Setting the Tracker ID 54 | ---------------------- 55 | 56 | Clickmap gives you a unique Tracker ID, and the :ttag:`clickmap` 57 | tag will include it in the rendered JavaScript code. You can find your 58 | Tracker ID clicking the link named "Tracker" in the dashboard 59 | of your Clickmap account. Set :const:`CLICKMAP_TRACKER_ID` in the project 60 | :file:`settings.py` file:: 61 | 62 | CLICKMAP_TRACKER_ID = 'XXXXXXXX' 63 | 64 | If you do not set an Tracker ID, the tracking code will not be 65 | rendered. 66 | 67 | 68 | .. _clickmap-internal-ips: 69 | 70 | Internal IP addresses 71 | --------------------- 72 | 73 | Usually you do not want to track clicks from your development or 74 | internal IP addresses. By default, if the tags detect that the client 75 | comes from any address in the :const:`ANALYTICAL_INTERNAL_IPS` setting 76 | (which is :const:`INTERNAL_IPS` by default,) the tracking code is 77 | commented out. See :ref:`identifying-visitors` for important information 78 | about detecting the visitor IP address. 79 | -------------------------------------------------------------------------------- /tests/unit/test_tag_uservoice.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the UserVoice tags and filters. 3 | """ 4 | 5 | import pytest 6 | from django.template import Context 7 | from django.test.utils import override_settings 8 | from utils import TagTestCase 9 | 10 | from analytical.templatetags.uservoice import UserVoiceNode 11 | from analytical.utils import AnalyticalException 12 | 13 | 14 | @override_settings(USERVOICE_WIDGET_KEY='abcdefghijklmnopqrst') 15 | class UserVoiceTagTestCase(TagTestCase): 16 | """ 17 | Tests for the ``uservoice`` template tag. 18 | """ 19 | 20 | def test_node(self): 21 | r = UserVoiceNode().render(Context()) 22 | assert 'widget.uservoice.com/abcdefghijklmnopqrst.js' in r 23 | 24 | def test_tag(self): 25 | r = self.render_tag('uservoice', 'uservoice') 26 | assert 'widget.uservoice.com/abcdefghijklmnopqrst.js' in r 27 | 28 | @override_settings(USERVOICE_WIDGET_KEY=None) 29 | def test_no_key(self): 30 | with pytest.raises(AnalyticalException): 31 | UserVoiceNode() 32 | 33 | @override_settings(USERVOICE_WIDGET_KEY='abcdefgh ijklmnopqrst') 34 | def test_invalid_key(self): 35 | with pytest.raises(AnalyticalException): 36 | UserVoiceNode() 37 | 38 | @override_settings(USERVOICE_WIDGET_KEY='') 39 | def test_empty_key(self): 40 | with pytest.raises(AnalyticalException): 41 | UserVoiceNode() 42 | 43 | def test_overridden_key(self): 44 | vars = {'uservoice_widget_key': 'defghijklmnopqrstuvw'} 45 | r = UserVoiceNode().render(Context(vars)) 46 | assert 'widget.uservoice.com/defghijklmnopqrstuvw.js' in r 47 | 48 | @override_settings(USERVOICE_WIDGET_OPTIONS={'key1': 'val1'}) 49 | def test_options(self): 50 | r = UserVoiceNode().render(Context()) 51 | assert """UserVoice.push(['set', {"key1": "val1"}]);""" in r 52 | 53 | @override_settings(USERVOICE_WIDGET_OPTIONS={'key1': 'val1'}) 54 | def test_override_options(self): 55 | data = {'uservoice_widget_options': {'key1': 'val2'}} 56 | r = UserVoiceNode().render(Context(data)) 57 | assert """UserVoice.push(['set', {"key1": "val2"}]);""" in r 58 | 59 | def test_auto_trigger_default(self): 60 | r = UserVoiceNode().render(Context()) 61 | assert "UserVoice.push(['addTrigger', {}]);" in r 62 | 63 | @override_settings(USERVOICE_ADD_TRIGGER=False) 64 | def test_auto_trigger(self): 65 | r = UserVoiceNode().render(Context()) 66 | assert "UserVoice.push(['addTrigger', {}]);" not in r 67 | 68 | @override_settings(USERVOICE_ADD_TRIGGER=False) 69 | def test_auto_trigger_custom_win(self): 70 | r = UserVoiceNode().render(Context({'uservoice_add_trigger': True})) 71 | assert "UserVoice.push(['addTrigger', {}]);" in r 72 | -------------------------------------------------------------------------------- /tests/unit/test_tag_gosquared.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the GoSquared template tags and filters. 3 | """ 4 | 5 | import pytest 6 | from django.contrib.auth.models import AnonymousUser, User 7 | from django.http import HttpRequest 8 | from django.template import Context 9 | from django.test.utils import override_settings 10 | from utils import TagTestCase 11 | 12 | from analytical.templatetags.gosquared import GoSquaredNode 13 | from analytical.utils import AnalyticalException 14 | 15 | 16 | @override_settings(GOSQUARED_SITE_TOKEN='ABC-123456-D') 17 | class GoSquaredTagTestCase(TagTestCase): 18 | """ 19 | Tests for the ``gosquared`` template tag. 20 | """ 21 | 22 | def test_tag(self): 23 | r = self.render_tag('gosquared', 'gosquared') 24 | assert 'GoSquared.acct = "ABC-123456-D";' in r 25 | 26 | def test_node(self): 27 | r = GoSquaredNode().render(Context({})) 28 | assert 'GoSquared.acct = "ABC-123456-D";' in r 29 | 30 | @override_settings(GOSQUARED_SITE_TOKEN=None) 31 | def test_no_token(self): 32 | with pytest.raises(AnalyticalException): 33 | GoSquaredNode() 34 | 35 | @override_settings(GOSQUARED_SITE_TOKEN='this is not a token') 36 | def test_wrong_token(self): 37 | with pytest.raises(AnalyticalException): 38 | GoSquaredNode() 39 | 40 | @override_settings(ANALYTICAL_AUTO_IDENTIFY=True) 41 | def test_auto_identify(self): 42 | r = GoSquaredNode().render( 43 | Context( 44 | { 45 | 'user': User(username='test', first_name='Test', last_name='User'), 46 | } 47 | ) 48 | ) 49 | assert 'GoSquared.UserName = "Test User";' in r 50 | 51 | @override_settings(ANALYTICAL_AUTO_IDENTIFY=True) 52 | def test_manual_identify(self): 53 | r = GoSquaredNode().render( 54 | Context( 55 | { 56 | 'user': User(username='test', first_name='Test', last_name='User'), 57 | 'gosquared_identity': 'test_identity', 58 | } 59 | ) 60 | ) 61 | assert 'GoSquared.UserName = "test_identity";' in r 62 | 63 | @override_settings(ANALYTICAL_AUTO_IDENTIFY=True) 64 | def test_identify_anonymous_user(self): 65 | r = GoSquaredNode().render(Context({'user': AnonymousUser()})) 66 | assert 'GoSquared.UserName = ' not in r 67 | 68 | @override_settings(ANALYTICAL_INTERNAL_IPS=['1.1.1.1']) 69 | def test_render_internal_ip(self): 70 | req = HttpRequest() 71 | req.META['REMOTE_ADDR'] = '1.1.1.1' 72 | context = Context({'request': req}) 73 | r = GoSquaredNode().render(context) 74 | assert r.startswith('') 76 | -------------------------------------------------------------------------------- /docs/services/hubspot.rst: -------------------------------------------------------------------------------- 1 | ============================ 2 | HubSpot -- inbound marketing 3 | ============================ 4 | 5 | HubSpot_ helps you get found by customers. It provides tools for 6 | content creation, conversion and marketing analysis. HubSpot uses 7 | tracking on your website to measure effect of your marketing efforts. 8 | 9 | .. _HubSpot: http://www.hubspot.com/ 10 | 11 | 12 | .. hubspot-installation: 13 | 14 | Installation 15 | ============ 16 | 17 | To start using the HubSpot integration, you must have installed the 18 | django-analytical package and have added the ``analytical`` application 19 | to :const:`INSTALLED_APPS` in your project :file:`settings.py` file. 20 | See :doc:`../install` for details. 21 | 22 | Next you need to add the HubSpot template tag to your templates. This 23 | step is only needed if you are not using the generic 24 | :ttag:`analytical.*` tags. If you are, skip to 25 | :ref:`hubspot-configuration`. 26 | 27 | The HubSpot tracking code is inserted into templates using a template 28 | tag. Load the :mod:`hubspot` template tag library and insert the 29 | :ttag:`hubspot` tag. Because every page that you want to track must 30 | have the tag, it is useful to add it to your base template. Insert 31 | the tag at the bottom of the HTML body:: 32 | 33 | {% load hubspot %} 34 | ... 35 | {% hubspot %} 36 | 37 | 38 | 39 | 40 | .. _hubspot-configuration: 41 | 42 | Configuration 43 | ============= 44 | 45 | Before you can use the HubSpot integration, you must first set your 46 | portal ID, also known as your Hub ID. 47 | 48 | 49 | .. _hubspot-portal-id: 50 | 51 | Setting the portal ID 52 | --------------------- 53 | 54 | Your HubSpot account has its own portal ID, the :ttag:`hubspot` tag 55 | will include them in the rendered JavaScript code. You can find the 56 | portal ID by accessing your dashboard. Alternatively, read this 57 | `Quick Answer page `_. 58 | Set :const:`HUBSPOT_PORTAL_ID` in the project :file:`settings.py` file:: 59 | 60 | HUBSPOT_PORTAL_ID = 'XXXX' 61 | 62 | If you do not set the portal ID, the tracking code will not be rendered. 63 | 64 | 65 | .. deprecated:: 0.18.0 66 | `HUBSPOT_DOMAIN` is no longer required. 67 | 68 | .. _hubspot-internal-ips: 69 | 70 | Internal IP addresses 71 | --------------------- 72 | 73 | Usually you do not want to track clicks from your development or 74 | internal IP addresses. By default, if the tags detect that the client 75 | comes from any address in the :const:`HUBSPOT_INTERNAL_IPS` setting, 76 | the tracking code is commented out. It takes the value of 77 | :const:`ANALYTICAL_INTERNAL_IPS` by default (which in turn is 78 | :const:`INTERNAL_IPS` by default). See :ref:`identifying-visitors` for 79 | important information about detecting the visitor IP address. 80 | -------------------------------------------------------------------------------- /tests/unit/test_tag_spring_metrics.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the Spring Metrics template tags and filters. 3 | """ 4 | 5 | import pytest 6 | from django.contrib.auth.models import AnonymousUser, User 7 | from django.http import HttpRequest 8 | from django.template import Context 9 | from django.test.utils import override_settings 10 | from utils import TagTestCase 11 | 12 | from analytical.templatetags.spring_metrics import SpringMetricsNode 13 | from analytical.utils import AnalyticalException 14 | 15 | 16 | @override_settings(SPRING_METRICS_TRACKING_ID='12345678') 17 | class SpringMetricsTagTestCase(TagTestCase): 18 | """ 19 | Tests for the ``spring_metrics`` template tag. 20 | """ 21 | 22 | def test_tag(self): 23 | r = self.render_tag('spring_metrics', 'spring_metrics') 24 | assert "_springMetq.push(['id', '12345678']);" in r 25 | 26 | def test_node(self): 27 | r = SpringMetricsNode().render(Context({})) 28 | assert "_springMetq.push(['id', '12345678']);" in r 29 | 30 | @override_settings(SPRING_METRICS_TRACKING_ID=None) 31 | def test_no_site_id(self): 32 | with pytest.raises(AnalyticalException): 33 | SpringMetricsNode() 34 | 35 | @override_settings(SPRING_METRICS_TRACKING_ID='123xyz') 36 | def test_wrong_site_id(self): 37 | with pytest.raises(AnalyticalException): 38 | SpringMetricsNode() 39 | 40 | @override_settings(ANALYTICAL_AUTO_IDENTIFY=True) 41 | def test_identify(self): 42 | r = SpringMetricsNode().render( 43 | Context( 44 | { 45 | 'user': User(email='test@test.com'), 46 | } 47 | ) 48 | ) 49 | assert "_springMetq.push(['setdata', {'email': 'test@test.com'}]);" in r 50 | 51 | @override_settings(ANALYTICAL_AUTO_IDENTIFY=True) 52 | def test_identify_anonymous_user(self): 53 | r = SpringMetricsNode().render(Context({'user': AnonymousUser()})) 54 | assert "_springMetq.push(['setdata', {'email':" not in r 55 | 56 | def test_custom(self): 57 | r = SpringMetricsNode().render( 58 | Context( 59 | { 60 | 'spring_metrics_var1': 'val1', 61 | 'spring_metrics_var2': 'val2', 62 | } 63 | ) 64 | ) 65 | assert "_springMetq.push(['setdata', {'var1': 'val1'}]);" in r 66 | assert "_springMetq.push(['setdata', {'var2': 'val2'}]);" in r 67 | 68 | @override_settings(ANALYTICAL_INTERNAL_IPS=['1.1.1.1']) 69 | def test_render_internal_ip(self): 70 | req = HttpRequest() 71 | req.META['REMOTE_ADDR'] = '1.1.1.1' 72 | context = Context({'request': req}) 73 | r = SpringMetricsNode().render(context) 74 | assert r.startswith('') 76 | -------------------------------------------------------------------------------- /tests/unit/test_tag_mixpanel.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the Mixpanel tags and filters. 3 | """ 4 | 5 | import pytest 6 | from django.contrib.auth.models import AnonymousUser, User 7 | from django.http import HttpRequest 8 | from django.template import Context 9 | from django.test.utils import override_settings 10 | from utils import TagTestCase 11 | 12 | from analytical.templatetags.mixpanel import MixpanelNode 13 | from analytical.utils import AnalyticalException 14 | 15 | 16 | @override_settings(MIXPANEL_API_TOKEN='0123456789abcdef0123456789abcdef') 17 | class MixpanelTagTestCase(TagTestCase): 18 | """ 19 | Tests for the ``mixpanel`` template tag. 20 | """ 21 | 22 | def test_tag(self): 23 | r = self.render_tag('mixpanel', 'mixpanel') 24 | assert "mixpanel.init('0123456789abcdef0123456789abcdef');" in r 25 | 26 | def test_node(self): 27 | r = MixpanelNode().render(Context()) 28 | assert "mixpanel.init('0123456789abcdef0123456789abcdef');" in r 29 | 30 | @override_settings(MIXPANEL_API_TOKEN=None) 31 | def test_no_token(self): 32 | with pytest.raises(AnalyticalException): 33 | MixpanelNode() 34 | 35 | @override_settings(MIXPANEL_API_TOKEN='0123456789abcdef0123456789abcdef0') 36 | def test_token_too_long(self): 37 | with pytest.raises(AnalyticalException): 38 | MixpanelNode() 39 | 40 | @override_settings(MIXPANEL_API_TOKEN='0123456789abcdef0123456789abcde') 41 | def test_token_too_short(self): 42 | with pytest.raises(AnalyticalException): 43 | MixpanelNode() 44 | 45 | @override_settings(ANALYTICAL_AUTO_IDENTIFY=True) 46 | def test_identify(self): 47 | r = MixpanelNode().render(Context({'user': User(username='test')})) 48 | assert "mixpanel.identify('test');" in r 49 | 50 | @override_settings(ANALYTICAL_AUTO_IDENTIFY=True) 51 | def test_identify_anonymous_user(self): 52 | r = MixpanelNode().render(Context({'user': AnonymousUser()})) 53 | assert 'mixpanel.register_once({distinct_id:' not in r 54 | 55 | def test_event(self): 56 | r = MixpanelNode().render( 57 | Context( 58 | { 59 | 'mixpanel_event': ( 60 | 'test_event', 61 | {'prop1': 'val1', 'prop2': 'val2'}, 62 | ), 63 | } 64 | ) 65 | ) 66 | assert "mixpanel.track('test_event', " 67 | '{"prop1": "val1", "prop2": "val2"});' in r 68 | 69 | @override_settings(ANALYTICAL_INTERNAL_IPS=['1.1.1.1']) 70 | def test_render_internal_ip(self): 71 | req = HttpRequest() 72 | req.META['REMOTE_ADDR'] = '1.1.1.1' 73 | context = Context({'request': req}) 74 | r = MixpanelNode().render(context) 75 | assert r.startswith('') 77 | -------------------------------------------------------------------------------- /analytical/templatetags/facebook_pixel.py: -------------------------------------------------------------------------------- 1 | """ 2 | Facebook Pixel template tags and filters. 3 | """ 4 | 5 | import re 6 | 7 | from django.template import Library, Node, TemplateSyntaxError 8 | 9 | from analytical.utils import disable_html, get_required_setting, is_internal_ip 10 | 11 | FACEBOOK_PIXEL_HEAD_CODE = """\ 12 | 24 | """ 25 | 26 | FACEBOOK_PIXEL_BODY_CODE = """\ 27 | 30 | """ 31 | 32 | register = Library() 33 | 34 | 35 | def _validate_no_args(token): 36 | bits = token.split_contents() 37 | if len(bits) > 1: 38 | raise TemplateSyntaxError("'%s' takes no arguments" % bits[0]) 39 | 40 | 41 | @register.tag 42 | def facebook_pixel_head(parser, token): 43 | """ 44 | Facebook Pixel head template tag. 45 | """ 46 | _validate_no_args(token) 47 | return FacebookPixelHeadNode() 48 | 49 | 50 | @register.tag 51 | def facebook_pixel_body(parser, token): 52 | """ 53 | Facebook Pixel body template tag. 54 | """ 55 | _validate_no_args(token) 56 | return FacebookPixelBodyNode() 57 | 58 | 59 | class _FacebookPixelNode(Node): 60 | """ 61 | Base class: override and provide code_template. 62 | """ 63 | 64 | def __init__(self): 65 | self.pixel_id = get_required_setting( 66 | 'FACEBOOK_PIXEL_ID', 67 | re.compile(r'^\d+$'), 68 | 'must be (a string containing) a number', 69 | ) 70 | 71 | def render(self, context): 72 | html = self.code_template % {'FACEBOOK_PIXEL_ID': self.pixel_id} 73 | if is_internal_ip(context, 'FACEBOOK_PIXEL'): 74 | return disable_html(html, 'Facebook Pixel') 75 | else: 76 | return html 77 | 78 | @property 79 | def code_template(self): 80 | raise NotImplementedError # pragma: no cover 81 | 82 | 83 | class FacebookPixelHeadNode(_FacebookPixelNode): 84 | code_template = FACEBOOK_PIXEL_HEAD_CODE 85 | 86 | 87 | class FacebookPixelBodyNode(_FacebookPixelNode): 88 | code_template = FACEBOOK_PIXEL_BODY_CODE 89 | 90 | 91 | def contribute_to_analytical(add_node): 92 | # ensure properly configured 93 | FacebookPixelHeadNode() 94 | FacebookPixelBodyNode() 95 | add_node('head_bottom', FacebookPixelHeadNode) 96 | add_node('body_bottom', FacebookPixelBodyNode) 97 | -------------------------------------------------------------------------------- /docs/services/optimizely.rst: -------------------------------------------------------------------------------- 1 | ========================= 2 | Optimizely -- A/B testing 3 | ========================= 4 | 5 | Optimizely_ is an easy way to implement A/B testing. Try different 6 | decisions, images, layouts, and copy without touching your website code 7 | and see exactly how your experiments are affecting pageviews, 8 | retention and sales. 9 | 10 | .. _Optimizely: http://www.optimizely.com/ 11 | 12 | 13 | .. optimizely-installation: 14 | 15 | Installation 16 | ============ 17 | 18 | To start using the Optimizely integration, you must have installed the 19 | django-analytical package and have added the ``analytical`` application 20 | to :const:`INSTALLED_APPS` in your project :file:`settings.py` file. 21 | See :doc:`../install` for details. 22 | 23 | Next you need to add the Optimizely template tag to your templates. 24 | This step is only needed if you are not using the generic 25 | :ttag:`analytical.*` tags. If you are, skip to 26 | :ref:`optimizely-configuration`. 27 | 28 | The Optimizely JavaScript code is inserted into templates using a 29 | template tag. Load the :mod:`optimizely` template tag library and 30 | insert the :ttag:`optimizely` tag. Because every page that you want to 31 | track must have the tag, it is useful to add it to your base template. 32 | Insert the tag at the top of the HTML head:: 33 | 34 | {% load optimizely %} 35 | 36 | 37 | {% optimizely %} 38 | ... 39 | 40 | 41 | .. _optimizely-configuration: 42 | 43 | Configuration 44 | ============= 45 | 46 | Before you can use the Optimizely integration, you must first set your 47 | account number. 48 | 49 | 50 | .. _optimizely-account-number: 51 | 52 | Setting the account number 53 | -------------------------- 54 | 55 | Optimizely gives you a unique account number, and the :ttag:`optimizely` 56 | tag will include it in the rendered JavaScript code. You can find your 57 | account number by clicking the *Implementation* link in the top 58 | right-hand corner of the Optimizely website. A pop-up window will 59 | appear containing HTML code looking like this:: 60 | 61 | 62 | 63 | The number ``XXXXXXX`` is your account number. Set 64 | :const:`OPTIMIZELY_ACCOUNT_NUMBER` in the project :file:`settings.py` 65 | file:: 66 | 67 | OPTIMIZELY_ACCOUNT_NUMBER = 'XXXXXXX' 68 | 69 | If you do not set an account number, the JavaScript code will not be 70 | rendered. 71 | 72 | 73 | .. _optimizely-internal-ips: 74 | 75 | Internal IP addresses 76 | --------------------- 77 | 78 | Usually you do not want to track clicks from your development or 79 | internal IP addresses. By default, if the tags detect that the client 80 | comes from any address in the :const:`OPTIMIZELY_INTERNAL_IPS` setting, 81 | the tracking code is commented out. It takes the value of 82 | :const:`ANALYTICAL_INTERNAL_IPS` by default (which in turn is 83 | :const:`INTERNAL_IPS` by default). See :ref:`identifying-visitors` for 84 | important information about detecting the visitor IP address. 85 | -------------------------------------------------------------------------------- /tests/unit/test_tag_hotjar.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the Hotjar template tags. 3 | """ 4 | 5 | import pytest 6 | from django.http import HttpRequest 7 | from django.template import Context, Template, TemplateSyntaxError 8 | from django.test import override_settings 9 | from utils import TagTestCase 10 | 11 | from analytical.templatetags.analytical import _load_template_nodes 12 | from analytical.templatetags.hotjar import HotjarNode 13 | from analytical.utils import AnalyticalException 14 | 15 | expected_html = """\ 16 | 26 | """ 27 | 28 | 29 | @override_settings(HOTJAR_SITE_ID='123456789') 30 | class HotjarTagTestCase(TagTestCase): 31 | maxDiff = None 32 | 33 | def test_tag(self): 34 | html = self.render_tag('hotjar', 'hotjar') 35 | assert expected_html == html 36 | 37 | def test_node(self): 38 | html = HotjarNode().render(Context({})) 39 | assert expected_html == html 40 | 41 | def test_tags_take_no_args(self): 42 | with pytest.raises(TemplateSyntaxError, match="'hotjar' takes no arguments"): 43 | Template('{% load hotjar %}{% hotjar "arg" %}').render(Context({})) 44 | 45 | @override_settings(HOTJAR_SITE_ID=None) 46 | def test_no_id(self): 47 | with pytest.raises( 48 | AnalyticalException, match='HOTJAR_SITE_ID setting is not set' 49 | ): 50 | HotjarNode() 51 | 52 | @override_settings(HOTJAR_SITE_ID='invalid') 53 | def test_invalid_id(self): 54 | expected_pattern = r"^HOTJAR_SITE_ID setting: must be \(a string containing\) a number: 'invalid'$" 55 | with pytest.raises(AnalyticalException, match=expected_pattern): 56 | HotjarNode() 57 | 58 | @override_settings(ANALYTICAL_INTERNAL_IPS=['1.1.1.1']) 59 | def test_render_internal_ip(self): 60 | request = HttpRequest() 61 | request.META['REMOTE_ADDR'] = '1.1.1.1' 62 | context = Context({'request': request}) 63 | 64 | actual_html = HotjarNode().render(context) 65 | disabled_html = '\n'.join( 66 | [ 67 | '', 70 | ] 71 | ) 72 | assert disabled_html == actual_html 73 | 74 | def test_contribute_to_analytical(self): 75 | """ 76 | `hotjar.contribute_to_analytical` registers the head and body nodes. 77 | """ 78 | template_nodes = _load_template_nodes() 79 | assert template_nodes == { 80 | 'head_top': [], 81 | 'head_bottom': [HotjarNode], 82 | 'body_top': [], 83 | 'body_bottom': [], 84 | } 85 | -------------------------------------------------------------------------------- /docs/services/facebook_pixel.rst: -------------------------------------------------------------------------------- 1 | ======================================= 2 | Facebook Pixel -- advertising analytics 3 | ======================================= 4 | 5 | `Facebook Pixel`_ is Facebook's tool for conversion tracking, optimisation and remarketing. 6 | 7 | .. _`Facebook Pixel`: https://developers.facebook.com/docs/facebook-pixel/ 8 | 9 | 10 | .. facebook-pixel-installation: 11 | 12 | Installation 13 | ============ 14 | 15 | To start using the Facebook Pixel integration, you must have installed the 16 | django-analytical package and have added the ``analytical`` application 17 | to :const:`INSTALLED_APPS` in your project :file:`settings.py` file. 18 | See :doc:`../install` for details. 19 | 20 | Next you need to add the Facebook Pixel template tag to your templates. 21 | This step is only needed if you are not using the generic 22 | :ttag:`analytical.*` tags. If you are, skip to 23 | :ref:`facebook-pixel-configuration`. 24 | 25 | The Facebook Pixel code is inserted into templates using template tags. 26 | Because every page that you want to track must have the tag, 27 | it is useful to add it to your base template. 28 | At the top of the template, load the :mod:`facebook_pixel` template tag library. 29 | Then insert the :ttag:`facebook_pixel_head` tag at the bottom of the head section, 30 | and optionally insert the :ttag:`facebook_pixel_body` tag at the bottom of the body section:: 31 | 32 | {% load facebook_pixel %} 33 | 34 | 35 | ... 36 | {% facebook_pixel_head %} 37 | 38 | 39 | ... 40 | {% facebook_pixel_body %} 41 | 42 | 43 | 44 | .. note:: 45 | The :ttag:`facebook_pixel_body` tag code will only be used for browsers with JavaScript disabled. 46 | It can be omitted if you don't need to support them. 47 | 48 | 49 | .. _facebook-pixel-configuration: 50 | 51 | Configuration 52 | ============= 53 | 54 | Before you can use the Facebook Pixel integration, 55 | you must first set your Pixel ID. 56 | 57 | 58 | .. _facebook-pixel-id: 59 | 60 | Setting the Pixel ID 61 | -------------------- 62 | 63 | Each Facebook Adverts account you have can have a Pixel ID, 64 | and the :mod:`facebook_pixel` tags will include it in the rendered page. 65 | You can find the Pixel ID on the "Pixels" section of your Facebook Adverts account. 66 | Set :const:`FACEBOOK_PIXEL_ID` in the project :file:`settings.py` file:: 67 | 68 | FACEBOOK_PIXEL_ID = 'XXXXXXXXXX' 69 | 70 | If you do not set a Pixel ID, the code will not be rendered. 71 | 72 | 73 | .. _facebook-pixel-internal-ips: 74 | 75 | Internal IP addresses 76 | --------------------- 77 | 78 | Usually you do not want to track clicks from your development or 79 | internal IP addresses. By default, if the tags detect that the client 80 | comes from any address in the :const:`FACEBOOK_PIXEL_INTERNAL_IPS` 81 | setting, the tracking code is commented out. It takes the value of 82 | :const:`ANALYTICAL_INTERNAL_IPS` by default (which in turn is 83 | :const:`INTERNAL_IPS` by default). See :ref:`identifying-visitors` for 84 | important information about detecting the visitor IP address. 85 | -------------------------------------------------------------------------------- /analytical/templatetags/analytical.py: -------------------------------------------------------------------------------- 1 | """ 2 | Analytical template tags and filters. 3 | """ 4 | 5 | import logging 6 | from importlib import import_module 7 | 8 | from django import template 9 | from django.template import Node, TemplateSyntaxError 10 | 11 | from analytical.utils import AnalyticalException 12 | 13 | TAG_LOCATIONS = ['head_top', 'head_bottom', 'body_top', 'body_bottom'] 14 | TAG_POSITIONS = ['first', None, 'last'] 15 | TAG_MODULES = [ 16 | 'analytical.chartbeat', 17 | 'analytical.clickmap', 18 | 'analytical.clicky', 19 | 'analytical.crazy_egg', 20 | 'analytical.facebook_pixel', 21 | 'analytical.gauges', 22 | 'analytical.google_analytics', 23 | 'analytical.google_analytics_js', 24 | 'analytical.google_analytics_gtag', 25 | 'analytical.gosquared', 26 | 'analytical.heap', 27 | 'analytical.hotjar', 28 | 'analytical.hubspot', 29 | 'analytical.intercom', 30 | 'analytical.kiss_insights', 31 | 'analytical.kiss_metrics', 32 | 'analytical.luckyorange', 33 | 'analytical.matomo', 34 | 'analytical.mixpanel', 35 | 'analytical.olark', 36 | 'analytical.optimizely', 37 | 'analytical.performable', 38 | 'analytical.rating_mailru', 39 | 'analytical.snapengage', 40 | 'analytical.spring_metrics', 41 | 'analytical.uservoice', 42 | 'analytical.woopra', 43 | 'analytical.yandex_metrica', 44 | ] 45 | 46 | logger = logging.getLogger(__name__) 47 | register = template.Library() 48 | 49 | 50 | def _location_tag(location): 51 | def analytical_tag(parser, token): 52 | bits = token.split_contents() 53 | if len(bits) > 1: 54 | raise TemplateSyntaxError("'%s' tag takes no arguments" % bits[0]) 55 | return AnalyticalNode(location) 56 | 57 | return analytical_tag 58 | 59 | 60 | for loc in TAG_LOCATIONS: 61 | register.tag('analytical_%s' % loc, _location_tag(loc)) 62 | 63 | 64 | class AnalyticalNode(Node): 65 | def __init__(self, location): 66 | self.nodes = [node_cls() for node_cls in template_nodes[location]] 67 | 68 | def render(self, context): 69 | return ''.join([node.render(context) for node in self.nodes]) 70 | 71 | 72 | def _load_template_nodes(): 73 | template_nodes = {loc: {pos: [] for pos in TAG_POSITIONS} for loc in TAG_LOCATIONS} 74 | 75 | def add_node_cls(location, node, position=None): 76 | template_nodes[location][position].append(node) 77 | 78 | for path in TAG_MODULES: 79 | module = _import_tag_module(path) 80 | try: 81 | module.contribute_to_analytical(add_node_cls) 82 | except AnalyticalException as e: 83 | logger.debug("not loading tags from '%s': %s", path, e) 84 | for location in TAG_LOCATIONS: 85 | template_nodes[location] = sum( 86 | (template_nodes[location][p] for p in TAG_POSITIONS), [] 87 | ) 88 | return template_nodes 89 | 90 | 91 | def _import_tag_module(path): 92 | app_name, lib_name = path.rsplit('.', 1) 93 | return import_module('%s.templatetags.%s' % (app_name, lib_name)) 94 | 95 | 96 | template_nodes = _load_template_nodes() 97 | -------------------------------------------------------------------------------- /docs/services/gauges.rst: -------------------------------------------------------------------------------- 1 | ============================= 2 | Gaug.es -- Real-time tracking 3 | ============================= 4 | 5 | Gaug.es_ is an easy way to implement real-time tracking for multiple 6 | websites. 7 | 8 | .. _Gaug.es: http://www.gaug.es/ 9 | 10 | 11 | .. gauges-installation: 12 | 13 | Installation 14 | ============ 15 | 16 | To start using the Gaug.es integration, you must have installed the 17 | django-analytical package and have added the ``analytical`` application 18 | to :const:`INSTALLED_APPS` in your project :file:`settings.py` file. 19 | See :doc:`../install` for details. 20 | 21 | Next you need to add the Gaug.es template tag to your templates. 22 | This step is only needed if you are not using the generic 23 | :ttag:`analytical.*` tags. If you are, skip to 24 | :ref:`gauges-configuration`. 25 | 26 | The Gaug.es JavaScript code is inserted into templates using a 27 | template tag. Load the :mod:`gauges` template tag library and 28 | insert the :ttag:`gauges` tag. Because every page that you want to 29 | track must have the tag, it is useful to add it to your base template. 30 | Insert the tag at the top of the HTML head:: 31 | 32 | {% load gauges %} 33 | 34 | 35 | {% gauges %} 36 | ... 37 | 38 | 39 | .. _gauges-configuration: 40 | 41 | Configuration 42 | ============= 43 | 44 | Before you can use the Gaug.es integration, you must first set your 45 | site id. 46 | 47 | 48 | .. _gauges-site-id: 49 | 50 | Setting the site id 51 | -------------------------- 52 | 53 | Gaug.es gives you a unique site id, and the :ttag:`gauges` 54 | tag will include it in the rendered JavaScript code. You can find your 55 | site id by clicking the *Tracking Code* link when logged into 56 | the on the gaug.es website. A page will display containing 57 | HTML code looking like this:: 58 | 59 | 72 | 73 | The code ``XXXXXXXXXXXXXXXXXXXXXXX`` is your site id. Set 74 | :const:`GAUGES_SITE_ID` in the project :file:`settings.py` 75 | file:: 76 | 77 | GAUGES_SITE_ID = 'XXXXXXXXXXXXXXXXXXXXXXX' 78 | 79 | If you do not set an site id, the JavaScript code will not be 80 | rendered. 81 | 82 | 83 | .. _gauges-internal-ips: 84 | 85 | Internal IP addresses 86 | --------------------- 87 | 88 | Usually you do not want to track clicks from your development or 89 | internal IP addresses. By default, if the tags detect that the client 90 | comes from any address in the :const:`ANALYTICAL_INTERNAL_IPS` setting 91 | (which is :const:`INTERNAL_IPS` by default,) the tracking code is 92 | commented out. See :ref:`identifying-visitors` for important information 93 | about detecting the visitor IP address. 94 | -------------------------------------------------------------------------------- /analytical/templatetags/uservoice.py: -------------------------------------------------------------------------------- 1 | """ 2 | UserVoice template tags. 3 | """ 4 | 5 | import json 6 | import re 7 | 8 | from django.conf import settings 9 | from django.template import Library, Node, TemplateSyntaxError 10 | 11 | from analytical.utils import get_identity, get_required_setting 12 | 13 | WIDGET_KEY_RE = re.compile(r'^[a-zA-Z0-9]*$') 14 | TRACKING_CODE = """ 15 | 27 | """ 28 | IDENTITY = """UserVoice.push(['identify', %(options)s]);""" 29 | TRIGGER = "UserVoice.push(['addTrigger', {}]);" 30 | register = Library() 31 | 32 | 33 | @register.tag 34 | def uservoice(parser, token): 35 | """ 36 | UserVoice tracking template tag. 37 | 38 | Renders JavaScript code to track page visits. You must supply 39 | your UserVoice Widget Key in the ``USERVOICE_WIDGET_KEY`` 40 | setting or the ``uservoice_widget_key`` template context variable. 41 | """ 42 | bits = token.split_contents() 43 | if len(bits) > 1: 44 | raise TemplateSyntaxError("'%s' takes no arguments" % bits[0]) 45 | return UserVoiceNode() 46 | 47 | 48 | class UserVoiceNode(Node): 49 | def __init__(self): 50 | self.default_widget_key = get_required_setting( 51 | 'USERVOICE_WIDGET_KEY', WIDGET_KEY_RE, 'must be an alphanumeric string' 52 | ) 53 | 54 | def render(self, context): 55 | widget_key = context.get('uservoice_widget_key') 56 | if not widget_key: 57 | widget_key = self.default_widget_key 58 | if not widget_key: 59 | return '' 60 | # default 61 | options = {} 62 | options.update(getattr(settings, 'USERVOICE_WIDGET_OPTIONS', {})) 63 | options.update(context.get('uservoice_widget_options', {})) 64 | 65 | identity = get_identity(context, 'uservoice', self._identify) 66 | if identity: 67 | identity = IDENTITY % {'options': json.dumps(identity, sort_keys=True)} 68 | 69 | trigger = context.get( 70 | 'uservoice_add_trigger', getattr(settings, 'USERVOICE_ADD_TRIGGER', True) 71 | ) 72 | 73 | html = TRACKING_CODE % { 74 | 'widget_key': widget_key, 75 | 'options': json.dumps(options, sort_keys=True), 76 | 'trigger': TRIGGER if trigger else '', 77 | 'identity': identity if identity else '', 78 | } 79 | return html 80 | 81 | def _identify(self, user): 82 | name = user.get_full_name() 83 | if not name: 84 | name = user.username 85 | return {'name': name, 'email': user.email} 86 | 87 | 88 | def contribute_to_analytical(add_node): 89 | UserVoiceNode() # ensure properly configured 90 | add_node('body_bottom', UserVoiceNode) 91 | -------------------------------------------------------------------------------- /analytical/templatetags/spring_metrics.py: -------------------------------------------------------------------------------- 1 | """ 2 | Spring Metrics template tags and filters. 3 | """ 4 | 5 | import re 6 | 7 | from django.template import Library, Node, TemplateSyntaxError 8 | 9 | from analytical.utils import ( 10 | disable_html, 11 | get_identity, 12 | get_required_setting, 13 | is_internal_ip, 14 | ) 15 | 16 | TRACKING_ID_RE = re.compile(r'^[0-9a-f]+$') 17 | TRACKING_CODE = """ 18 | 33 | """ # noqa 34 | 35 | register = Library() 36 | 37 | 38 | @register.tag 39 | def spring_metrics(parser, token): 40 | """ 41 | Spring Metrics tracking template tag. 42 | 43 | Renders JavaScript code to track page visits. You must supply 44 | your Spring Metrics Tracking ID in the 45 | ``SPRING_METRICS_TRACKING_ID`` setting. 46 | """ 47 | bits = token.split_contents() 48 | if len(bits) > 1: 49 | raise TemplateSyntaxError("'%s' takes no arguments" % bits[0]) 50 | return SpringMetricsNode() 51 | 52 | 53 | class SpringMetricsNode(Node): 54 | def __init__(self): 55 | self.tracking_id = get_required_setting( 56 | 'SPRING_METRICS_TRACKING_ID', TRACKING_ID_RE, 'must be a hexadecimal string' 57 | ) 58 | 59 | def render(self, context): 60 | custom = {} 61 | for dict_ in context: 62 | for var, val in dict_.items(): 63 | if var.startswith('spring_metrics_'): 64 | custom[var[15:]] = val 65 | if 'email' not in custom: 66 | identity = get_identity(context, 'spring_metrics', lambda u: u.email) 67 | if identity is not None: 68 | custom['email'] = identity 69 | 70 | html = TRACKING_CODE % { 71 | 'tracking_id': self.tracking_id, 72 | 'custom_commands': self._generate_custom_javascript(custom), 73 | } 74 | if is_internal_ip(context, 'SPRING_METRICS'): 75 | html = disable_html(html, 'Spring Metrics') 76 | return html 77 | 78 | def _generate_custom_javascript(self, params): 79 | commands = [] 80 | convert = params.pop('convert', None) 81 | if convert is not None: 82 | commands.append("_springMetq.push(['convert', '%s'])" % convert) 83 | commands.extend( 84 | "_springMetq.push(['setdata', {'%s': '%s'}]);" % (var, val) 85 | for var, val in params.items() 86 | ) 87 | return ' '.join(commands) 88 | 89 | 90 | def contribute_to_analytical(add_node): 91 | SpringMetricsNode() # ensure properly configured 92 | add_node('head_bottom', SpringMetricsNode) 93 | -------------------------------------------------------------------------------- /tests/unit/test_tag_luckyorange.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the Lucky Orange template tags. 3 | """ 4 | 5 | import pytest 6 | from django.http import HttpRequest 7 | from django.template import Context, Template, TemplateSyntaxError 8 | from django.test import override_settings 9 | from utils import TagTestCase 10 | 11 | from analytical.templatetags.analytical import _load_template_nodes 12 | from analytical.templatetags.luckyorange import LuckyOrangeNode 13 | from analytical.utils import AnalyticalException 14 | 15 | expected_html = """\ 16 | 24 | """ 25 | 26 | 27 | @override_settings(LUCKYORANGE_SITE_ID='123456') 28 | class LuckyOrangeTagTestCase(TagTestCase): 29 | maxDiff = None 30 | 31 | def test_tag(self): 32 | html = self.render_tag('luckyorange', 'luckyorange') 33 | assert expected_html == html 34 | 35 | def test_node(self): 36 | html = LuckyOrangeNode().render(Context({})) 37 | assert expected_html == html 38 | 39 | def test_tags_take_no_args(self): 40 | with pytest.raises( 41 | TemplateSyntaxError, match="'luckyorange' takes no arguments" 42 | ): 43 | Template('{% load luckyorange %}{% luckyorange "arg" %}').render( 44 | Context({}) 45 | ) 46 | 47 | @override_settings(LUCKYORANGE_SITE_ID=None) 48 | def test_no_id(self): 49 | with pytest.raises( 50 | AnalyticalException, match='LUCKYORANGE_SITE_ID setting is not set' 51 | ): 52 | LuckyOrangeNode() 53 | 54 | @override_settings(LUCKYORANGE_SITE_ID='invalid') 55 | def test_invalid_id(self): 56 | expected_pattern = r"^LUCKYORANGE_SITE_ID setting: must be \(a string containing\) a number: 'invalid'$" 57 | with pytest.raises(AnalyticalException, match=expected_pattern): 58 | LuckyOrangeNode() 59 | 60 | @override_settings(ANALYTICAL_INTERNAL_IPS=['1.1.1.1']) 61 | def test_render_internal_ip(self): 62 | request = HttpRequest() 63 | request.META['REMOTE_ADDR'] = '1.1.1.1' 64 | context = Context({'request': request}) 65 | 66 | actual_html = LuckyOrangeNode().render(context) 67 | disabled_html = '\n'.join( 68 | [ 69 | '', 72 | ] 73 | ) 74 | assert disabled_html == actual_html 75 | 76 | def test_contribute_to_analytical(self): 77 | """ 78 | `luckyorange.contribute_to_analytical` registers the head and body nodes. 79 | """ 80 | template_nodes = _load_template_nodes() 81 | assert template_nodes == { 82 | 'head_top': [], 83 | 'head_bottom': [LuckyOrangeNode], 84 | 'body_top': [], 85 | 'body_bottom': [], 86 | } 87 | -------------------------------------------------------------------------------- /analytical/templatetags/yandex_metrica.py: -------------------------------------------------------------------------------- 1 | """ 2 | Yandex.Metrica template tags and filters. 3 | """ 4 | 5 | import json 6 | import re 7 | 8 | from django.conf import settings 9 | from django.template import Library, Node, TemplateSyntaxError 10 | 11 | from analytical.utils import disable_html, get_required_setting, is_internal_ip 12 | 13 | COUNTER_ID_RE = re.compile(r'^\d{8}$') 14 | COUNTER_CODE = """ 15 | 35 | 36 | """ # noqa 37 | 38 | 39 | register = Library() 40 | 41 | 42 | @register.tag 43 | def yandex_metrica(parser, token): 44 | """ 45 | Yandex.Metrica counter template tag. 46 | 47 | Renders JavaScript code to track page visits. You must supply 48 | your website counter ID (as a string) in the 49 | ``YANDEX_METRICA_COUNTER_ID`` setting. 50 | """ 51 | bits = token.split_contents() 52 | if len(bits) > 1: 53 | raise TemplateSyntaxError("'%s' takes no arguments" % bits[0]) 54 | return YandexMetricaNode() 55 | 56 | 57 | class YandexMetricaNode(Node): 58 | def __init__(self): 59 | self.counter_id = get_required_setting( 60 | 'YANDEX_METRICA_COUNTER_ID', 61 | COUNTER_ID_RE, 62 | "must be (a string containing) a number'", 63 | ) 64 | 65 | def render(self, context): 66 | options = { 67 | 'id': int(self.counter_id), 68 | 'clickmap': True, 69 | 'trackLinks': True, 70 | 'accurateTrackBounce': True, 71 | } 72 | if getattr(settings, 'YANDEX_METRICA_WEBVISOR', False): 73 | options['webvisor'] = True 74 | if getattr(settings, 'YANDEX_METRICA_TRACKHASH', False): 75 | options['trackHash'] = True 76 | if getattr(settings, 'YANDEX_METRICA_NOINDEX', False): 77 | options['ut'] = 'noindex' 78 | if getattr(settings, 'YANDEX_METRICA_ECOMMERCE', False): 79 | options['ecommerce'] = 'dataLayer' 80 | html = COUNTER_CODE % { 81 | 'counter_id': self.counter_id, 82 | 'options': json.dumps(options), 83 | } 84 | if is_internal_ip(context, 'YANDEX_METRICA'): 85 | html = disable_html(html, 'Yandex.Metrica') 86 | return html 87 | 88 | 89 | def contribute_to_analytical(add_node): 90 | YandexMetricaNode() # ensure properly configured 91 | add_node('head_bottom', YandexMetricaNode) 92 | -------------------------------------------------------------------------------- /docs/services/yandex_metrica.rst: -------------------------------------------------------------------------------- 1 | ================================== 2 | Yandex.Metrica -- traffic analysis 3 | ================================== 4 | 5 | `Yandex.Metrica`_ is an analytics tool like as google analytics. 6 | 7 | .. _`Yandex.Metrica`: http://metrica.yandex.com/ 8 | 9 | 10 | .. yandex-metrica-installation: 11 | 12 | Installation 13 | ============ 14 | 15 | To start using the Yandex.Metrica integration, you must have installed the 16 | django-analytical package and have added the ``analytical`` application 17 | to :const:`INSTALLED_APPS` in your project :file:`settings.py` file. 18 | See :doc:`../install` for details. 19 | 20 | Next you need to add the Yandex.Metrica template tag to your templates. This 21 | step is only needed if you are not using the generic 22 | :ttag:`analytical.*` tags. If you are, skip to 23 | :ref:`yandex-metrica-configuration`. 24 | 25 | The Yandex.Metrica counter code is inserted into templates using a template 26 | tag. Load the :mod:`yandex_metrica` template tag library and insert the 27 | :ttag:`yandex_metrica` tag. Because every page that you want to track must 28 | have the tag, it is useful to add it to your base template. Insert 29 | the tag at the bottom of the HTML head:: 30 | 31 | {% load yandex_metrica %} 32 | 33 | 34 | ... 35 | {% yandex_metrica %} 36 | 37 | ... 38 | 39 | 40 | .. _yandex-metrica-configuration: 41 | 42 | Configuration 43 | ============= 44 | 45 | Before you can use the Yandex.Metrica integration, you must first set 46 | your website counter ID. 47 | 48 | 49 | .. _yandex-metrica-counter-id: 50 | 51 | Setting the counter ID 52 | ---------------------- 53 | 54 | Every website you track with Yandex.Metrica gets its own counter ID, 55 | and the :ttag:`yandex_metrica` tag will include it in the rendered 56 | JavaScript code. You can find the web counter ID on the overview page 57 | of your account. Set :const:`YANDEX_METRICA_COUNTER_ID` in the 58 | project :file:`settings.py` file:: 59 | 60 | YANDEX_METRICA_COUNTER_ID = '12345678' 61 | 62 | If you do not set a counter ID, the counter code will not be rendered. 63 | 64 | You can set additional options to tune your counter: 65 | 66 | ============================ ============= ============================================= 67 | Constant Default Value Description 68 | ============================ ============= ============================================= 69 | ``YANDEX_METRICA_WEBVISOR`` False Webvisor, scroll map, form analysis. 70 | ``YANDEX_METRICA_TRACKHASH`` False Hash tracking in the browser address bar. 71 | ``YANDEX_METRICA_NOINDEX`` False Stop automatic page indexing. 72 | ``YANDEX_METRICA_ECOMMERCE`` False Dispatch ecommerce data to Metrica. 73 | ============================ ============= ============================================= 74 | 75 | Internal IP addresses 76 | --------------------- 77 | 78 | Usually you do not want to track clicks from your development or 79 | internal IP addresses. By default, if the tags detect that the client 80 | comes from any address in the :const:`YANDEX_METRICA_INTERNAL_IPS` setting, 81 | the tracking code is commented out. It takes the value of 82 | :const:`ANALYTICAL_INTERNAL_IPS` by default (which in turn is 83 | :const:`INTERNAL_IPS` by default). See :ref:`identifying-visitors` for 84 | important information about detecting the visitor IP address. 85 | -------------------------------------------------------------------------------- /analytical/templatetags/kiss_metrics.py: -------------------------------------------------------------------------------- 1 | """ 2 | KISSmetrics template tags. 3 | """ 4 | 5 | import json 6 | import re 7 | 8 | from django.template import Library, Node, TemplateSyntaxError 9 | 10 | from analytical.utils import ( 11 | disable_html, 12 | get_identity, 13 | get_required_setting, 14 | is_internal_ip, 15 | ) 16 | 17 | API_KEY_RE = re.compile(r'^[0-9a-f]{40}$') 18 | TRACKING_CODE = """ 19 | 35 | """ 36 | IDENTIFY_CODE = "_kmq.push(['identify', '%s']);" 37 | EVENT_CODE = "_kmq.push(['record', '%(name)s', %(properties)s]);" 38 | PROPERTY_CODE = "_kmq.push(['set', %(properties)s]);" 39 | ALIAS_CODE = "_kmq.push(['alias', '%s', '%s']);" 40 | 41 | EVENT_CONTEXT_KEY = 'kiss_metrics_event' 42 | PROPERTY_CONTEXT_KEY = 'kiss_metrics_properties' 43 | ALIAS_CONTEXT_KEY = 'kiss_metrics_alias' 44 | 45 | register = Library() 46 | 47 | 48 | @register.tag 49 | def kiss_metrics(parser, token): 50 | """ 51 | KISSinsights tracking template tag. 52 | 53 | Renders JavaScript code to track page visits. You must supply 54 | your KISSmetrics API key in the ``KISS_METRICS_API_KEY`` 55 | setting. 56 | """ 57 | bits = token.split_contents() 58 | if len(bits) > 1: 59 | raise TemplateSyntaxError("'%s' takes no arguments" % bits[0]) 60 | return KissMetricsNode() 61 | 62 | 63 | class KissMetricsNode(Node): 64 | def __init__(self): 65 | self.api_key = get_required_setting( 66 | 'KISS_METRICS_API_KEY', 67 | API_KEY_RE, 68 | 'must be a string containing a 40-digit hexadecimal number', 69 | ) 70 | 71 | def render(self, context): 72 | commands = [] 73 | identity = get_identity(context, 'kiss_metrics') 74 | if identity is not None: 75 | commands.append(IDENTIFY_CODE % identity) 76 | try: 77 | properties = context[ALIAS_CONTEXT_KEY] 78 | key, value = properties.popitem() 79 | commands.append(ALIAS_CODE % (key, value)) 80 | except KeyError: 81 | pass 82 | try: 83 | name, properties = context[EVENT_CONTEXT_KEY] 84 | commands.append( 85 | EVENT_CODE 86 | % { 87 | 'name': name, 88 | 'properties': json.dumps(properties, sort_keys=True), 89 | } 90 | ) 91 | except KeyError: 92 | pass 93 | try: 94 | properties = context[PROPERTY_CONTEXT_KEY] 95 | commands.append( 96 | PROPERTY_CODE 97 | % { 98 | 'properties': json.dumps(properties, sort_keys=True), 99 | } 100 | ) 101 | except KeyError: 102 | pass 103 | html = TRACKING_CODE % { 104 | 'api_key': self.api_key, 105 | 'commands': ' '.join(commands), 106 | } 107 | if is_internal_ip(context, 'KISS_METRICS'): 108 | html = disable_html(html, 'KISSmetrics') 109 | return html 110 | 111 | 112 | def contribute_to_analytical(add_node): 113 | KissMetricsNode() # ensure properly configured 114 | add_node('head_top', KissMetricsNode) 115 | -------------------------------------------------------------------------------- /docs/services/gosquared.rst: -------------------------------------------------------------------------------- 1 | =============================== 2 | GoSquared -- traffic monitoring 3 | =============================== 4 | 5 | GoSquared_ provides both real-time traffic monitoring and and trends. 6 | It tells you what is currently happening at your website, what is 7 | popular, locate and identify visitors and track twitter. 8 | 9 | .. _GoSquared: http://www.gosquared.com/ 10 | 11 | 12 | Installation 13 | ============ 14 | 15 | To start using the GoSquared integration, you must have installed the 16 | django-analytical package and have added the ``analytical`` application 17 | to :const:`INSTALLED_APPS` in your project :file:`settings.py` file. 18 | See :doc:`../install` for details. 19 | 20 | Next you need to add the GoSquared template tag to your templates. This 21 | step is only needed if you are not using the generic 22 | :ttag:`analytical.*` tags. If you are, skip to 23 | :ref:`gosquared-configuration`. 24 | 25 | The GoSquared tracking code is inserted into templates using a template 26 | tag. Load the :mod:`gosquared` template tag library and insert the 27 | :ttag:`gosquared` tag. Because every page that you want to track must 28 | have the tag, it is useful to add it to your base template. Insert 29 | the tag at the bottom of the HTML body:: 30 | 31 | {% load gosquared %} 32 | ... 33 | {% gosquared %} 34 | 35 | 36 | 37 | 38 | .. _gosquared-configuration: 39 | 40 | Configuration 41 | ============= 42 | 43 | When you set up a website to be tracked by GoSquared, it assigns the 44 | site a token. You can find the token on the *Tracking Code* tab of your 45 | website settings page. Set :const:`GOSQUARED_SITE_TOKEN` in the project 46 | :file:`settings.py` file:: 47 | 48 | GOSQUARED_SITE_TOKEN = 'XXX-XXXXXX-X' 49 | 50 | If you do not set a site token, the tracking code will not be rendered. 51 | 52 | 53 | Internal IP addresses 54 | --------------------- 55 | 56 | Usually you do not want to track clicks from your development or 57 | internal IP addresses. By default, if the tags detect that the client 58 | comes from any address in the :const:`GOSQUARED_INTERNAL_IPS` setting, 59 | the tracking code is commented out. It takes the value of 60 | :const:`ANALYTICAL_INTERNAL_IPS` by default (which in turn is 61 | :const:`INTERNAL_IPS` by default). See :ref:`identifying-visitors` for 62 | important information about detecting the visitor IP address. 63 | 64 | 65 | Identifying authenticated users 66 | ------------------------------- 67 | 68 | If your websites identifies visitors, you can pass this information on 69 | to GoSquared to display on the LiveStats dashboard. By default, the 70 | name of an authenticated user is passed to GoSquared automatically. See 71 | :ref:`identifying-visitors`. 72 | 73 | You can also send the visitor identity yourself by adding either the 74 | ``gosquared_identity`` or the ``analytical_identity`` variable to 75 | the template context. If both variables are set, the former takes 76 | precedence. For example:: 77 | 78 | context = RequestContext({'gosquared_identity': identity}) 79 | return some_template.render(context) 80 | 81 | If you can derive the identity from the HTTP request, you can also use 82 | a context processor that you add to the 83 | :data:`TEMPLATE_CONTEXT_PROCESSORS` list in :file:`settings.py`:: 84 | 85 | def identify(request): 86 | try: 87 | return {'gosquared_identity': request.user.username} 88 | except AttributeError: 89 | return {} 90 | 91 | Just remember that if you set the same context variable in the 92 | :class:`~django.template.context.RequestContext` constructor and in a 93 | context processor, the latter clobbers the former. 94 | 95 | 96 | ---- 97 | 98 | Thanks go to GoSquared for their support with the development of this 99 | application. 100 | -------------------------------------------------------------------------------- /tests/unit/test_tag_kiss_metrics.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the KISSmetrics tags and filters. 3 | """ 4 | 5 | import pytest 6 | from django.contrib.auth.models import AnonymousUser, User 7 | from django.http import HttpRequest 8 | from django.template import Context 9 | from django.test.utils import override_settings 10 | from utils import TagTestCase 11 | 12 | from analytical.templatetags.kiss_metrics import KissMetricsNode 13 | from analytical.utils import AnalyticalException 14 | 15 | 16 | @override_settings(KISS_METRICS_API_KEY='0123456789abcdef0123456789abcdef01234567') 17 | class KissMetricsTagTestCase(TagTestCase): 18 | """ 19 | Tests for the ``kiss_metrics`` template tag. 20 | """ 21 | 22 | def test_tag(self): 23 | r = self.render_tag('kiss_metrics', 'kiss_metrics') 24 | assert ( 25 | '//doug1izaerwt3.cloudfront.net/0123456789abcdef0123456789abcdef01234567.1.js' 26 | in r 27 | ) 28 | 29 | def test_node(self): 30 | r = KissMetricsNode().render(Context()) 31 | assert ( 32 | '//doug1izaerwt3.cloudfront.net/0123456789abcdef0123456789abcdef01234567.1.js' 33 | in r 34 | ) 35 | 36 | @override_settings(KISS_METRICS_API_KEY=None) 37 | def test_no_api_key(self): 38 | with pytest.raises(AnalyticalException): 39 | KissMetricsNode() 40 | 41 | @override_settings(KISS_METRICS_API_KEY='0123456789abcdef0123456789abcdef0123456') 42 | def test_api_key_too_short(self): 43 | with pytest.raises(AnalyticalException): 44 | KissMetricsNode() 45 | 46 | @override_settings(KISS_METRICS_API_KEY='0123456789abcdef0123456789abcdef012345678') 47 | def test_api_key_too_long(self): 48 | with pytest.raises(AnalyticalException): 49 | KissMetricsNode() 50 | 51 | @override_settings(ANALYTICAL_AUTO_IDENTIFY=True) 52 | def test_identify(self): 53 | r = KissMetricsNode().render(Context({'user': User(username='test')})) 54 | assert "_kmq.push(['identify', 'test']);" in r 55 | 56 | @override_settings(ANALYTICAL_AUTO_IDENTIFY=True) 57 | def test_identify_anonymous_user(self): 58 | r = KissMetricsNode().render(Context({'user': AnonymousUser()})) 59 | assert "_kmq.push(['identify', " not in r 60 | 61 | def test_event(self): 62 | r = KissMetricsNode().render( 63 | Context( 64 | { 65 | 'kiss_metrics_event': ( 66 | 'test_event', 67 | {'prop1': 'val1', 'prop2': 'val2'}, 68 | ), 69 | } 70 | ) 71 | ) 72 | assert "_kmq.push(['record', 'test_event', " 73 | '{"prop1": "val1", "prop2": "val2"}]);' in r 74 | 75 | def test_property(self): 76 | r = KissMetricsNode().render( 77 | Context( 78 | { 79 | 'kiss_metrics_properties': {'prop1': 'val1', 'prop2': 'val2'}, 80 | } 81 | ) 82 | ) 83 | assert '_kmq.push([\'set\', {"prop1": "val1", "prop2": "val2"}]);' in r 84 | 85 | def test_alias(self): 86 | r = KissMetricsNode().render( 87 | Context( 88 | { 89 | 'kiss_metrics_alias': {'test': 'test_alias'}, 90 | } 91 | ) 92 | ) 93 | assert "_kmq.push(['alias', 'test', 'test_alias']);" in r 94 | 95 | @override_settings(ANALYTICAL_INTERNAL_IPS=['1.1.1.1']) 96 | def test_render_internal_ip(self): 97 | req = HttpRequest() 98 | req.META['REMOTE_ADDR'] = '1.1.1.1' 99 | context = Context({'request': req}) 100 | r = KissMetricsNode().render(context) 101 | assert r.startswith('') 103 | -------------------------------------------------------------------------------- /analytical/templatetags/mixpanel.py: -------------------------------------------------------------------------------- 1 | """ 2 | Mixpanel template tags and filters. 3 | """ 4 | 5 | import json 6 | import re 7 | 8 | from django.template import Library, Node, TemplateSyntaxError 9 | from django.utils.safestring import mark_safe 10 | 11 | from analytical.utils import ( 12 | disable_html, 13 | get_identity, 14 | get_required_setting, 15 | is_internal_ip, 16 | ) 17 | 18 | MIXPANEL_API_TOKEN_RE = re.compile(r'^[0-9a-f]{32}$') 19 | TRACKING_CODE = """ 20 | 26 | """ # noqa 27 | IDENTIFY_CODE = "mixpanel.identify('%s');" 28 | IDENTIFY_PROPERTIES = 'mixpanel.people.set(%s);' 29 | EVENT_CODE = "mixpanel.track('%(name)s', %(properties)s);" 30 | EVENT_CONTEXT_KEY = 'mixpanel_event' 31 | 32 | register = Library() 33 | 34 | 35 | @register.tag 36 | def mixpanel(parser, token): 37 | """ 38 | Mixpanel tracking template tag. 39 | 40 | Renders JavaScript code to track page visits. You must supply 41 | your Mixpanel token in the ``MIXPANEL_API_TOKEN`` setting. 42 | """ 43 | bits = token.split_contents() 44 | if len(bits) > 1: 45 | raise TemplateSyntaxError("'%s' takes no arguments" % bits[0]) 46 | return MixpanelNode() 47 | 48 | 49 | class MixpanelNode(Node): 50 | def __init__(self): 51 | self._token = get_required_setting( 52 | 'MIXPANEL_API_TOKEN', 53 | MIXPANEL_API_TOKEN_RE, 54 | 'must be a string containing a 32-digit hexadecimal number', 55 | ) 56 | 57 | def render(self, context): 58 | commands = [] 59 | identity = get_identity(context, 'mixpanel') 60 | if identity is not None: 61 | if isinstance(identity, dict): 62 | commands.append( 63 | IDENTIFY_CODE % identity.get('id', identity.get('username')) 64 | ) 65 | commands.append( 66 | IDENTIFY_PROPERTIES % json.dumps(identity, sort_keys=True) 67 | ) 68 | else: 69 | commands.append(IDENTIFY_CODE % identity) 70 | try: 71 | name, properties = context[EVENT_CONTEXT_KEY] 72 | commands.append( 73 | EVENT_CODE 74 | % { 75 | 'name': name, 76 | 'properties': json.dumps(properties, sort_keys=True), 77 | } 78 | ) 79 | except KeyError: 80 | pass 81 | html = TRACKING_CODE % { 82 | 'token': self._token, 83 | 'commands': ' '.join(commands), 84 | } 85 | if is_internal_ip(context, 'MIXPANEL'): 86 | html = disable_html(html, 'Mixpanel') 87 | return mark_safe(html) 88 | 89 | 90 | def contribute_to_analytical(add_node): 91 | MixpanelNode() # ensure properly configured 92 | add_node('head_bottom', MixpanelNode) 93 | -------------------------------------------------------------------------------- /tests/unit/test_tag_olark.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the Olark template tags and filters. 3 | """ 4 | 5 | import pytest 6 | from django.contrib.auth.models import AnonymousUser, User 7 | from django.template import Context 8 | from django.test.utils import override_settings 9 | from utils import TagTestCase 10 | 11 | from analytical.templatetags.olark import OlarkNode 12 | from analytical.utils import AnalyticalException 13 | 14 | 15 | @override_settings(OLARK_SITE_ID='1234-567-89-0123') 16 | class OlarkTestCase(TagTestCase): 17 | """ 18 | Tests for the ``olark`` template tag. 19 | """ 20 | 21 | def test_tag(self): 22 | r = self.render_tag('olark', 'olark') 23 | assert "olark.identify('1234-567-89-0123');" in r 24 | 25 | def test_node(self): 26 | r = OlarkNode().render(Context()) 27 | assert "olark.identify('1234-567-89-0123');" in r 28 | 29 | @override_settings(OLARK_SITE_ID=None) 30 | def test_no_site_id(self): 31 | with pytest.raises(AnalyticalException): 32 | OlarkNode() 33 | 34 | @override_settings(OLARK_SITE_ID='1234-567-8901234') 35 | def test_wrong_site_id(self): 36 | with pytest.raises(AnalyticalException): 37 | OlarkNode() 38 | 39 | @override_settings(ANALYTICAL_AUTO_IDENTIFY=True) 40 | def test_identify(self): 41 | r = OlarkNode().render( 42 | Context( 43 | { 44 | 'user': User(username='test', first_name='Test', last_name='User'), 45 | } 46 | ) 47 | ) 48 | assert ( 49 | "olark('api.chat.updateVisitorNickname', {snippet: 'Test User (test)'});" 50 | in r 51 | ) 52 | 53 | @override_settings(ANALYTICAL_AUTO_IDENTIFY=True) 54 | def test_identify_anonymous_user(self): 55 | r = OlarkNode().render(Context({'user': AnonymousUser()})) 56 | assert "olark('api.chat.updateVisitorNickname', " not in r 57 | 58 | def test_nickname(self): 59 | r = OlarkNode().render(Context({'olark_nickname': 'testnick'})) 60 | assert "olark('api.chat.updateVisitorNickname', {snippet: 'testnick'});" in r 61 | 62 | def test_status_string(self): 63 | r = OlarkNode().render(Context({'olark_status': 'teststatus'})) 64 | assert "olark('api.chat.updateVisitorStatus', " 65 | '{snippet: "teststatus"});' in r 66 | 67 | def test_status_string_list(self): 68 | r = OlarkNode().render( 69 | Context( 70 | { 71 | 'olark_status': ['teststatus1', 'teststatus2'], 72 | } 73 | ) 74 | ) 75 | assert "olark('api.chat.updateVisitorStatus', " 76 | '{snippet: ["teststatus1", "teststatus2"]});' in r 77 | 78 | def test_messages(self): 79 | messages = [ 80 | 'welcome_title', 81 | 'chatting_title', 82 | 'unavailable_title', 83 | 'busy_title', 84 | 'away_message', 85 | 'loading_title', 86 | 'welcome_message', 87 | 'busy_message', 88 | 'chat_input_text', 89 | 'name_input_text', 90 | 'email_input_text', 91 | 'offline_note_message', 92 | 'send_button_text', 93 | 'offline_note_thankyou_text', 94 | 'offline_note_error_text', 95 | 'offline_note_sending_text', 96 | 'operator_is_typing_text', 97 | 'operator_has_stopped_typing_text', 98 | 'introduction_error_text', 99 | 'introduction_messages', 100 | 'introduction_submit_button_text', 101 | ] 102 | vars = {f'olark_{m}': m for m in messages} 103 | r = OlarkNode().render(Context(vars)) 104 | for m in messages: 105 | assert f'olark.configure(\'locale.{m}\', "{m}");' in r 106 | -------------------------------------------------------------------------------- /analytical/templatetags/chartbeat.py: -------------------------------------------------------------------------------- 1 | """ 2 | Chartbeat template tags and filters. 3 | """ 4 | 5 | import json 6 | import re 7 | 8 | from django.conf import settings 9 | from django.core.exceptions import ImproperlyConfigured 10 | from django.template import Library, Node, TemplateSyntaxError 11 | 12 | from analytical.utils import disable_html, get_required_setting, is_internal_ip 13 | 14 | USER_ID_RE = re.compile(r'^\d+$') 15 | INIT_CODE = """""" 16 | SETUP_CODE = """ 17 | 35 | """ # noqa 36 | DOMAIN_CONTEXT_KEY = 'chartbeat_domain' 37 | 38 | 39 | register = Library() 40 | 41 | 42 | @register.tag 43 | def chartbeat_top(parser, token): 44 | """ 45 | Top Chartbeat template tag. 46 | 47 | Render the top JavaScript code for Chartbeat. 48 | """ 49 | bits = token.split_contents() 50 | if len(bits) > 1: 51 | raise TemplateSyntaxError("'%s' takes no arguments" % bits[0]) 52 | return ChartbeatTopNode() 53 | 54 | 55 | class ChartbeatTopNode(Node): 56 | def render(self, context): 57 | if is_internal_ip(context): 58 | return disable_html(INIT_CODE, 'Chartbeat') 59 | return INIT_CODE 60 | 61 | 62 | @register.tag 63 | def chartbeat_bottom(parser, token): 64 | """ 65 | Bottom Chartbeat template tag. 66 | 67 | Render the bottom JavaScript code for Chartbeat. You must supply 68 | your Chartbeat User ID (as a string) in the ``CHARTBEAT_USER_ID`` 69 | setting. 70 | """ 71 | bits = token.split_contents() 72 | if len(bits) > 1: 73 | raise TemplateSyntaxError("'%s' takes no arguments" % bits[0]) 74 | return ChartbeatBottomNode() 75 | 76 | 77 | class ChartbeatBottomNode(Node): 78 | def __init__(self): 79 | self.user_id = get_required_setting( 80 | 'CHARTBEAT_USER_ID', USER_ID_RE, 'must be (a string containing) a number' 81 | ) 82 | 83 | def render(self, context): 84 | config = {'uid': self.user_id} 85 | domain = _get_domain(context) 86 | if domain is not None: 87 | config['domain'] = domain 88 | html = SETUP_CODE % {'config': json.dumps(config, sort_keys=True)} 89 | if is_internal_ip(context, 'CHARTBEAT'): 90 | html = disable_html(html, 'Chartbeat') 91 | return html 92 | 93 | 94 | def contribute_to_analytical(add_node): 95 | ChartbeatBottomNode() # ensure properly configured 96 | add_node('head_top', ChartbeatTopNode, 'first') 97 | add_node('body_bottom', ChartbeatBottomNode, 'last') 98 | 99 | 100 | def _get_domain(context): 101 | domain = context.get(DOMAIN_CONTEXT_KEY) 102 | 103 | if domain is not None: 104 | return domain 105 | else: 106 | if 'django.contrib.sites' not in settings.INSTALLED_APPS: 107 | return 108 | elif getattr(settings, 'CHARTBEAT_AUTO_DOMAIN', True): 109 | from django.contrib.sites.models import Site 110 | 111 | try: 112 | return Site.objects.get_current().domain 113 | except (ImproperlyConfigured, Site.DoesNotExist): # pylint: disable=E1101 114 | return 115 | -------------------------------------------------------------------------------- /docs/features.rst: -------------------------------------------------------------------------------- 1 | ========================== 2 | Features and customization 3 | ========================== 4 | 5 | The django-analytical application sets up basic tracking without any 6 | further configuration. This page describes extra features and ways in 7 | which behavior can be customized. 8 | 9 | 10 | .. _internal-ips: 11 | 12 | Internal IP addresses 13 | ===================== 14 | 15 | Visits by the website developers or internal users are usually not 16 | interesting. The django-analytical will comment out the service 17 | initialization code if the client IP address is detected as one from the 18 | :data:`ANALYTICAL_INTERNAL_IPS` setting. The default value for this 19 | setting is :data:`INTERNAL_IPS`. 20 | 21 | Example: 22 | 23 | .. code-block:: python 24 | 25 | ANALYTICAL_INTERNAL_IPS = ['192.168.1.45', '192.168.1.57'] 26 | 27 | .. note:: 28 | 29 | The template tags can only access the visitor IP address if the 30 | HTTP request is present in the template context as the 31 | ``request`` variable. For this reason, the 32 | :data:`ANALYTICAL_INTERNAL_IPS` setting only works if you add this 33 | variable to the context yourself when you render the template, or 34 | you use the ``RequestContext`` and add 35 | ``'django.core.context_processors.request'`` to the list of 36 | context processors in the ``TEMPLATE_CONTEXT_PROCESSORS`` 37 | setting. 38 | 39 | 40 | .. _identifying-visitors: 41 | 42 | Identifying authenticated users 43 | =============================== 44 | 45 | Some analytics services can track individual users. If the visitor is 46 | logged in through the standard Django authentication system and the 47 | current user is accessible in the template context, the username can be 48 | passed to the analytics services that support identifying users. This 49 | feature is configured by the :data:`ANALYTICAL_AUTO_IDENTIFY` setting 50 | and is enabled by default. To disable: 51 | 52 | .. code-block:: python 53 | 54 | ANALYTICAL_AUTO_IDENTIFY = False 55 | 56 | .. note:: 57 | 58 | The template tags can only access the visitor username if the 59 | Django user is present in the template context either as the 60 | ``user`` variable, or as an attribute on the HTTP request in the 61 | ``request`` variable. Use a 62 | :class:`~django.template.RequestContext` to render your 63 | templates and add 64 | ``'django.contrib.auth.context_processors.auth'`` or 65 | ``'django.core.context_processors.request'`` to the list of 66 | context processors in the :data:`TEMPLATE_CONTEXT_PROCESSORS` 67 | setting. (The first of these is added by default.) 68 | Alternatively, add one of the variables to the context yourself 69 | when you render the template. 70 | 71 | Changing the identity 72 | ********************* 73 | 74 | If you want to override the identity of the logged-in user that the various 75 | providers send you can do it by setting the ``analytical_identity`` context 76 | variable in your view code: 77 | 78 | .. code-block:: python 79 | 80 | context = RequestContext({'analytical_identity': user.uuid}) 81 | return some_template.render(context) 82 | 83 | or in the template: 84 | 85 | .. code-block:: django 86 | 87 | {% with analytical_identity=request.user.uuid|default:None %} 88 | {% analytical_head_top %} 89 | {% endwith %} 90 | 91 | or by implementing a context processor, e.g. 92 | 93 | .. code-block:: python 94 | 95 | # FILE: myproject/context_processors.py 96 | from django.conf import settings 97 | 98 | def get_identity(request): 99 | return { 100 | 'analytical_identity': 'some-value-here', 101 | } 102 | 103 | # FILE: myproject/settings.py 104 | TEMPLATES = [ 105 | { 106 | 'OPTIONS': { 107 | 'context_processors': [ 108 | 'myproject.context_processors.get_identity', 109 | ], 110 | }, 111 | }, 112 | ] 113 | 114 | That allows you as a developer to leave your view code untouched and 115 | make sure that the variable is injected for all templates. 116 | 117 | If you want to change the identity only for specific provider use the 118 | ``*_identity`` context variable, where the ``*`` prefix is the module name 119 | of the specific provider. 120 | -------------------------------------------------------------------------------- /tests/unit/test_tag_chartbeat.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the Chartbeat template tags and filters. 3 | """ 4 | 5 | import re 6 | 7 | import pytest 8 | from django.http import HttpRequest 9 | from django.template import Context 10 | from django.test import TestCase 11 | from django.test.utils import override_settings 12 | from utils import TagTestCase 13 | 14 | from analytical.templatetags.chartbeat import ChartbeatBottomNode, ChartbeatTopNode 15 | from analytical.utils import AnalyticalException 16 | 17 | 18 | @override_settings(CHARTBEAT_USER_ID='12345') 19 | class ChartbeatTagTestCaseNoSites(TestCase): 20 | def test_rendering_setup_no_site(self): 21 | r = ChartbeatBottomNode().render(Context()) 22 | self.assertTrue('var _sf_async_config={"uid": "12345"};' in r, r) 23 | 24 | 25 | @override_settings( 26 | INSTALLED_APPS=( 27 | 'analytical', 28 | 'django.contrib.sites', 29 | 'django.contrib.auth', 30 | 'django.contrib.contenttypes', 31 | ) 32 | ) 33 | @override_settings(CHARTBEAT_USER_ID='12345') 34 | class ChartbeatTagTestCaseWithSites(TestCase): 35 | def setUp(self): 36 | from django.core.management import call_command 37 | 38 | call_command('migrate', verbosity=0) 39 | 40 | def test_rendering_setup_site(self): 41 | from django.contrib.sites.models import Site 42 | 43 | site = Site.objects.create(domain='test.com', name='test') 44 | with override_settings(SITE_ID=site.id): 45 | r = ChartbeatBottomNode().render(Context()) 46 | assert re.search('var _sf_async_config={.*"uid": "12345".*};', r) 47 | assert re.search('var _sf_async_config={.*"domain": "test.com".*};', r) 48 | 49 | @override_settings(CHARTBEAT_AUTO_DOMAIN=False) 50 | def test_auto_domain_false(self): 51 | """ 52 | Even if 'django.contrib.sites' is in INSTALLED_APPS, if 53 | CHARTBEAT_AUTO_DOMAIN is False, ensure there is no 'domain' 54 | in _sf_async_config. 55 | """ 56 | r = ChartbeatBottomNode().render(Context()) 57 | assert 'var _sf_async_config={"uid": "12345"};' in r 58 | 59 | 60 | @override_settings(CHARTBEAT_USER_ID='12345') 61 | class ChartbeatTagTestCase(TagTestCase): 62 | """ 63 | Tests for the ``chartbeat`` template tag. 64 | """ 65 | 66 | def test_top_tag(self): 67 | r = self.render_tag( 68 | 'chartbeat', 'chartbeat_top', {'chartbeat_domain': 'test.com'} 69 | ) 70 | assert 'var _sf_startpt=(new Date()).getTime()' in r 71 | 72 | def test_bottom_tag(self): 73 | r = self.render_tag( 74 | 'chartbeat', 'chartbeat_bottom', {'chartbeat_domain': 'test.com'} 75 | ) 76 | assert re.search('var _sf_async_config={.*"uid": "12345".*};', r) 77 | assert re.search('var _sf_async_config={.*"domain": "test.com".*};', r) 78 | 79 | def test_top_node(self): 80 | r = ChartbeatTopNode().render( 81 | Context( 82 | { 83 | 'chartbeat_domain': 'test.com', 84 | } 85 | ) 86 | ) 87 | assert 'var _sf_startpt=(new Date()).getTime()' in r 88 | 89 | def test_bottom_node(self): 90 | r = ChartbeatBottomNode().render( 91 | Context( 92 | { 93 | 'chartbeat_domain': 'test.com', 94 | } 95 | ) 96 | ) 97 | assert re.search('var _sf_async_config={.*"uid": "12345".*};', r) 98 | assert re.search('var _sf_async_config={.*"domain": "test.com".*};', r) 99 | 100 | @override_settings(CHARTBEAT_USER_ID=None) 101 | def test_no_user_id(self): 102 | with pytest.raises(AnalyticalException): 103 | ChartbeatBottomNode() 104 | 105 | @override_settings(CHARTBEAT_USER_ID='123abc') 106 | def test_wrong_user_id(self): 107 | with pytest.raises(AnalyticalException): 108 | ChartbeatBottomNode() 109 | 110 | @override_settings(ANALYTICAL_INTERNAL_IPS=['1.1.1.1']) 111 | def test_render_internal_ip(self): 112 | req = HttpRequest() 113 | req.META['REMOTE_ADDR'] = '1.1.1.1' 114 | context = Context({'request': req}) 115 | r = ChartbeatBottomNode().render(context) 116 | assert r.startswith('') 118 | -------------------------------------------------------------------------------- /docs/services/crazy_egg.rst: -------------------------------------------------------------------------------- 1 | ================================== 2 | Crazy Egg -- visual click tracking 3 | ================================== 4 | 5 | `Crazy Egg`_ is an easy to use hosted web application that visualizes 6 | website clicks using heatmaps. It allows you to discover the areas of 7 | web pages that are most important to your visitors. 8 | 9 | .. _`Crazy Egg`: http://www.crazyegg.com/ 10 | 11 | 12 | .. crazy-egg-installation: 13 | 14 | Installation 15 | ============ 16 | 17 | To start using the Crazy Egg integration, you must have installed the 18 | django-analytical package and have added the ``analytical`` application 19 | to :const:`INSTALLED_APPS` in your project :file:`settings.py` file. 20 | See :doc:`../install` for details. 21 | 22 | Next you need to add the Crazy Egg template tag to your templates. 23 | This step is only needed if you are not using the generic 24 | :ttag:`analytical.*` tags. If you are, skip to 25 | :ref:`crazy-egg-configuration`. 26 | 27 | The Crazy Egg tracking code is inserted into templates using a template 28 | tag. Load the :mod:`crazy_egg` template tag library and insert the 29 | :ttag:`crazy_egg` tag. Because every page that you want to track must 30 | have the tag, it is useful to add it to your base template. Insert 31 | the tag at the bottom of the HTML body:: 32 | 33 | {% load crazy_egg %} 34 | ... 35 | {% crazy_egg %} 36 | 37 | 38 | 39 | 40 | .. _crazy-egg-configuration: 41 | 42 | Configuration 43 | ============= 44 | 45 | Before you can use the Crazy Egg integration, you must first set your 46 | account number. You can also segment the click analysis through user 47 | variables. 48 | 49 | 50 | .. _crazy-egg-account-number: 51 | 52 | Setting the account number 53 | -------------------------- 54 | 55 | Crazy Egg gives you a unique account number, and the :ttag:`crazy egg` 56 | tag will include it in the rendered JavaScript code. You can find your 57 | account number by clicking the link named "What's my code?" in the 58 | dashboard of your Crazy Egg account. Set 59 | :const:`CRAZY_EGG_ACCOUNT_NUMBER` in the project :file:`settings.py` 60 | file:: 61 | 62 | CRAZY_EGG_ACCOUNT_NUMBER = 'XXXXXXXX' 63 | 64 | If you do not set an account number, the tracking code will not be 65 | rendered. 66 | 67 | 68 | .. _crazy-egg-internal-ips: 69 | 70 | Internal IP addresses 71 | --------------------- 72 | 73 | Usually you do not want to track clicks from your development or 74 | internal IP addresses. By default, if the tags detect that the client 75 | comes from any address in the :const:`CRAZY_EGG_INTERNAL_IPS` setting, 76 | the tracking code is commented out. It takes the value of 77 | :const:`ANALYTICAL_INTERNAL_IPS` by default (which in turn is 78 | :const:`INTERNAL_IPS` by default). See :ref:`identifying-visitors` for 79 | important information about detecting the visitor IP address. 80 | 81 | 82 | .. _crazy-egg-uservars: 83 | 84 | User variables 85 | -------------- 86 | 87 | Crazy Egg can segment clicks based on `user variables`_. If you want to 88 | set a user variable, use the context variables ``crazy_egg_var1`` 89 | through ``crazy_egg_var5`` when you render your template:: 90 | 91 | context = RequestContext({'crazy_egg_var1': 'red', 92 | 'crazy_egg_var2': 'male'}) 93 | return some_template.render(context) 94 | 95 | If you use the same user variables in different views and its value can 96 | be computed from the HTTP request, you can also set them in a context 97 | processor that you add to the :data:`TEMPLATE_CONTEXT_PROCESSORS` list 98 | in :file:`settings.py`:: 99 | 100 | def track_admin_role(request): 101 | if request.user.is_staff(): 102 | role = 'staff' 103 | else: 104 | role = 'visitor' 105 | return {'crazy_egg_var3': role} 106 | 107 | Just remember that if you set the same context variable in the 108 | :class:`~django.template.context.RequestContext` constructor and in a 109 | context processor, the latter clobbers the former. 110 | 111 | .. _`user variables`: https://www.crazyegg.com/help/Setting_Up_A_Page_to_Track/How_do_I_set_the_values_of_User_Var_1_User_Var_2_etc_in_the_confetti_and_overlay_views/ 112 | 113 | 114 | ---- 115 | 116 | The work on Crazy Egg was made possible by `Bateau Knowledge`_. Thanks 117 | go to Crazy Egg for their support with the development of this 118 | application. 119 | 120 | .. _`Bateau Knowledge`: http://www.bateauknowledge.nl/ 121 | -------------------------------------------------------------------------------- /analytical/templatetags/matomo.py: -------------------------------------------------------------------------------- 1 | """ 2 | Matomo template tags and filters. 3 | """ 4 | 5 | import re 6 | from collections import namedtuple 7 | from itertools import chain 8 | 9 | from django.conf import settings 10 | from django.template import Library, Node, TemplateSyntaxError 11 | 12 | from analytical.utils import ( 13 | disable_html, 14 | get_identity, 15 | get_required_setting, 16 | is_internal_ip, 17 | ) 18 | 19 | # domain name (characters separated by a dot), optional port, optional URI path, no slash 20 | DOMAINPATH_RE = re.compile(r'^(([^./?#@:]+\.)*[^./?#@:]+)+(:[0-9]+)?(/[^/?#@:]+)*$') 21 | 22 | # numeric ID 23 | SITEID_RE = re.compile(r'^\d+$') 24 | 25 | TRACKING_CODE = """ 26 | 40 | 41 | """ # noqa 42 | 43 | VARIABLE_CODE = ( 44 | '_paq.push(["setCustomVariable", %(index)s, "%(name)s", "%(value)s", "%(scope)s"]);' # noqa 45 | ) 46 | IDENTITY_CODE = '_paq.push(["setUserId", "%(userid)s"]);' 47 | DISABLE_COOKIES_CODE = "_paq.push(['disableCookies']);" 48 | 49 | DEFAULT_SCOPE = 'page' 50 | 51 | MatomoVar = namedtuple('MatomoVar', ('index', 'name', 'value', 'scope')) 52 | 53 | 54 | register = Library() 55 | 56 | 57 | @register.tag 58 | def matomo(parser, token): 59 | """ 60 | Matomo tracking template tag. 61 | 62 | Renders JavaScript code to track page visits. You must supply 63 | your Matomo domain (plus optional URI path), and tracked site ID 64 | in the ``MATOMO_DOMAIN_PATH`` and the ``MATOMO_SITE_ID`` setting. 65 | 66 | Custom variables can be passed in the ``matomo_vars`` context 67 | variable. It is an iterable of custom variables as tuples like: 68 | ``(index, name, value[, scope])`` where scope may be ``'page'`` 69 | (default) or ``'visit'``. Index should be an integer and the 70 | other parameters should be strings. 71 | """ 72 | bits = token.split_contents() 73 | if len(bits) > 1: 74 | raise TemplateSyntaxError("'%s' takes no arguments" % bits[0]) 75 | return MatomoNode() 76 | 77 | 78 | class MatomoNode(Node): 79 | def __init__(self): 80 | self.domain_path = get_required_setting( 81 | 'MATOMO_DOMAIN_PATH', 82 | DOMAINPATH_RE, 83 | 'must be a domain name, optionally followed ' 84 | 'by an URI path, no trailing slash (e.g. ' 85 | 'matomo.example.com or my.matomo.server/path)', 86 | ) 87 | self.site_id = get_required_setting( 88 | 'MATOMO_SITE_ID', SITEID_RE, 'must be a (string containing a) number' 89 | ) 90 | 91 | def render(self, context): 92 | custom_variables = context.get('matomo_vars', ()) 93 | 94 | complete_variables = ( 95 | var if len(var) >= 4 else var + (DEFAULT_SCOPE,) for var in custom_variables 96 | ) 97 | 98 | variables_code = ( 99 | VARIABLE_CODE % MatomoVar(*var)._asdict() for var in complete_variables 100 | ) 101 | 102 | commands = [] 103 | if getattr(settings, 'MATOMO_DISABLE_COOKIES', False): 104 | commands.append(DISABLE_COOKIES_CODE) 105 | 106 | userid = get_identity(context, 'matomo') 107 | if userid is not None: 108 | variables_code = chain( 109 | variables_code, (IDENTITY_CODE % {'userid': userid},) 110 | ) 111 | 112 | html = TRACKING_CODE % { 113 | 'url': self.domain_path, 114 | 'siteid': self.site_id, 115 | 'variables': '\n '.join(variables_code), 116 | 'commands': '\n '.join(commands), 117 | } 118 | if is_internal_ip(context, 'MATOMO'): 119 | html = disable_html(html, 'Matomo') 120 | return html 121 | 122 | 123 | def contribute_to_analytical(add_node): 124 | MatomoNode() # ensure properly configured 125 | add_node('body_bottom', MatomoNode) 126 | -------------------------------------------------------------------------------- /docs/services/chartbeat.rst: -------------------------------------------------------------------------------- 1 | ============================= 2 | Chartbeat -- traffic analysis 3 | ============================= 4 | 5 | Chartbeat_ provides real-time analytics to websites and blogs. It shows 6 | visitors, load times, and referring sites on a minute-by-minute basis. 7 | The service also provides alerts the second your website crashes or 8 | slows to a crawl. 9 | 10 | .. _Chartbeat: http://www.chartbeat.com/ 11 | 12 | 13 | .. chartbeat-installation: 14 | 15 | Installation 16 | ============ 17 | 18 | To start using the Chartbeat integration, you must have installed the 19 | django-analytical package and have added the ``analytical`` application 20 | to :const:`INSTALLED_APPS` in your project :file:`settings.py` file. 21 | See :doc:`../install` for details. 22 | 23 | Next you need to add the Chartbeat template tags to your templates. This 24 | step is only needed if you are not using the generic 25 | :ttag:`analytical.*` tags. If you are, skip to 26 | :ref:`chartbeat-configuration`. 27 | 28 | The Chartbeat tracking code is inserted into templates using template 29 | tags. At the top of the template, load the :mod:`chartbeat` template 30 | tag library. Then insert the :ttag:`chartbeat_top` tag at the top of 31 | the head section, and the :ttag:`chartbeat_bottom` tag at the bottom of 32 | the body section:: 33 | 34 | {% load chartbeat %} 35 | 36 | 37 | {% chartbeat_top %} 38 | 39 | ... 40 | 41 | {% chartbeat_bottom %} 42 | 43 | 44 | 45 | Because these tags are used to measure page loading time, it is 46 | important to place them as close as possible to the start and end of the 47 | document. 48 | 49 | 50 | .. _chartbeat-configuration: 51 | 52 | Configuration 53 | ============= 54 | 55 | Before you can use the Chartbeat integration, you must first set your 56 | User ID. 57 | 58 | 59 | .. _chartbeat-user-id: 60 | 61 | Setting the User ID 62 | ------------------- 63 | 64 | Your Chartbeat account has a unique User ID. You can find your User ID 65 | by visiting the Chartbeat `Add New Site`_ page. The second code snippet 66 | contains a line that looks like this:: 67 | 68 | var _sf_async_config={uid:XXXXX,domain:"YYYYYYYYYY"}; 69 | 70 | Here, ``XXXXX`` is your User ID. Set :const:`CHARTBEAT_USER_ID` in the 71 | project :file:`settings.py` file:: 72 | 73 | CHARTBEAT_USER_ID = 'XXXXX' 74 | 75 | If you do not set a User ID, the tracking code will not be rendered. 76 | 77 | .. _`Add New Site`: http://chartbeat.com/code/ 78 | 79 | 80 | .. _chartbeat-internal-ips: 81 | 82 | Internal IP addresses 83 | --------------------- 84 | 85 | Usually you do not want to track clicks from your development or 86 | internal IP addresses. By default, if the tags detect that the client 87 | comes from any address in the :const:`CHARTBEAT_INTERNAL_IPS` setting, 88 | the tracking code is commented out. It takes the value of 89 | :const:`ANALYTICAL_INTERNAL_IPS` by default (which in turn is 90 | :const:`INTERNAL_IPS` by default). See :ref:`identifying-visitors` for 91 | important information about detecting the visitor IP address. 92 | 93 | 94 | .. _chartbeat-domain: 95 | 96 | Setting the domain 97 | ------------------ 98 | 99 | The JavaScript tracking code can send the website domain to Chartbeat. 100 | If you use multiple subdomains this enables you to treat them as one 101 | website in Chartbeat. If your project uses the sites framework, the 102 | domain name of the current :class:`~django.contrib.sites.models.Site` 103 | will be passed to Chartbeat automatically. You can modify this behavior 104 | using the :const:`CHARTBEAT_AUTO_DOMAIN` setting:: 105 | 106 | CHARTBEAT_AUTO_DOMAIN = False 107 | 108 | Alternatively, you set the domain through the ``chartbeat_domain`` 109 | context variable when you render the template:: 110 | 111 | context = RequestContext({'chartbeat_domain': 'example.com'}) 112 | return some_template.render(context) 113 | 114 | It is annoying to do this for every view, so you may want to set it in 115 | a context processor that you add to the 116 | :data:`TEMPLATE_CONTEXT_PROCESSORS` list in :file:`settings.py`:: 117 | 118 | def chartbeat(request): 119 | return {'chartbeat_domain': 'example.com'} 120 | 121 | The context domain overrides the domain from the current site. If no 122 | domain is set, either explicitly or implicitly through the sites 123 | framework, then no domain is sent, and Chartbeat will detect the domain 124 | name from the URL. If your website uses just one domain, this will work 125 | just fine. 126 | 127 | 128 | ---- 129 | 130 | Thanks go to Chartbeat for their support with the development of this 131 | application. 132 | -------------------------------------------------------------------------------- /tests/unit/test_tag_facebook_pixel.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the Facebook Pixel template tags. 3 | """ 4 | 5 | import pytest 6 | from django.http import HttpRequest 7 | from django.template import Context, Template, TemplateSyntaxError 8 | from django.test import override_settings 9 | from utils import TagTestCase 10 | 11 | from analytical.templatetags.analytical import _load_template_nodes 12 | from analytical.templatetags.facebook_pixel import ( 13 | FacebookPixelBodyNode, 14 | FacebookPixelHeadNode, 15 | ) 16 | from analytical.utils import AnalyticalException 17 | 18 | expected_head_html = """\ 19 | 31 | """ 32 | 33 | 34 | expected_body_html = """\ 35 | 38 | """ 39 | 40 | 41 | @override_settings(FACEBOOK_PIXEL_ID='1234567890') 42 | class FacebookPixelTagTestCase(TagTestCase): 43 | maxDiff = None 44 | 45 | def test_head_tag(self): 46 | html = self.render_tag('facebook_pixel', 'facebook_pixel_head') 47 | assert expected_head_html == html 48 | 49 | def test_head_node(self): 50 | html = FacebookPixelHeadNode().render(Context({})) 51 | assert expected_head_html == html 52 | 53 | def test_body_tag(self): 54 | html = self.render_tag('facebook_pixel', 'facebook_pixel_body') 55 | assert expected_body_html == html 56 | 57 | def test_body_node(self): 58 | html = FacebookPixelBodyNode().render(Context({})) 59 | assert expected_body_html == html 60 | 61 | def test_tags_take_no_args(self): 62 | template = '{%% load facebook_pixel %%}{%% facebook_pixel_%s "arg" %%}' 63 | with pytest.raises( 64 | TemplateSyntaxError, match="'facebook_pixel_head' takes no arguments" 65 | ): 66 | Template(template % 'head').render(Context({})) 67 | 68 | with pytest.raises( 69 | TemplateSyntaxError, match="'facebook_pixel_body' takes no arguments" 70 | ): 71 | Template(template % 'body').render(Context({})) 72 | 73 | @override_settings(FACEBOOK_PIXEL_ID=None) 74 | def test_no_id(self): 75 | expected_pattern = 'FACEBOOK_PIXEL_ID setting is not set' 76 | with pytest.raises(AnalyticalException, match=expected_pattern): 77 | FacebookPixelHeadNode() 78 | with pytest.raises(AnalyticalException, match=expected_pattern): 79 | FacebookPixelBodyNode() 80 | 81 | @override_settings(FACEBOOK_PIXEL_ID='invalid') 82 | def test_invalid_id(self): 83 | expected_pattern = r"FACEBOOK_PIXEL_ID setting: must be \(a string containing\) a number: 'invalid'$" 84 | with pytest.raises(AnalyticalException, match=expected_pattern): 85 | FacebookPixelHeadNode() 86 | with pytest.raises(AnalyticalException, match=expected_pattern): 87 | FacebookPixelBodyNode() 88 | 89 | @override_settings(ANALYTICAL_INTERNAL_IPS=['1.1.1.1']) 90 | def test_render_internal_ip(self): 91 | request = HttpRequest() 92 | request.META['REMOTE_ADDR'] = '1.1.1.1' 93 | context = Context({'request': request}) 94 | 95 | def _disabled(html): 96 | return '\n'.join( 97 | [ 98 | '', 101 | ] 102 | ) 103 | 104 | head_html = FacebookPixelHeadNode().render(context) 105 | assert _disabled(expected_head_html) == head_html 106 | 107 | body_html = FacebookPixelBodyNode().render(context) 108 | assert _disabled(expected_body_html) == body_html 109 | 110 | def test_contribute_to_analytical(self): 111 | """ 112 | `facebook_pixel.contribute_to_analytical` registers the head and body nodes. 113 | """ 114 | template_nodes = _load_template_nodes() 115 | assert template_nodes == { 116 | 'head_top': [], 117 | 'head_bottom': [FacebookPixelHeadNode], 118 | 'body_top': [], 119 | 'body_bottom': [FacebookPixelBodyNode], 120 | } 121 | -------------------------------------------------------------------------------- /docs/services/kiss_insights.rst: -------------------------------------------------------------------------------- 1 | ================================ 2 | KISSinsights -- feedback surveys 3 | ================================ 4 | 5 | KISSinsights_ provides unobtrusive surveys that pop up from the bottom 6 | right-hand corner of your website. Asking specific questions gets you 7 | the targeted, actionable feedback you need to make your site better. 8 | 9 | .. _KISSinsights: http://www.kissinsights.com/ 10 | 11 | 12 | .. kiss-insights-installation: 13 | 14 | Installation 15 | ============ 16 | 17 | To start using the KISSinsights integration, you must have installed the 18 | django-analytical package and have added the ``analytical`` application 19 | to :const:`INSTALLED_APPS` in your project :file:`settings.py` file. 20 | See :doc:`../install` for details. 21 | 22 | Next you need to add the KISSinsights template tag to your templates. 23 | This step is only needed if you are not using the generic 24 | :ttag:`analytical.*` tags. If you are, skip to 25 | :ref:`kiss-insights-configuration`. 26 | 27 | The KISSinsights survey code is inserted into templates using a template 28 | tag. Load the :mod:`kiss_insights` template tag library and insert the 29 | :ttag:`kiss_insights` tag. Because every page that you want to track 30 | must have the tag, it is useful to add it to your base template. Insert 31 | the tag at the top of the HTML body:: 32 | 33 | {% load kiss_insights %} 34 | ... 35 | 36 | 37 | {% kiss_insights %} 38 | ... 39 | 40 | 41 | .. _kiss-insights-configuration: 42 | 43 | Configuration 44 | ============= 45 | 46 | Before you can use the KISSinsights integration, you must first set your 47 | account number and site code. 48 | 49 | 50 | .. _kiss-insights-account-number: 51 | 52 | Setting the account number and site code 53 | ---------------------------------------- 54 | 55 | In order to install the survey code, you need to set your KISSinsights 56 | account number and website code. The :ttag:`kiss_insights` tag will 57 | include it in the rendered JavaScript code. You can find the account 58 | number and website code by visiting the code installation page of the 59 | website you want to place the surveys on. You will see some HTML code 60 | with a JavaScript tag with a ``src`` attribute containing 61 | ``//s3.amazonaws.com/ki.js/XXXXX/YYY.js``. Here ``XXXXX`` is the 62 | account number and ``YYY`` the website code. Set 63 | :const:`KISS_INSIGHTS_ACCOUNT_NUMBER` and 64 | :const:`KISS_INSIGHTS_WEBSITE_CODE` in the project :file:`settings.py` 65 | file:: 66 | 67 | KISSINSIGHTS_ACCOUNT_NUMBER = 'XXXXX' 68 | KISSINSIGHTS_SITE_CODE = 'XXX' 69 | 70 | If you do not set the account number and website code, the survey code 71 | will not be rendered. 72 | 73 | 74 | .. _kiss-insights-identity-user: 75 | 76 | Identifying authenticated users 77 | ------------------------------- 78 | 79 | If your websites identifies visitors, you can pass this information on 80 | to KISSinsights so that you can tie survey submissions to customers. 81 | By default, the username of an authenticated user is passed to 82 | KISSinsights automatically. See :ref:`identifying-visitors`. 83 | 84 | You can also send the visitor identity yourself by adding either the 85 | ``kiss_insights_identity`` or the ``analytical_identity`` variable to 86 | the template context. If both variables are set, the former takes 87 | precedence. For example:: 88 | 89 | context = RequestContext({'kiss_insights_identity': identity}) 90 | return some_template.render(context) 91 | 92 | If you can derive the identity from the HTTP request, you can also use 93 | a context processor that you add to the 94 | :data:`TEMPLATE_CONTEXT_PROCESSORS` list in :file:`settings.py`:: 95 | 96 | def identify(request): 97 | try: 98 | return {'kiss_insights_identity': request.user.email} 99 | except AttributeError: 100 | return {} 101 | 102 | Just remember that if you set the same context variable in the 103 | :class:`~django.template.context.RequestContext` constructor and in a 104 | context processor, the latter clobbers the former. 105 | 106 | 107 | .. _kiss-insights-show-survey: 108 | 109 | Showing a specific survey 110 | ------------------------- 111 | 112 | KISSinsights can also be told to show a specific survey. You can let 113 | the :ttag:`kiss_insights` tag include the code to select a survey by 114 | passing the survey ID to the template in the 115 | ``kiss_insights_show_survey`` context variable:: 116 | 117 | context = RequestContext({'kiss_insights_show_survey': 1234}) 118 | return some_template.render(context) 119 | 120 | For information about how to find the survey ID, see the explanation 121 | on `"How can I show a survey after a custom trigger condition?"`_ on the 122 | KISSinsights help page. 123 | 124 | .. _`"How can I show a survey after a custom trigger condition?"`: http://www.kissinsights.com/help#customer-trigger 125 | -------------------------------------------------------------------------------- /analytical/templatetags/intercom.py: -------------------------------------------------------------------------------- 1 | """ 2 | intercom.io template tags and filters. 3 | """ 4 | 5 | import hashlib 6 | import hmac 7 | import json 8 | import re 9 | 10 | from django.conf import settings 11 | from django.template import Library, Node, TemplateSyntaxError 12 | 13 | from analytical.utils import ( 14 | disable_html, 15 | get_identity, 16 | get_required_setting, 17 | get_user_from_context, 18 | get_user_is_authenticated, 19 | is_internal_ip, 20 | ) 21 | 22 | APP_ID_RE = re.compile(r'[\da-z]+$') 23 | TRACKING_CODE = """ 24 | 27 | 28 | """ # noqa 29 | 30 | register = Library() 31 | 32 | 33 | def _hashable_bytes(data): 34 | """ 35 | Coerce strings to hashable bytes. 36 | """ 37 | if isinstance(data, bytes): 38 | return data 39 | elif isinstance(data, str): 40 | return data.encode('ascii') # Fail on anything non-ASCII. 41 | else: 42 | raise TypeError(data) 43 | 44 | 45 | def intercom_user_hash(data): 46 | """ 47 | Return a SHA-256 HMAC `user_hash` as expected by Intercom, if configured. 48 | 49 | Return None if the `INTERCOM_HMAC_SECRET_KEY` setting is not configured. 50 | """ 51 | if getattr(settings, 'INTERCOM_HMAC_SECRET_KEY', None): 52 | return hmac.new( 53 | key=_hashable_bytes(settings.INTERCOM_HMAC_SECRET_KEY), 54 | msg=_hashable_bytes(data), 55 | digestmod=hashlib.sha256, 56 | ).hexdigest() 57 | else: 58 | return None 59 | 60 | 61 | @register.tag 62 | def intercom(parser, token): 63 | """ 64 | Intercom.io template tag. 65 | 66 | Renders JavaScript code to intercom.io testing. You must supply 67 | your APP ID account number in the ``INTERCOM_APP_ID`` 68 | setting. 69 | """ 70 | bits = token.split_contents() 71 | if len(bits) > 1: 72 | raise TemplateSyntaxError("'%s' takes no arguments" % bits[0]) 73 | return IntercomNode() 74 | 75 | 76 | class IntercomNode(Node): 77 | def __init__(self): 78 | self.app_id = get_required_setting( 79 | 'INTERCOM_APP_ID', APP_ID_RE, "must be a string looking like 'XXXXXXX'" 80 | ) 81 | 82 | def _identify(self, user): 83 | name = user.get_full_name() 84 | if not name: 85 | name = user.username 86 | return name 87 | 88 | def _get_custom_attrs(self, context): 89 | params = {} 90 | for dict_ in context: 91 | for var, val in dict_.items(): 92 | if var.startswith('intercom_'): 93 | params[var[9:]] = val 94 | 95 | user = get_user_from_context(context) 96 | if user is not None and get_user_is_authenticated(user): 97 | if 'name' not in params: 98 | params['name'] = get_identity(context, 'intercom', self._identify, user) 99 | if 'email' not in params and user.email: 100 | params['email'] = user.email 101 | 102 | params.setdefault('user_id', user.pk) 103 | 104 | params['created_at'] = int(user.date_joined.timestamp()) 105 | else: 106 | params['created_at'] = None 107 | 108 | # Generate a user_hash HMAC to verify the user's identity, if configured. 109 | # (If both user_id and email are present, the user_id field takes precedence.) 110 | # See: 111 | # https://www.intercom.com/help/configure-intercom-for-your-product-or-site/staying-secure/enable-identity-verification-on-your-web-product 112 | user_hash_data = params.get('user_id', params.get('email')) 113 | if user_hash_data: 114 | user_hash = intercom_user_hash(str(user_hash_data)) 115 | if user_hash is not None: 116 | params.setdefault('user_hash', user_hash) 117 | 118 | return params 119 | 120 | def render(self, context): 121 | params = self._get_custom_attrs(context) 122 | params['app_id'] = self.app_id 123 | html = TRACKING_CODE % {'settings_json': json.dumps(params, sort_keys=True)} 124 | 125 | if is_internal_ip(context, 'INTERCOM'): 126 | html = disable_html(html, 'Intercom') 127 | return html 128 | 129 | 130 | def contribute_to_analytical(add_node): 131 | IntercomNode() 132 | add_node('body_bottom', IntercomNode) 133 | -------------------------------------------------------------------------------- /analytical/templatetags/olark.py: -------------------------------------------------------------------------------- 1 | """ 2 | Olark template tags. 3 | """ 4 | 5 | import json 6 | import re 7 | 8 | from django.template import Library, Node, TemplateSyntaxError 9 | 10 | from analytical.utils import get_identity, get_required_setting 11 | 12 | SITE_ID_RE = re.compile(r'^\d+-\d+-\d+-\d+$') 13 | SETUP_CODE = """ 14 | 18 | """ # noqa 19 | NICKNAME_CODE = "olark('api.chat.updateVisitorNickname', {snippet: '%s'});" 20 | NICKNAME_CONTEXT_KEY = 'olark_nickname' 21 | FULLNAME_CODE = "olark('api.visitor.updateFullName', {{fullName: '{0}'}});" 22 | FULLNAME_CONTEXT_KEY = 'olark_fullname' 23 | EMAIL_CODE = "olark('api.visitor.updateEmailAddress', {{emailAddress: '{0}'}});" 24 | EMAIL_CONTEXT_KEY = 'olark_email' 25 | STATUS_CODE = "olark('api.chat.updateVisitorStatus', {snippet: %s});" 26 | STATUS_CONTEXT_KEY = 'olark_status' 27 | MESSAGE_CODE = 'olark.configure(\'locale.%(key)s\', "%(msg)s");' 28 | MESSAGE_KEYS = { 29 | 'welcome_title', 30 | 'chatting_title', 31 | 'unavailable_title', 32 | 'busy_title', 33 | 'away_message', 34 | 'loading_title', 35 | 'welcome_message', 36 | 'busy_message', 37 | 'chat_input_text', 38 | 'name_input_text', 39 | 'email_input_text', 40 | 'offline_note_message', 41 | 'send_button_text', 42 | 'offline_note_thankyou_text', 43 | 'offline_note_error_text', 44 | 'offline_note_sending_text', 45 | 'operator_is_typing_text', 46 | 'operator_has_stopped_typing_text', 47 | 'introduction_error_text', 48 | 'introduction_messages', 49 | 'introduction_submit_button_text', 50 | } 51 | 52 | register = Library() 53 | 54 | 55 | @register.tag 56 | def olark(parser, token): 57 | """ 58 | Olark set-up template tag. 59 | 60 | Renders JavaScript code to set-up Olark chat. You must supply 61 | your site ID in the ``OLARK_SITE_ID`` setting. 62 | """ 63 | bits = token.split_contents() 64 | if len(bits) > 1: 65 | raise TemplateSyntaxError("'%s' takes no arguments" % bits[0]) 66 | return OlarkNode() 67 | 68 | 69 | class OlarkNode(Node): 70 | def __init__(self): 71 | self.site_id = get_required_setting( 72 | 'OLARK_SITE_ID', 73 | SITE_ID_RE, 74 | "must be a string looking like 'XXXX-XXX-XX-XXXX'", 75 | ) 76 | 77 | def render(self, context): 78 | extra_code = [] 79 | try: 80 | extra_code.append(NICKNAME_CODE % context[NICKNAME_CONTEXT_KEY]) 81 | except KeyError: 82 | identity = get_identity(context, 'olark', self._get_nickname) 83 | if identity is not None: 84 | extra_code.append(NICKNAME_CODE % identity) 85 | try: 86 | extra_code.append(FULLNAME_CODE.format(context[FULLNAME_CONTEXT_KEY])) 87 | except KeyError: 88 | pass 89 | try: 90 | extra_code.append(EMAIL_CODE.format(context[EMAIL_CONTEXT_KEY])) 91 | except KeyError: 92 | pass 93 | try: 94 | extra_code.append( 95 | STATUS_CODE % json.dumps(context[STATUS_CONTEXT_KEY], sort_keys=True) 96 | ) 97 | except KeyError: 98 | pass 99 | extra_code.extend(self._get_configuration(context)) 100 | html = SETUP_CODE % { 101 | 'site_id': self.site_id, 102 | 'extra_code': ' '.join(extra_code), 103 | } 104 | return html 105 | 106 | def _get_nickname(self, user): 107 | name = user.get_full_name() 108 | if name: 109 | return '%s (%s)' % (name, user.username) 110 | else: 111 | return user.username 112 | 113 | def _get_configuration(self, context): 114 | code = [] 115 | for dict_ in context: 116 | for var, val in dict_.items(): 117 | if var.startswith('olark_'): 118 | key = var[6:] 119 | if key in MESSAGE_KEYS: 120 | code.append(MESSAGE_CODE % {'key': key, 'msg': val}) 121 | return code 122 | 123 | 124 | def contribute_to_analytical(add_node): 125 | OlarkNode() # ensure properly configured 126 | add_node('body_bottom', OlarkNode) 127 | -------------------------------------------------------------------------------- /analytical/templatetags/woopra.py: -------------------------------------------------------------------------------- 1 | """ 2 | Woopra template tags and filters. 3 | """ 4 | 5 | import json 6 | import re 7 | from contextlib import suppress 8 | 9 | from django.conf import settings 10 | from django.template import Library, Node, TemplateSyntaxError 11 | 12 | from analytical.utils import ( 13 | AnalyticalException, 14 | disable_html, 15 | get_identity, 16 | get_required_setting, 17 | get_user_from_context, 18 | get_user_is_authenticated, 19 | is_internal_ip, 20 | ) 21 | 22 | DOMAIN_RE = re.compile(r'^\S+$') 23 | TRACKING_CODE = """ 24 | 32 | """ # noqa 33 | 34 | register = Library() 35 | 36 | 37 | @register.tag 38 | def woopra(parser, token): 39 | """ 40 | Woopra tracking template tag. 41 | 42 | Renders JavaScript code to track page visits. You must supply 43 | your Woopra domain in the ``WOOPRA_DOMAIN`` setting. 44 | """ 45 | bits = token.split_contents() 46 | if len(bits) > 1: 47 | raise TemplateSyntaxError("'%s' takes no arguments" % bits[0]) 48 | return WoopraNode() 49 | 50 | 51 | class WoopraNode(Node): 52 | def __init__(self): 53 | self.domain = get_required_setting( 54 | 'WOOPRA_DOMAIN', DOMAIN_RE, 'must be a domain name' 55 | ) 56 | 57 | def render(self, context): 58 | settings = self._get_settings(context) 59 | visitor = self._get_visitor(context) 60 | 61 | html = TRACKING_CODE % { 62 | 'settings': json.dumps(settings, sort_keys=True), 63 | 'visitor': json.dumps(visitor, sort_keys=True), 64 | } 65 | if is_internal_ip(context, 'WOOPRA'): 66 | html = disable_html(html, 'Woopra') 67 | return html 68 | 69 | def _get_settings(self, context): 70 | variables = {'domain': self.domain} 71 | woopra_int_settings = { 72 | 'idle_timeout': 'WOOPRA_IDLE_TIMEOUT', 73 | } 74 | woopra_str_settings = { 75 | 'cookie_name': 'WOOPRA_COOKIE_NAME', 76 | 'cookie_domain': 'WOOPRA_COOKIE_DOMAIN', 77 | 'cookie_path': 'WOOPRA_COOKIE_PATH', 78 | 'cookie_expire': 'WOOPRA_COOKIE_EXPIRE', 79 | } 80 | woopra_bool_settings = { 81 | 'click_tracking': 'WOOPRA_CLICK_TRACKING', 82 | 'download_tracking': 'WOOPRA_DOWNLOAD_TRACKING', 83 | 'outgoing_tracking': 'WOOPRA_OUTGOING_TRACKING', 84 | 'outgoing_ignore_subdomain': 'WOOPRA_OUTGOING_IGNORE_SUBDOMAIN', 85 | 'ignore_query_url': 'WOOPRA_IGNORE_QUERY_URL', 86 | 'hide_campaign': 'WOOPRA_HIDE_CAMPAIGN', 87 | } 88 | 89 | for key, name in woopra_int_settings.items(): 90 | with suppress(AttributeError): 91 | variables[key] = getattr(settings, name) 92 | if type(variables[key]) is not int: 93 | raise AnalyticalException(f'{name} must be an int value') 94 | 95 | for key, name in woopra_str_settings.items(): 96 | with suppress(AttributeError): 97 | variables[key] = getattr(settings, name) 98 | if type(variables[key]) is not str: 99 | raise AnalyticalException(f'{name} must be a string value') 100 | 101 | for key, name in woopra_bool_settings.items(): 102 | with suppress(AttributeError): 103 | variables[key] = getattr(settings, name) 104 | if type(variables[key]) is not bool: 105 | raise AnalyticalException(f'{name} must be a boolean value') 106 | 107 | return variables 108 | 109 | def _get_visitor(self, context): 110 | params = {} 111 | for dict_ in context: 112 | for var, val in dict_.items(): 113 | if var.startswith('woopra_'): 114 | params[var[7:]] = val 115 | if 'name' not in params and 'email' not in params: 116 | user = get_user_from_context(context) 117 | if user is not None and get_user_is_authenticated(user): 118 | params['name'] = get_identity(context, 'woopra', self._identify, user) 119 | if user.email: 120 | params['email'] = user.email 121 | return params 122 | 123 | def _identify(self, user): 124 | name = user.get_full_name() 125 | if not name: 126 | name = user.username 127 | return name 128 | 129 | 130 | def contribute_to_analytical(add_node): 131 | WoopraNode() # ensure properly configured 132 | add_node('head_bottom', WoopraNode) 133 | -------------------------------------------------------------------------------- /docs/services/performable.rst: -------------------------------------------------------------------------------- 1 | ============================================== 2 | Performable -- web analytics and landing pages 3 | ============================================== 4 | 5 | Performable_ provides a platform for inbound marketing, landing pages 6 | and web analytics. Its analytics module tracks individual customer 7 | interaction, funnel and e-commerce analysis. Landing pages can be 8 | created and designed on-line, and integrated with you existing website. 9 | 10 | .. _Performable: http://www.performable.com/ 11 | 12 | 13 | .. performable-installation: 14 | 15 | Installation 16 | ============ 17 | 18 | To start using the Performable integration, you must have installed the 19 | django-analytical package and have added the ``analytical`` application 20 | to :const:`INSTALLED_APPS` in your project :file:`settings.py` file. 21 | See :doc:`../install` for details. 22 | 23 | Next you need to add the Performable template tag to your templates. 24 | This step is only needed if you are not using the generic 25 | :ttag:`analytical.*` tags. If you are, skip to 26 | :ref:`performable-configuration`. 27 | 28 | The Performable JavaScript code is inserted into templates using a 29 | template tag. Load the :mod:`performable` template tag library and 30 | insert the :ttag:`performable` tag. Because every page that you want to 31 | track must have the tag, it is useful to add it to your base template. 32 | Insert the tag at the bottom of the HTML body:: 33 | 34 | {% load performable %} 35 | ... 36 | {% performable %} 37 | 38 | 39 | 40 | 41 | .. _performable-configuration: 42 | 43 | Configuration 44 | ============= 45 | 46 | Before you can use the Performable integration, you must first set your 47 | API key. 48 | 49 | 50 | .. _performable-account-code: 51 | 52 | Setting the API key 53 | ------------------- 54 | 55 | You Performable account has its own API key, which :ttag:`performable` 56 | tag will include it in the rendered JavaScript code. You can find your 57 | API key on the *Account Settings* page (click 'Account Settings' in the 58 | top right-hand corner of your Performable dashboard). Set 59 | :const:`PERFORMABLE_API_KEY` in the project :file:`settings.py` file:: 60 | 61 | PERFORMABLE_API_KEY = 'XXXXXX' 62 | 63 | If you do not set an API key, the JavaScript code will not be rendered. 64 | 65 | 66 | .. _performable-identity-user: 67 | 68 | Identifying authenticated users 69 | ------------------------------- 70 | 71 | If your websites identifies visitors, you can pass this information on 72 | to Performable so that you can track individual users. By default, the 73 | username of an authenticated user is passed to Performable 74 | automatically. See :ref:`identifying-visitors`. 75 | 76 | You can also send the visitor identity yourself by adding either the 77 | ``performable_identity`` or the ``analytical_identity`` variable to 78 | the template context. If both variables are set, the former takes 79 | precedence. For example:: 80 | 81 | context = RequestContext({'performable_identity': identity}) 82 | return some_template.render(context) 83 | 84 | If you can derive the identity from the HTTP request, you can also use 85 | a context processor that you add to the 86 | :data:`TEMPLATE_CONTEXT_PROCESSORS` list in :file:`settings.py`:: 87 | 88 | def identify(request): 89 | try: 90 | return {'performable_identity': request.user.email} 91 | except AttributeError: 92 | return {} 93 | 94 | Just remember that if you set the same context variable in the 95 | :class:`~django.template.context.RequestContext` constructor and in a 96 | context processor, the latter clobbers the former. 97 | 98 | 99 | .. _performable-internal-ips: 100 | 101 | Internal IP addresses 102 | --------------------- 103 | 104 | Usually you do not want to track clicks from your development or 105 | internal IP addresses. By default, if the tags detect that the client 106 | comes from any address in the :const:`PERFORMABLE_INTERNAL_IPS` setting, 107 | the tracking code is commented out. It takes the value of 108 | :const:`ANALYTICAL_INTERNAL_IPS` by default (which in turn is 109 | :const:`INTERNAL_IPS` by default). See :ref:`identifying-visitors` for 110 | important information about detecting the visitor IP address. 111 | 112 | 113 | .. _performable-embed-page: 114 | 115 | Embedding a landing page 116 | ======================== 117 | 118 | You can embed a Performable landing page in your Django website. The 119 | :ttag:`performable_embed` template tag adds the JavaScript code to embed 120 | the page. It takes two arguments: the hostname and the page ID:: 121 | 122 | {% performable_embed HOSTNAME PAGE_ID %} 123 | 124 | To find the hostname and page ID, select :menuselection:`Manage --> 125 | Manage Landing Pages` on your Performable dashboard. Select the landing 126 | page you want to embed. Look at the URL in your browser address bar; it 127 | will look like this:: 128 | 129 | http://my.performable.com/s/HOSTNAME/page/PAGE_ID/ 130 | 131 | (If you are placing the hostname and page id values in the template, do 132 | not forget to enclose them in quotes or they will be considered context 133 | variable names.) 134 | 135 | 136 | ---- 137 | 138 | Thanks go to Performable for their support with the development of this 139 | application. 140 | -------------------------------------------------------------------------------- /docs/tutorial.rst: -------------------------------------------------------------------------------- 1 | .. _tutorial: 2 | 3 | ======== 4 | Tutorial 5 | ======== 6 | 7 | This tutorial will show you how to install and configure 8 | django-analytical for basic tracking, and then briefly touch on two 9 | common customization issues: visitor identification and custom data 10 | tracking. 11 | 12 | Suppose your Django website provides information about the IPv4 to IPv6 13 | transition. Visitors can discuss their problems and help each other 14 | make the necessary changes to their network infrastructure. You want to 15 | use two different analytics services: 16 | 17 | * :doc:`Clicky ` for detailed traffic analysis 18 | * :doc:`Crazy Egg ` to see where visitors click on 19 | your pages 20 | 21 | At the end of this tutorial, the project will track visitors on both 22 | Clicky and Crazy Egg, identify authenticated users and add extra 23 | tracking data to segment mouse clicks on Crazy Egg based on whether 24 | visitors are using IPv4 or IPv6. 25 | 26 | 27 | Setting up basic tracking 28 | ========================= 29 | 30 | To get started with django-analytical, the package must first be 31 | installed. You can download and install the latest stable package from 32 | the Python Package Index automatically by using ``easy_install``: 33 | 34 | .. code-block:: bash 35 | 36 | $ easy_install django-analytical 37 | 38 | For more ways to install django-analytical, see 39 | :ref:`installing-the-package`. 40 | 41 | After you install django-analytical, you need to add it to the list of 42 | installed applications in the ``settings.py`` file of your project: 43 | 44 | .. code-block:: python 45 | 46 | INSTALLED_APPS = [ 47 | ... 48 | 'analytical', 49 | ... 50 | ] 51 | 52 | Then you have to add the general-purpose django-analytical template tags 53 | to your base template: 54 | 55 | .. code-block:: django 56 | 57 | {% load analytical %} 58 | 59 | 60 | 61 | {% analytical_head_top %} 62 | 63 | ... 64 | 65 | {% analytical_head_bottom %} 66 | 67 | 68 | {% analytical_body_top %} 69 | 70 | ... 71 | 72 | {% analytical_body_bottom %} 73 | 74 | 75 | 76 | Finally, you need to configure the Clicky Site ID and the Crazy Egg 77 | account number. Add the following to your project :file:`settings.py` 78 | file (replacing the ``x``'s with your own codes): 79 | 80 | .. code-block:: python 81 | 82 | CLICKY_SITE_ID = 'xxxxxxxx' 83 | CRAZY_EGG_ACCOUNT_NUMBER = 'xxxxxxxx' 84 | 85 | The analytics services are now installed. If you run Django with these 86 | changes, both Clicky and Crazy Egg will be tracking your visitors. 87 | 88 | 89 | Identifying authenticated users 90 | =============================== 91 | 92 | Suppose that when your visitors post questions on IPv6 or tell others 93 | about their experience with the transition, they first log in through 94 | the standard Django authentication system. Clicky can identify and 95 | track individual visitors and you want to use this feature. 96 | 97 | If django-analytical template tags detect that the current user is 98 | authenticated, they will automatically include code to send the username 99 | to services that support this feature. This only works if the template 100 | context has the current user in the ``user`` or ``request.user`` context 101 | variable. If you use a :class:`~django.template.RequestContext` to 102 | render templates (which is recommended anyway) and have the 103 | :class:`django.contrib.auth.context_processors.auth` context processor 104 | in the :data:`TEMPLATE_CONTEXT_PROCESSORS` setting (which is default), 105 | then this identification works without having to make any changes. 106 | 107 | For more detailed information on automatic identification, and how to 108 | disable or override it, see :ref:`identifying-visitors`. 109 | 110 | 111 | Adding custom tracking data 112 | =========================== 113 | 114 | Suppose that you think that visitors who already have IPv6 use the 115 | website in a different way from those still on IPv4. You want to test 116 | this hypothesis by segmenting the Crazy Egg heatmaps based on the IP 117 | protocol version. 118 | 119 | In order to filter on protocol version in Crazy Egg, you need to 120 | include the visitor IP protocol version in the Crazy Egg tracking code. 121 | The easiest way to do this is by using a context processor: 122 | 123 | .. code-block:: python 124 | 125 | def track_ip_proto(request): 126 | addr = request.META.get('HTTP_X_FORWARDED_FOR', '') 127 | if not addr: 128 | addr = request.META.get('REMOTE_ADDR', '') 129 | if ':' in addr: 130 | proto = 'ipv6' 131 | else: 132 | proto = 'ipv4' # assume IPv4 if no information 133 | return {'crazy_egg_var1': proto} 134 | 135 | Use a :class:`~django.template.RequestContext` when rendering templates 136 | and add the ``'track_ip_proto'`` to :data:`TEMPLATE_CONTEXT_PROCESSORS`. 137 | In Crazy Egg, you can now select *User Var1* in the overlay or confetti 138 | views to see whether visitors using IPv4 behave differently from those 139 | using IPv6. 140 | 141 | 142 | ---- 143 | 144 | This concludes the tutorial. For information about setting up, 145 | configuring and customizing the different analytics services, see 146 | :doc:`features` and :doc:`services`. 147 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "setuptools.build_meta" 3 | requires = ["setuptools>=80"] 4 | 5 | [project] 6 | name = "django-analytical" 7 | dynamic = ["version"] 8 | description = "Analytics service integration for Django projects" 9 | readme = "README.rst" 10 | license = "MIT" 11 | license-files = ["LICENSE.txt"] 12 | authors = [ 13 | {name = "Joost Cassee", email = "joost@cassee.net"}, 14 | {name = "Joshua Krall", email = "joshuakrall@pobox.com"}, 15 | {name = "Aleck Landgraf", email = "aleck.landgraf@buildingenergy.com"}, 16 | {name = "Alexandre Pocquet", email = "apocquet@lecko.fr"}, 17 | {name = "Bateau Knowledge", email = "info@bateauknowledge.nl"}, 18 | {name = "Bogdan Bodnar", email = "bogdanbodnar@mail.com"}, 19 | {name = "Brad Pitcher", email = "bradpitcher@gmail.com"}, 20 | {name = "Corentin Mercier", email = "corentin@mercier.link"}, 21 | {name = "Craig Bruce", email = "craig@eyesopen.com"}, 22 | {name = "Daniel Vitiello", email = "ezdismissal@gmail.com"}, 23 | {name = "David Smith", email = "smithdc@gmail.com"}, 24 | {name = "Diederik van der Boor", email = "vdboor@edoburu.nl"}, 25 | {name = "Eric Amador", email = "eric.amador14@gmail.com"}, 26 | {name = "Eric Davis", email = "eric@davislv.com"}, 27 | {name = "Eric Wang", email = "gnawrice@gmail.com"}, 28 | {name = "Erick Massip", email = "ericmassip1@gmail.com"}, 29 | {name = "Garrett Coakley", email = "garrettc@users.noreply.github.com"}, 30 | {name = "Garrett Robinson", email = "garrett.f.robinson@gmail.com"}, 31 | {name = "GreenKahuna", email = "info@greenkahuna.com"}, 32 | {name = "Hugo Osvaldo Barrera", email = "hugo@barrera.io"}, 33 | {name = "Ian Ramsay", email = "ianalexr@yahoo.com"}, 34 | {name = "Iván Raskovsky", email = "raskovsky+git@gmail.com"}, 35 | {name = "James Paden", email = "james@xemion.com"}, 36 | {name = "Jannis Leidel", email = "jannis@leidel.info"}, 37 | {name = "Julien Grenier", email = "julien.grenier42@gmail.com"}, 38 | {name = "Kevin Olbrich", email = "ko@sv01.de"}, 39 | {name = "Marc Bourqui", email = "m.bourqui@edsi-tech.com"}, 40 | {name = "Martey Dodoo", email = "martey@mobolic.com"}, 41 | {name = "Martín Gaitán", email = "gaitan@gmail.com"}, 42 | {name = "Matthäus G. Chajdas", email = "dev@anteru.net"}, 43 | {name = "Max Arnold", email = "arnold.maxim@gmail.com"}, 44 | {name = "Nikolay Korotkiy", email = "sikmir@gmail.com"}, 45 | {name = "Paul Oswald", email = "pauloswald@gmail.com"}, 46 | {name = "Peter Bittner", email = "django@bittner.it"}, 47 | {name = "Petr Dlouhý", email = "petr.dlouhy@email.cz"}, 48 | {name = "Philippe O. Wagner", email = "admin@arteria.ch"}, 49 | {name = "Pi Delport", email = "pjdelport@gmail.com"}, 50 | {name = "Sandra Mau", email = "sandra.mau@gmail.com"}, 51 | {name = "Scott Adams", email = "scottadams80@gmail.com"}, 52 | {name = "Scott Karlin", email = "gitlab@karlin-online.com"}, 53 | {name = "Sean Wallace", email = "sean@lowpro.ca"}, 54 | {name = "Sid Mitra", email = "sidmitra.del@gmail.com"}, 55 | {name = "Simon Ye", email = "sye737@gmail.com"}, 56 | {name = "Steve Schwarz", email = "steve@agilitynerd.com"}, 57 | {name = "Steven Skoczen", email = "steven.skoczen@wk.com"}, 58 | {name = "Tim Gates", email = "tim.gates@iress.com"}, 59 | {name = "Tinnet Coronam", email = "tinnet@coronam.net"}, 60 | {name = "Uros Trebec", email = "uros@trebec.org"}, 61 | {name = "Walter Renner", email = "walter.renner@me.com"}, 62 | ] 63 | maintainers = [ 64 | {name = "Jazzband community", email = "jazzband-bot@users.noreply.github.com"}, 65 | {name = "Peter Bittner", email = "django@bittner.it"}, 66 | ] 67 | keywords=[ 68 | "django", 69 | "analytics", 70 | ] 71 | classifiers=[ 72 | "Development Status :: 5 - Production/Stable", 73 | "Environment :: Web Environment", 74 | "Framework :: Django", 75 | "Framework :: Django :: 4.2", 76 | "Framework :: Django :: 5.1", 77 | "Framework :: Django :: 5.2", 78 | "Intended Audience :: Developers", 79 | "Operating System :: OS Independent", 80 | "Programming Language :: Python", 81 | "Programming Language :: Python :: 3", 82 | "Programming Language :: Python :: 3 :: Only", 83 | "Programming Language :: Python :: 3.9", 84 | "Programming Language :: Python :: 3.10", 85 | "Programming Language :: Python :: 3.11", 86 | "Programming Language :: Python :: 3.12", 87 | "Programming Language :: Python :: 3.13", 88 | "Topic :: Internet :: WWW/HTTP", 89 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 90 | "Topic :: Software Development :: Libraries :: Python Modules", 91 | ] 92 | requires-python = ">=3.9" 93 | dependencies = [ 94 | "django>=4.2", 95 | ] 96 | 97 | [project.urls] 98 | Homepage = "https://github.com/jazzband/django-analytical" 99 | Documentation = "https://django-analytical.readthedocs.io/" 100 | 101 | [tool.coverage.report] 102 | show_missing = true 103 | skip_covered = true 104 | 105 | [tool.coverage.run] 106 | source = ["analytical"] 107 | 108 | [tool.pytest.ini_options] 109 | addopts = "--junitxml=tests/unittests-report.xml --color=yes --verbose" 110 | DJANGO_SETTINGS_MODULE = "tests.testproject.settings" 111 | 112 | [tool.ruff.format] 113 | quote-style = "single" 114 | 115 | [tool.ruff.lint.flake8-quotes] 116 | inline-quotes = "single" 117 | 118 | [tool.setuptools] 119 | packages = [ 120 | "analytical", 121 | "analytical.templatetags", 122 | ] 123 | 124 | [tool.setuptools.dynamic] 125 | version = {attr = "analytical.__version__"} 126 | --------------------------------------------------------------------------------