├── .coveragerc ├── .gitignore ├── .travis.yml ├── CHANGELOG ├── LICENSE ├── MANIFEST.in ├── README.md ├── example ├── example │ ├── __init__.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── geoip │ ├── GeoLite2-City.mmdb │ ├── GeoLiteCity.dat │ └── LICENSE ├── manage.py └── templates │ └── 404.html ├── setup.py └── tracking ├── __init__.py ├── admin.py ├── apps.py ├── cache.py ├── handlers.py ├── managers.py ├── middleware.py ├── migrations ├── 0001_initial.py ├── 0002_auto_20180918_2014.py └── __init__.py ├── models.py ├── settings.py ├── templates └── tracking │ ├── dashboard.html │ └── snippets │ └── stats.html ├── tests ├── __init__.py ├── test_geoip.py ├── test_managers.py ├── test_middleware.py ├── test_utils.py └── test_views.py ├── urls.py ├── utils.py └── views.py /.coveragerc: -------------------------------------------------------------------------------- 1 | # .coveragerc to control coverage.py 2 | [run] 3 | branch = True 4 | source = . 5 | 6 | [report] 7 | omit = 8 | setup.py 9 | example/* 10 | tracking/migrations/* 11 | tracking/compat.py 12 | tracking/tests/* 13 | 14 | exclude_lines = 15 | # versioning is for packaging routines 16 | def get_version 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.sw? 2 | *.py? 3 | *~ 4 | *.egg-info 5 | *.egg/ 6 | .DS_Store 7 | dist 8 | MANIFEST 9 | .coverage 10 | htmlcov/ 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | sudo: false 4 | 5 | python: 6 | - "3.6" 7 | - "3.7" 8 | - "3.8" 9 | - "3.9" 10 | - "3.10" 11 | 12 | env: 13 | - DJANGO="Django>=3.2,<3.3" 14 | - DJANGO="Django>=4.0,<4.1" 15 | 16 | matrix: 17 | exclude: 18 | - python: "3.6" 19 | env: DJANGO="Django>=4.0,<4.1" 20 | - python: "3.7" 21 | env: DJANGO="Django>=4.0,<4.1" 22 | 23 | addons: 24 | apt: 25 | packages: 26 | - libgeoip-dev 27 | 28 | install: 29 | - pip install "$DJANGO" 30 | - pip install geoip 31 | - pip install geoip2 32 | - pip install coverage django-discover-runner mock unittest2 33 | - pip freeze 34 | - python setup.py develop 35 | 36 | script: 37 | - python example/manage.py test --verbosity=2 38 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | 2015-07-22 Michael J. Schultz 2 | 3 | * Add continuous integration (CI) tests for Django 1.8 4 | * Drop CI tests for Django 1.5 and 1.6 5 | 6 | 2015-07-10 Michael J. Schultz 7 | 8 | * Handle race condition when saving visitor in middleware. 9 | 10 | 2014-12-27 Byron Ruth 11 | 12 | * Replace IP validation with Django's built-in validators. This also 13 | removes a Windows-specific issue of not having the `socket.inet_aton` 14 | function. (#48, #47) 15 | 16 | 2014-12-08 Michael J. Schultz 17 | 18 | * Add tests to existing codebase with minor bugfixes 19 | * Change /dashboard/ view to include past 7 days by default (#20, #28) 20 | * Improve timezone support by removing `.date()` calls (#39) 21 | * Increase scope of `TRACK_IGNORE_URLS` beyond Pageview tracking (#45) 22 | 23 | 2013-11-07 Byron Ruth 24 | 25 | * Add PageView.method field for tracking the request method used 26 | 27 | 2013-11-06 Byron Ruth 28 | 29 | * Update deprecated import path for Django 1.6+ 30 | 31 | 2013-11-01 Byron Ruth 32 | 33 | * Move stats dashboard endpoint / rather than /dashboard/ 34 | * Update initial migration to support custom user models for Django 1.5+ 35 | 36 | 2013-09-30 Byron Ruth 37 | 38 | * Fix MANIFEST.in file to graft templates directory 39 | 40 | 2013-09-08 Byron Ruth 41 | 42 | * Add support custom user model in Django 1.5+ 43 | 44 | 2013-07-14 Byron Ruth 45 | 46 | * Remove deprecated module import for GIS by Tarak Blah 47 | * Added start and end date to stats view context by Tarak Blah 48 | * Remove registered users as requirement in manager by Tarak Blah 49 | 50 | 2013-03-16 Byron Ruth 51 | 52 | * Fix offset-aware and offset-naive datetime errors by Chris Franklin 53 | 54 | 2012-03-14 Byron Ruth 55 | 56 | * Add internal cache mechanism to reduce database hits in middleware 57 | by one. Cache invalidates after `SESSION_COOKIE_AGE` and when the user 58 | explicitly logs out. 59 | 60 | 2012-03-08 Byron Ruth 61 | 62 | * Add `TRACK_PAGEVIEWS` and `TRACK_IGNORE_URLS` settings. 63 | * Deprecate `TRACKING_USE_GEOIP` in favor of `TRACK_USING_GEOIP` 64 | (even though this is not an officially supported feature) 65 | 66 | 2012-03-03 Byron Ruth 67 | 68 | * Add `TRACK_ANONYMOUS_USERS` setting 69 | 70 | 2012-01-30 Byron Ruth 71 | 72 | * Fix bug where the 'User-Agent' header was assumed to 73 | exist which caused an exception. 74 | 75 | 2012-01-25 Byron Ruth 76 | 77 | * Add `TRACK_AJAX_REQUESTS` setting 78 | 79 | 2012-01-18 Byron Ruth 80 | 81 | * Initial release 82 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012-2013 Byron Ruth 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, this 11 | list of conditions and the following disclaimer in the documentation and/or 12 | other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 15 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 16 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 18 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 19 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 20 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 21 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 22 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 23 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | global-exclude .DS_Store 2 | graft tracking/templates 3 | include README.md 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Overview 2 | ======== 3 | 4 | [![Build Status](https://travis-ci.org/bruth/django-tracking2.svg?branch=master)](https://travis-ci.org/bruth/django-tracking2) 5 | [![PyPI](https://img.shields.io/pypi/v/django-tracking2.svg)](https://pypi.python.org/pypi/django-tracking2) 6 | 7 | django-tracking2 tracks the length of time visitors and registered users 8 | spend on your site. Although this will work for websites, this is more 9 | applicable to web _applications_ with registered users. This does 10 | not replace (nor intend) to replace client-side analytics which is 11 | great for understanding aggregate flow of page views. 12 | 13 | **Note: This is not a new version of [django-tracking]. These apps 14 | have very different approaches and, ultimately, goals of tracking users. 15 | This app is about keeping a history of visitor sessions, rather than the 16 | current state of the visitor.** 17 | 18 | [django-tracking]: https://github.com/codekoala/django-tracking 19 | 20 | Requirements 21 | ============ 22 | * Django's [session framework][1] installed 23 | 24 | [1]: https://docs.djangoproject.com/en/3.2/topics/http/sessions/ 25 | 26 | Download 27 | ======== 28 | ```bash 29 | pip install django-tracking2 30 | ``` 31 | 32 | Setup 33 | ===== 34 | Add `tracking` to your project's `INSTALLED_APPS` setting: 35 | 36 | ```python 37 | INSTALLED_APPS = ( 38 | ... 39 | 'tracking', 40 | ... 41 | ) 42 | ``` 43 | 44 | The `tracking` app should follow the app with your user model 45 | 46 | Add `tracking.middleware.VisitorTrackingMiddleware` to your project's 47 | `MIDDLEWARE_CLASSES` before the `SessionMiddleware`: 48 | 49 | ```python 50 | MIDDLEWARE_CLASSES = ( 51 | ... 52 | 'tracking.middleware.VisitorTrackingMiddleware', 53 | 'django.contrib.sessions.middleware.SessionMiddleware', 54 | ... 55 | ) 56 | ``` 57 | 58 | Settings 59 | -------- 60 | `TRACK_AJAX_REQUESTS` - If True, AJAX requests will be tracked. Default 61 | is False 62 | 63 | `TRACK_ANONYMOUS_USERS` - If False, anonymous users will not be tracked. 64 | Default is True 65 | 66 | `TRACK_SUPERUSERS` - If False, users with the superuser flag set to True will 67 | not be tracked. Default is True. 68 | 69 | `TRACK_PAGEVIEWS` - If True, individual pageviews will be tracked. 70 | 71 | `TRACK_IGNORE_URLS` - A list of regular expressions that will be matched 72 | against the `request.path_info` (`request.path` is stored, but not matched 73 | against). If they are matched, the visitor (and pageview) record will not 74 | be saved. Default includes 'favicon.ico' and 'robots.txt'. Note, static and 75 | media are not included since they should be served up statically Django's 76 | static serve view or via a lightweight server in production. Read more 77 | [here](https://docs.djangoproject.com/en/dev/howto/static-files/#serving-other-directories) 78 | 79 | `TRACK_IGNORE_STATUS_CODES` - A list of HttpResponse status codes that will be ignored. 80 | If the HttpResponse object has a `status_code` in this blacklist, the pageview record 81 | will not be saved. For example, 82 | 83 | ```python 84 | TRACK_IGNORE_STATUS_CODES = [400, 404, 403, 405, 410, 500] 85 | ``` 86 | 87 | `TRACK_REFERER` - If True, referring site for all pageviews will be tracked. Default is False 88 | 89 | `TRACK_QUERY_STRING` - If True, query string for all pageviews will be tracked. Default is False 90 | 91 | Views 92 | ----- 93 | To view aggregate data about all visitors and per-registered user stats, 94 | do the following: 95 | 96 | Include `tracking.urls` in your `urls.py`: 97 | 98 | ```python 99 | urlpatterns = [ 100 | ... 101 | re_path(r'^tracking/', include('tracking.urls')), 102 | ... 103 | ] 104 | ``` 105 | 106 | These urls are protected by a custom Django permission `tracking.visitor_log`. 107 | Thus only superusers and users granted this permission can view these pages. 108 | 109 | Available URLs 110 | -------------- 111 | * `/` - overview of all visitor activity, includes a time picker for 112 | filtering. 113 | 114 | Templates 115 | --------- 116 | * `tracking/dashboard.html` - for the dashboard page 117 | * `tracking/snippets/stats.html` - standalone content for the dashboard page 118 | (simplifies overriding templates) 119 | -------------------------------------------------------------------------------- /example/example/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bruth/django-tracking2/89a184e5227ec6c68e614ca9e372a4c1196b3fdc/example/example/__init__.py -------------------------------------------------------------------------------- /example/example/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for example project. 3 | 4 | For more information on this file, see 5 | https://docs.djangoproject.com/en/1.6/topics/settings/ 6 | 7 | For the full list of settings and their values, see 8 | https://docs.djangoproject.com/en/1.6/ref/settings/ 9 | """ 10 | 11 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 12 | import os 13 | BASE_DIR = os.path.dirname(os.path.dirname(__file__)) 14 | 15 | 16 | # Quick-start development settings - unsuitable for production 17 | # See https://docs.djangoproject.com/en/1.6/howto/deployment/checklist/ 18 | 19 | # SECURITY WARNING: keep the secret key used in production secret! 20 | SECRET_KEY = '6!9cxt&$5a3bz-*xf(l$r4(z24pxyytf0aksfb_kt^b$1kq^4g' 21 | 22 | # SECURITY WARNING: don't run with debug turned on in production! 23 | DEBUG = True 24 | 25 | ALLOWED_HOSTS = [] 26 | 27 | # Application definition 28 | 29 | TRACK_PAGEVIEWS = True 30 | GEOIP_PATH = os.path.join(BASE_DIR, 'geoip') 31 | 32 | TEMPLATES = [ 33 | { 34 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 35 | 'APP_DIRS': True, 36 | 'OPTIONS': { 37 | 'context_processors': [ 38 | 'django.contrib.auth.context_processors.auth', 39 | 'django.template.context_processors.debug', 40 | 'django.template.context_processors.i18n', 41 | 'django.template.context_processors.media', 42 | 'django.template.context_processors.request', 43 | 'django.template.context_processors.static', 44 | 'django.template.context_processors.tz', 45 | 'django.contrib.messages.context_processors.messages', 46 | ], 47 | }, 48 | } 49 | ] 50 | 51 | INSTALLED_APPS = ( 52 | 'django.contrib.admin', 53 | 'django.contrib.auth', 54 | 'django.contrib.contenttypes', 55 | 'django.contrib.sessions', 56 | 'django.contrib.messages', 57 | 'django.contrib.staticfiles', 58 | 59 | # add tracking as INSTALLED_APPS 60 | 'tracking', 61 | ) 62 | 63 | MIDDLEWARE = [ 64 | # make sure tracking middleware is before SessionMiddleware 65 | 'tracking.middleware.VisitorTrackingMiddleware', 66 | 67 | 'django.middleware.security.SecurityMiddleware', 68 | 'django.contrib.sessions.middleware.SessionMiddleware', 69 | 'django.middleware.common.CommonMiddleware', 70 | 'django.middleware.csrf.CsrfViewMiddleware', 71 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 72 | 'django.contrib.messages.middleware.MessageMiddleware', 73 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 74 | ] 75 | 76 | ROOT_URLCONF = 'example.urls' 77 | 78 | WSGI_APPLICATION = 'example.wsgi.application' 79 | 80 | 81 | # Database 82 | # https://docs.djangoproject.com/en/1.6/ref/settings/#databases 83 | 84 | DATABASES = { 85 | 'default': { 86 | 'ENGINE': 'django.db.backends.sqlite3', 87 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 88 | } 89 | } 90 | 91 | # Internationalization 92 | # https://docs.djangoproject.com/en/1.6/topics/i18n/ 93 | 94 | LANGUAGE_CODE = 'en-us' 95 | 96 | TIME_ZONE = 'UTC' 97 | 98 | USE_I18N = True 99 | 100 | USE_L10N = True 101 | 102 | USE_TZ = True 103 | 104 | 105 | # Static files (CSS, JavaScript, Images) 106 | # https://docs.djangoproject.com/en/1.6/howto/static-files/ 107 | 108 | STATIC_URL = '/static/' 109 | -------------------------------------------------------------------------------- /example/example/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import include 2 | from django.contrib import admin 3 | from django.urls import re_path 4 | 5 | urlpatterns = [ 6 | re_path(r'^admin/', admin.site.urls), 7 | re_path(r'^tracking/', include('tracking.urls')), 8 | ] 9 | -------------------------------------------------------------------------------- /example/example/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for example project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.6/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 12 | 13 | from django.core.wsgi import get_wsgi_application 14 | application = get_wsgi_application() 15 | -------------------------------------------------------------------------------- /example/geoip/GeoLite2-City.mmdb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bruth/django-tracking2/89a184e5227ec6c68e614ca9e372a4c1196b3fdc/example/geoip/GeoLite2-City.mmdb -------------------------------------------------------------------------------- /example/geoip/GeoLiteCity.dat: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bruth/django-tracking2/89a184e5227ec6c68e614ca9e372a4c1196b3fdc/example/geoip/GeoLiteCity.dat -------------------------------------------------------------------------------- /example/geoip/LICENSE: -------------------------------------------------------------------------------- 1 | This directory contains a test city database from 2 | https://github.com/maxmind/geoip-api-python/blob/master/tests/data/. 3 | The code in that repository is licensed under LGPL, the data likely falls 4 | under a similar license. We are not using any code from that repo and the 5 | data is unmodified. 6 | -------------------------------------------------------------------------------- /example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /example/templates/404.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bruth/django-tracking2/89a184e5227ec6c68e614ca9e372a4c1196b3fdc/example/templates/404.html -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | kwargs = { 4 | 'packages': find_packages( 5 | exclude=['tests', '*.tests', '*.tests.*', 'tests.*']), 6 | 'include_package_data': True, 7 | 'install_requires': [ 8 | 'django>=3.2', 9 | ], 10 | 'name': 'django-tracking2', 11 | 'version': __import__('tracking').get_version(), 12 | 'author': 'Byron Ruth', 13 | 'author_email': 'b@devel.io', 14 | 'description': ('django-tracking2 tracks the length of time visitors ' 15 | 'and registered users spend on your site'), 16 | 'license': 'BSD', 17 | 'keywords': 'visitor tracking time analytics', 18 | 'url': 'https://github.com/bruth/django-tracking2', 19 | 'classifiers': [ 20 | 'Development Status :: 5 - Production/Stable', 21 | 'Framework :: Django', 22 | 'Intended Audience :: Developers', 23 | 'License :: OSI Approved :: BSD License', 24 | 'Operating System :: OS Independent', 25 | 'Topic :: Internet :: WWW/HTTP', 26 | 'Programming Language :: Python :: 3.6', 27 | 'Programming Language :: Python :: 3.7', 28 | 'Programming Language :: Python :: 3.8', 29 | 'Programming Language :: Python :: 3.9', 30 | 'Programming Language :: Python :: 3.10', 31 | ], 32 | } 33 | 34 | setup(**kwargs) 35 | -------------------------------------------------------------------------------- /tracking/__init__.py: -------------------------------------------------------------------------------- 1 | __version_info__ = { 2 | 'major': 0, 3 | 'minor': 4, 4 | 'micro': 2, 5 | 'releaselevel': 'beta', 6 | 'serial': 1 7 | } 8 | 9 | def get_version(short=False): 10 | assert __version_info__['releaselevel'] in ('alpha', 'beta', 'final') 11 | vers = ["%(major)i.%(minor)i.%(micro)i" % __version_info__] 12 | if __version_info__['releaselevel'] != 'final' and not short: 13 | vers.append('%s%i' % (__version_info__['releaselevel'][0], __version_info__['serial'])) 14 | return ''.join(vers) 15 | 16 | __version__ = get_version() 17 | default_app_config = 'tracking.apps.TrackingConfig' 18 | -------------------------------------------------------------------------------- /tracking/admin.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from django.contrib import admin 3 | from tracking.models import Visitor, Pageview 4 | from tracking.settings import TRACK_PAGEVIEWS 5 | 6 | class VisitorAdmin(admin.ModelAdmin): 7 | date_hierarchy = 'start_time' 8 | 9 | list_display = ('session_key', 'user', 'start_time', 'session_over', 10 | 'pretty_time_on_site', 'ip_address', 'user_agent') 11 | list_filter = ('user', 'ip_address') 12 | 13 | def session_over(self, obj): 14 | return obj.session_ended() or obj.session_expired() 15 | session_over.boolean = True 16 | 17 | def pretty_time_on_site(self, obj): 18 | if obj.time_on_site is not None: 19 | return timedelta(seconds=obj.time_on_site) 20 | pretty_time_on_site.short_description = 'Time on site' 21 | 22 | 23 | admin.site.register(Visitor, VisitorAdmin) 24 | 25 | 26 | class PageviewAdmin(admin.ModelAdmin): 27 | date_hierarchy = 'view_time' 28 | 29 | list_display = ('url', 'view_time') 30 | 31 | 32 | if TRACK_PAGEVIEWS: 33 | admin.site.register(Pageview, PageviewAdmin) 34 | -------------------------------------------------------------------------------- /tracking/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.contrib.auth.signals import user_logged_out 3 | from django.db.models.signals import post_save 4 | 5 | 6 | class TrackingConfig(AppConfig): 7 | 8 | name = 'tracking' 9 | verbose_name = 'django-tracking2' 10 | default_auto_field = 'django.db.models.AutoField' 11 | 12 | def ready(self): 13 | from tracking import handlers 14 | from tracking.models import Visitor 15 | user_logged_out.connect(handlers.track_ended_session) 16 | post_save.connect(handlers.post_save_cache, sender=Visitor) 17 | -------------------------------------------------------------------------------- /tracking/cache.py: -------------------------------------------------------------------------------- 1 | # Inspired by http://eflorenzano.com/blog/2008/11/28/drop-dead-simple-django-caching/ 2 | from django.db import models 3 | from django.core.cache import cache 4 | from django.db.models.query import QuerySet 5 | 6 | 7 | def instance_cache_key(instance): 8 | opts = instance._meta 9 | if hasattr(opts, 'model_name'): 10 | name = opts.model_name 11 | else: 12 | name = opts.module_name 13 | return '%s.%s:%s' % (opts.app_label, name, instance.pk) 14 | 15 | 16 | class CacheQuerySet(QuerySet): 17 | def filter(self, *args, **kwargs): 18 | pk = None 19 | for val in ('pk', 'pk__exact', 'id', 'id__exact'): 20 | if val in kwargs: 21 | pk = kwargs[val] 22 | break 23 | if pk is not None: 24 | opts = self.model._meta 25 | key = '%s.%s:%s' % (opts.app_label, opts.model_name, pk) 26 | obj = cache.get(key) 27 | if obj is not None: 28 | self._result_cache = [obj] 29 | return super(CacheQuerySet, self).filter(*args, **kwargs) 30 | 31 | 32 | class CacheManager(models.Manager): 33 | def get_queryset(self): 34 | return CacheQuerySet(self.model) 35 | 36 | -------------------------------------------------------------------------------- /tracking/handlers.py: -------------------------------------------------------------------------------- 1 | from django.utils import timezone 2 | from django.core.cache import cache 3 | from django.conf import settings 4 | from tracking.models import Visitor 5 | from tracking.cache import instance_cache_key 6 | 7 | SESSION_COOKIE_AGE = getattr(settings, 'SESSION_COOKIE_AGE') 8 | 9 | 10 | def track_ended_session(sender, request, user, **kwargs): 11 | try: 12 | visitor = Visitor.objects.get(pk=request.session.session_key) 13 | # This should rarely ever occur.. e.g. direct request to logout 14 | except Visitor.DoesNotExist: 15 | return 16 | 17 | # Explicitly end this session. This improves the accuracy of the stats. 18 | visitor.end_time = timezone.now() 19 | visitor.time_on_site = (visitor.end_time - visitor.start_time).seconds 20 | visitor.save() 21 | 22 | # Unset the cache since the user logged out, this particular visitor will 23 | # unlikely be accessed individually. 24 | cache.delete(instance_cache_key(visitor)) 25 | 26 | 27 | def post_save_cache(sender, instance, **kwargs): 28 | cache.set(instance_cache_key(instance), instance, SESSION_COOKIE_AGE) 29 | -------------------------------------------------------------------------------- /tracking/managers.py: -------------------------------------------------------------------------------- 1 | from __future__ import division 2 | 3 | from datetime import timedelta 4 | from django.utils import timezone 5 | from django.contrib.auth import get_user_model 6 | from django.db import models 7 | from django.db.models import Count, Avg 8 | from tracking.settings import TRACK_PAGEVIEWS, TRACK_ANONYMOUS_USERS 9 | from tracking.cache import CacheManager 10 | 11 | 12 | class VisitorManager(CacheManager): 13 | def active(self, registered_only=True): 14 | "Returns all active users, e.g. not logged and non-expired session." 15 | visitors = self.filter( 16 | expiry_time__gt=timezone.now(), 17 | end_time=None 18 | ) 19 | if registered_only: 20 | visitors = visitors.filter(user__isnull=False) 21 | return visitors 22 | 23 | def registered(self): 24 | return self.get_queryset().filter(user__isnull=False) 25 | 26 | def guests(self): 27 | return self.get_queryset().filter(user__isnull=True) 28 | 29 | def stats(self, start_date, end_date, registered_only=False): 30 | """Returns a dictionary of visits including: 31 | 32 | * total visits 33 | * unique visits 34 | * return ratio 35 | * pages per visit (if pageviews are enabled) 36 | * time on site 37 | 38 | for all users, registered users and guests. 39 | """ 40 | visitors = self.filter( 41 | start_time__gte=start_date, 42 | start_time__lt=end_date 43 | ) 44 | 45 | stats = { 46 | 'total': 0, 47 | 'unique': 0, 48 | 'return_ratio': 0, 49 | } 50 | 51 | # All visitors 52 | stats['total'] = total_count = visitors.count() 53 | unique_count = 0 54 | 55 | # No visitors! Nothing more to do. 56 | if not total_count: 57 | return stats 58 | 59 | # Avg time on site 60 | total_time_on_site = visitors.aggregate( 61 | avg_tos=Avg('time_on_site'))['avg_tos'] 62 | stats['time_on_site'] = timedelta(seconds=int(total_time_on_site)) 63 | 64 | # Registered user sessions 65 | registered_visitors = visitors.filter(user__isnull=False) 66 | registered_total_count = registered_visitors.count() 67 | 68 | if registered_total_count: 69 | registered_unique_count = registered_visitors.values( 70 | 'user' 71 | ).distinct().count() 72 | # Avg time on site 73 | time_on_site = registered_visitors.aggregate( 74 | avg_tos=Avg('time_on_site'))['avg_tos'] 75 | 76 | # Update the total unique count.. 77 | unique_count += registered_unique_count 78 | 79 | # Set the registered stats.. 80 | returns = (registered_total_count - registered_unique_count) 81 | stats['registered'] = { 82 | 'total': registered_total_count, 83 | 'unique': registered_unique_count, 84 | 'return_ratio': (returns / registered_total_count) * 100, 85 | 'time_on_site': timedelta(seconds=int(time_on_site)), 86 | } 87 | 88 | # Get stats for our guests.. 89 | if TRACK_ANONYMOUS_USERS and not registered_only: 90 | guests = visitors.filter(user__isnull=True) 91 | guest_total_count = guests.count() 92 | 93 | if guest_total_count: 94 | guest_unique_count = guests.values( 95 | 'ip_address' 96 | ).distinct().count() 97 | # Avg time on site 98 | guest_time_on_site = guests.aggregate( 99 | avg_tos=Avg('time_on_site'))['avg_tos'] 100 | # return rate 101 | returns = (guest_total_count - guest_unique_count) 102 | return_ratio = (returns / guest_total_count) * 100 103 | time_on_site = timedelta(seconds=int(guest_time_on_site)) 104 | else: 105 | guest_total_count = 0 106 | guest_unique_count = 0 107 | return_ratio = 0.0 108 | time_on_site = timedelta(0) 109 | 110 | # Update the total unique count 111 | unique_count += guest_unique_count 112 | stats['guests'] = { 113 | 'total': guest_total_count, 114 | 'unique': guest_unique_count, 115 | 'return_ratio': return_ratio, 116 | 'time_on_site': time_on_site, 117 | } 118 | 119 | # Finish setting the total visitor counts 120 | returns = (total_count - unique_count) 121 | stats['unique'] = unique_count 122 | stats['return_ratio'] = (returns / total_count) * 100 123 | 124 | # If pageviews are being tracked, add the aggregate pages-per-visit 125 | if TRACK_PAGEVIEWS: 126 | if 'registered' in stats: 127 | pages_per_visit = registered_visitors.annotate( 128 | page_count=Count('pageviews') 129 | ).filter(page_count__gt=0).aggregate( 130 | pages_per_visit=Avg('page_count'))['pages_per_visit'] 131 | stats['registered']['pages_per_visit'] = pages_per_visit 132 | 133 | if TRACK_ANONYMOUS_USERS and not registered_only: 134 | stats['guests']['pages_per_visit'] = guests.annotate( 135 | page_count=Count('pageviews') 136 | ).filter(page_count__gt=0).aggregate( 137 | pages_per_visit=Avg('page_count'))['pages_per_visit'] 138 | 139 | total_per_visit = visitors.annotate( 140 | page_count=Count('pageviews') 141 | ).filter(page_count__gt=0).aggregate( 142 | pages_per_visit=Avg('page_count'))['pages_per_visit'] 143 | else: 144 | if 'registered' in stats: 145 | total_per_visit = stats['registered']['pages_per_visit'] 146 | else: 147 | total_per_visit = 0 148 | 149 | stats['pages_per_visit'] = total_per_visit 150 | 151 | return stats 152 | 153 | def user_stats(self, start_date=None, end_date=None): 154 | user_kwargs = { 155 | 'visit_history__start_time__lt': end_date, 156 | } 157 | visit_kwargs = { 158 | 'start_time__lt': end_date, 159 | } 160 | if start_date: 161 | user_kwargs['visit_history__start_time__gte'] = start_date 162 | visit_kwargs['start_time__gte'] = start_date 163 | else: 164 | user_kwargs['visit_history__start_time__isnull'] = False 165 | visit_kwargs['start_time__isnull'] = False 166 | 167 | users = list(get_user_model().objects.filter(**user_kwargs).annotate( 168 | visit_count=Count('visit_history'), 169 | time_on_site=Avg('visit_history__time_on_site'), 170 | ).filter(visit_count__gt=0).order_by( 171 | '-time_on_site', 172 | get_user_model().USERNAME_FIELD, 173 | )) 174 | 175 | # Aggregate pageviews per visit 176 | for user in users: 177 | user.pages_per_visit = user.visit_history.filter( 178 | **visit_kwargs 179 | ).annotate( 180 | page_count=Count('pageviews') 181 | ).filter(page_count__gt=0).aggregate( 182 | pages_per_visit=Avg('page_count'))['pages_per_visit'] 183 | # Lop off the floating point, turn into timedelta 184 | user.time_on_site = timedelta(seconds=int(user.time_on_site)) 185 | return users 186 | 187 | 188 | class PageviewManager(models.Manager): 189 | def stats(self, start_date=None, end_date=None, registered_only=False): 190 | """Returns a dictionary of pageviews including: 191 | 192 | * total pageviews 193 | 194 | for all users, registered users and guests. 195 | """ 196 | pageviews = self.filter( 197 | visitor__start_time__lt=end_date, 198 | visitor__start_time__gte=start_date, 199 | ).select_related('visitor') 200 | 201 | stats = { 202 | 'total': 0, 203 | 'unique': 0, 204 | } 205 | 206 | stats['total'] = total_views = pageviews.count() 207 | unique_count = 0 208 | 209 | if not total_views: 210 | return stats 211 | 212 | # Registered user sessions 213 | registered_pageviews = pageviews.filter(visitor__user__isnull=False) 214 | registered_count = registered_pageviews.count() 215 | 216 | if registered_count: 217 | registered_unique_count = registered_pageviews.values( 218 | 'visitor', 'url').distinct().count() 219 | 220 | # Update the total unique count... 221 | unique_count += registered_unique_count 222 | 223 | stats['registered'] = { 224 | 'total': registered_count, 225 | 'unique': registered_unique_count, 226 | } 227 | 228 | if TRACK_ANONYMOUS_USERS and not registered_only: 229 | guest_pageviews = pageviews.filter(visitor__user__isnull=True) 230 | guest_count = guest_pageviews.count() 231 | 232 | if guest_count: 233 | guest_unique_count = guest_pageviews.values( 234 | 'visitor', 'url').distinct().count() 235 | 236 | # Update the total unique count... 237 | unique_count += guest_unique_count 238 | 239 | stats['guests'] = { 240 | 'total': guest_count, 241 | 'unique': guest_unique_count, 242 | } 243 | 244 | # Finish setting the total visitor counts 245 | stats['unique'] = unique_count 246 | 247 | return stats 248 | -------------------------------------------------------------------------------- /tracking/middleware.py: -------------------------------------------------------------------------------- 1 | import re 2 | import logging 3 | import warnings 4 | 5 | from django.db import IntegrityError, transaction 6 | from django.utils import timezone 7 | from django.utils.encoding import smart_str 8 | try: 9 | from django.utils.deprecation import MiddlewareMixin 10 | except ImportError: 11 | MiddlewareMixin = object 12 | 13 | from tracking.models import Visitor, Pageview 14 | from tracking.utils import get_ip_address, total_seconds 15 | from tracking.settings import ( 16 | TRACK_AJAX_REQUESTS, 17 | TRACK_ANONYMOUS_USERS, 18 | TRACK_IGNORE_STATUS_CODES, 19 | TRACK_IGNORE_URLS, 20 | TRACK_IGNORE_USER_AGENTS, 21 | TRACK_PAGEVIEWS, 22 | TRACK_QUERY_STRING, 23 | TRACK_REFERER, 24 | TRACK_SUPERUSERS, 25 | ) 26 | 27 | track_ignore_urls = [re.compile(x) for x in TRACK_IGNORE_URLS] 28 | track_ignore_user_agents = [ 29 | re.compile(x, re.IGNORECASE) for x in TRACK_IGNORE_USER_AGENTS 30 | ] 31 | 32 | log = logging.getLogger(__file__) 33 | 34 | 35 | class VisitorTrackingMiddleware(MiddlewareMixin): 36 | def _should_track(self, user, request, response): 37 | # Session framework not installed, nothing to see here.. 38 | if not hasattr(request, 'session'): 39 | msg = ('VisitorTrackingMiddleware installed without' 40 | 'SessionMiddleware') 41 | warnings.warn(msg, RuntimeWarning) 42 | return False 43 | 44 | # Do not track AJAX requests 45 | if ( 46 | request.META.get('HTTP_X_REQUESTED_WITH') == 'XMLHttpRequest' 47 | and not TRACK_AJAX_REQUESTS 48 | ): 49 | return False 50 | 51 | # Do not track if HTTP HttpResponse status_code blacklisted 52 | if response.status_code in TRACK_IGNORE_STATUS_CODES: 53 | return False 54 | 55 | # Do not tracking anonymous users if set 56 | if user is None and not TRACK_ANONYMOUS_USERS: 57 | return False 58 | 59 | # Do not track superusers if set 60 | if user and user.is_superuser and not TRACK_SUPERUSERS: 61 | return False 62 | 63 | # Do not track ignored urls 64 | path = request.path_info.lstrip('/') 65 | for url in track_ignore_urls: 66 | if url.match(path): 67 | return False 68 | 69 | # Do not track ignored user agents 70 | user_agent = request.META.get('HTTP_USER_AGENT', '') 71 | for user_agent_pattern in track_ignore_user_agents: 72 | if user_agent_pattern.match(user_agent): 73 | return False 74 | 75 | # everything says we should track this hit 76 | return True 77 | 78 | def _refresh_visitor(self, user, request, visit_time): 79 | # A Visitor row is unique by session_key 80 | session_key = request.session.session_key 81 | 82 | try: 83 | visitor = Visitor.objects.get(pk=session_key) 84 | except Visitor.DoesNotExist: 85 | # Log the ip address. Start time is managed via the field 86 | # `default` value 87 | ip_address = get_ip_address(request) 88 | visitor = Visitor(pk=session_key, ip_address=ip_address) 89 | 90 | # Update the user field if the visitor user is not set. This 91 | # implies authentication has occured on this request and now 92 | # the user is object exists. Check using `user_id` to prevent 93 | # a database hit. 94 | if user and not visitor.user_id: 95 | visitor.user_id = user.id 96 | 97 | # update some session expiration details 98 | visitor.expiry_age = request.session.get_expiry_age() 99 | visitor.expiry_time = request.session.get_expiry_date() 100 | 101 | # grab the latest User-Agent and store it 102 | user_agent = request.META.get('HTTP_USER_AGENT', None) 103 | if user_agent: 104 | visitor.user_agent = smart_str( 105 | user_agent, encoding='latin-1', errors='ignore') 106 | 107 | time_on_site = 0 108 | if visitor.start_time: 109 | time_on_site = total_seconds(visit_time - visitor.start_time) 110 | visitor.time_on_site = int(time_on_site) 111 | 112 | try: 113 | with transaction.atomic(): 114 | visitor.save() 115 | except IntegrityError: 116 | # there is a small chance a second response has saved this 117 | # Visitor already and a second save() at the same time (having 118 | # failed to UPDATE anything) will attempt to INSERT the same 119 | # session key (pk) again causing an IntegrityError 120 | # If this happens we'll just grab the "winner" and use that! 121 | visitor = Visitor.objects.get(pk=session_key) 122 | 123 | return visitor 124 | 125 | def _add_pageview(self, visitor, request, view_time): 126 | referer = None 127 | query_string = None 128 | 129 | if TRACK_REFERER: 130 | referer = request.META.get('HTTP_REFERER', None) 131 | 132 | if TRACK_QUERY_STRING: 133 | query_string = request.META.get('QUERY_STRING') 134 | 135 | pageview = Pageview( 136 | visitor=visitor, url=request.path, view_time=view_time, 137 | method=request.method, referer=referer, 138 | query_string=query_string) 139 | pageview.save() 140 | 141 | def process_response(self, request, response): 142 | # If dealing with a non-authenticated user, we still should track the 143 | # session since if authentication happens, the `session_key` carries 144 | # over, thus having a more accurate start time of session 145 | user = getattr(request, 'user', None) 146 | if user and user.is_anonymous: 147 | # set AnonymousUsers to None for simplicity 148 | user = None 149 | 150 | # make sure this is a response we want to track 151 | if not self._should_track(user, request, response): 152 | return response 153 | 154 | # Force a save to generate a session key if one does not exist 155 | if not request.session.session_key: 156 | request.session.save() 157 | 158 | # Be conservative with the determining time on site since simply 159 | # increasing the session timeout could greatly skew results. This 160 | # is the only time we can guarantee. 161 | now = timezone.now() 162 | 163 | # update/create the visitor object for this request 164 | visitor = self._refresh_visitor(user, request, now) 165 | 166 | if TRACK_PAGEVIEWS: 167 | self._add_pageview(visitor, request, now) 168 | 169 | return response 170 | -------------------------------------------------------------------------------- /tracking/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import django.utils.timezone 6 | from django.conf import settings 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='Pageview', 18 | fields=[ 19 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 20 | ('url', models.TextField(editable=False)), 21 | ('referer', models.TextField(null=True, editable=False)), 22 | ('query_string', models.TextField(null=True, editable=False)), 23 | ('method', models.CharField(max_length=20, null=True)), 24 | ('view_time', models.DateTimeField()), 25 | ], 26 | options={ 27 | 'ordering': ('-view_time',), 28 | }, 29 | bases=(models.Model,), 30 | ), 31 | migrations.CreateModel( 32 | name='Visitor', 33 | fields=[ 34 | ('session_key', models.CharField(max_length=40, serialize=False, primary_key=True)), 35 | ('ip_address', models.CharField(max_length=39, editable=False)), 36 | ('user_agent', models.TextField(null=True, editable=False)), 37 | ('start_time', models.DateTimeField(default=django.utils.timezone.now, editable=False)), 38 | ('expiry_age', models.IntegerField(null=True, editable=False)), 39 | ('expiry_time', models.DateTimeField(null=True, editable=False)), 40 | ('time_on_site', models.IntegerField(null=True, editable=False)), 41 | ('end_time', models.DateTimeField(null=True, editable=False)), 42 | ('user', models.ForeignKey(related_name='visit_history', editable=False, to=settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE)), 43 | ], 44 | options={ 45 | 'ordering': ('-start_time',), 46 | 'permissions': (('view_visitor', 'Can view visitor'),), 47 | }, 48 | bases=(models.Model,), 49 | ), 50 | migrations.AddField( 51 | model_name='pageview', 52 | name='visitor', 53 | field=models.ForeignKey(related_name='pageviews', to='tracking.Visitor', on_delete=models.CASCADE), 54 | preserve_default=True, 55 | ), 56 | ] 57 | -------------------------------------------------------------------------------- /tracking/migrations/0002_auto_20180918_2014.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.1 on 2018-09-18 20:14 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('tracking', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name='visitor', 15 | options={'ordering': ('-start_time',), 'permissions': (('visitor_log', 'Can view visitor'),)}, 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /tracking/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bruth/django-tracking2/89a184e5227ec6c68e614ca9e372a4c1196b3fdc/tracking/migrations/__init__.py -------------------------------------------------------------------------------- /tracking/models.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.conf import settings 4 | from django.db import models 5 | from django.utils import timezone 6 | 7 | from tracking.managers import VisitorManager, PageviewManager 8 | from tracking.settings import TRACK_USING_GEOIP 9 | 10 | from django.contrib.gis.geoip2 import HAS_GEOIP2 as HAS_GEOIP 11 | 12 | if HAS_GEOIP: 13 | from django.contrib.gis.geoip2 import GeoIP2, GeoIP2Exception 14 | 15 | GEOIP_CACHE_TYPE = getattr(settings, 'GEOIP_CACHE_TYPE', 4) 16 | 17 | log = logging.getLogger(__file__) 18 | 19 | 20 | class Visitor(models.Model): 21 | session_key = models.CharField(max_length=40, primary_key=True) 22 | user = models.ForeignKey( 23 | settings.AUTH_USER_MODEL, 24 | related_name='visit_history', 25 | null=True, 26 | editable=False, 27 | on_delete=models.CASCADE, 28 | ) 29 | # Update to GenericIPAddress in Django 1.4 30 | ip_address = models.CharField(max_length=39, editable=False) 31 | user_agent = models.TextField(null=True, editable=False) 32 | start_time = models.DateTimeField(default=timezone.now, editable=False) 33 | expiry_age = models.IntegerField(null=True, editable=False) 34 | expiry_time = models.DateTimeField(null=True, editable=False) 35 | time_on_site = models.IntegerField(null=True, editable=False) 36 | end_time = models.DateTimeField(null=True, editable=False) 37 | 38 | objects = VisitorManager() 39 | 40 | def session_expired(self): 41 | """The session has ended due to session expiration.""" 42 | if self.expiry_time: 43 | return self.expiry_time <= timezone.now() 44 | return False 45 | session_expired.boolean = True 46 | 47 | def session_ended(self): 48 | """The session has ended due to an explicit logout.""" 49 | return bool(self.end_time) 50 | session_ended.boolean = True 51 | 52 | @property 53 | def geoip_data(self): 54 | """Attempt to retrieve MaxMind GeoIP data based on visitor's IP.""" 55 | if not HAS_GEOIP or not TRACK_USING_GEOIP: 56 | return 57 | 58 | if not hasattr(self, '_geoip_data'): 59 | self._geoip_data = None 60 | try: 61 | gip = GeoIP2(cache=GEOIP_CACHE_TYPE) 62 | self._geoip_data = gip.city(self.ip_address) 63 | except GeoIP2Exception: 64 | msg = 'Error getting GeoIP data for IP "{0}"'.format( 65 | self.ip_address) 66 | log.exception(msg) 67 | 68 | return self._geoip_data 69 | 70 | class Meta(object): 71 | ordering = ('-start_time',) 72 | permissions = ( 73 | ('visitor_log', 'Can view visitor'), 74 | ) 75 | 76 | 77 | class Pageview(models.Model): 78 | visitor = models.ForeignKey( 79 | Visitor, 80 | related_name='pageviews', 81 | on_delete=models.CASCADE, 82 | ) 83 | url = models.TextField(null=False, editable=False) 84 | referer = models.TextField(null=True, editable=False) 85 | query_string = models.TextField(null=True, editable=False) 86 | method = models.CharField(max_length=20, null=True) 87 | view_time = models.DateTimeField() 88 | 89 | objects = PageviewManager() 90 | 91 | class Meta(object): 92 | ordering = ('-view_time',) 93 | -------------------------------------------------------------------------------- /tracking/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | TRACK_AJAX_REQUESTS = getattr(settings, 'TRACK_AJAX_REQUESTS', False) 4 | TRACK_ANONYMOUS_USERS = getattr(settings, 'TRACK_ANONYMOUS_USERS', True) 5 | TRACK_SUPERUSERS = getattr(settings, 'TRACK_SUPERUSERS', True) 6 | 7 | TRACK_PAGEVIEWS = getattr(settings, 'TRACK_PAGEVIEWS', False) 8 | 9 | TRACK_IGNORE_URLS = getattr(settings, 'TRACK_IGNORE_URLS', ( 10 | r'^(favicon\.ico|robots\.txt)$', 11 | )) 12 | 13 | TRACK_IGNORE_USER_AGENTS = getattr(settings, 'TRACK_IGNORE_USER_AGENTS', tuple()) 14 | 15 | TRACK_IGNORE_STATUS_CODES = getattr(settings, 'TRACK_IGNORE_STATUS_CODES', []) 16 | 17 | TRACK_USING_GEOIP = getattr(settings, 'TRACK_USING_GEOIP', False) 18 | if hasattr(settings, 'TRACKING_USE_GEOIP'): 19 | raise DeprecationWarning('TRACKING_USE_GEOIP is now TRACK_USING_GEOIP') 20 | 21 | TRACK_REFERER = getattr(settings, 'TRACK_REFERER', False) 22 | 23 | TRACK_QUERY_STRING = getattr(settings, 'TRACK_QUERY_STRING', False) 24 | -------------------------------------------------------------------------------- /tracking/templates/tracking/dashboard.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Dashboard - django-tracking2 5 | 6 | 7 |

Dashboard - django-tracking2

8 |
9 |
10 | {{ form.as_table }} 11 | 12 |
13 |
14 |
15 |

16 | Visitor tracking began on 17 | {{ track_start_time|date:"Y-m-d H:i:s" }} 18 |

19 | {% if warn_incomplete %} 20 |

21 | The start time precedes the oldest tracked visitor, thus 22 | the stats are not complete for the specified range. 23 |

24 | {% endif %} 25 |
26 |
27 | {% include "tracking/snippets/stats.html" %} 28 |
29 | 30 | 31 | -------------------------------------------------------------------------------- /tracking/templates/tracking/snippets/stats.html: -------------------------------------------------------------------------------- 1 |

Visitors

2 | {% if visitor_stats.total %} 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | {% if pageview_stats %} 12 | 13 | {% endif %} 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | {% if pageview_stats %} 24 | 25 | {% endif %} 26 | 27 | 28 | {% if visitor_stats.guests %} 29 | 30 | 31 | 32 | 33 | 34 | 35 | {% if pageview_stats %} 36 | 37 | {% endif %} 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | {% if pageview_stats %} 46 | 47 | {% endif %} 48 | 49 | {% endif %} 50 | 51 |
TotalUnique% Returning VisitorAvg. Time on SiteAvg. Pages/Visit
Registered{{ visitor_stats.registered.total }}{{ visitor_stats.registered.unique }}{{ visitor_stats.registered.return_ratio|floatformat }}%{{ visitor_stats.registered.time_on_site|default_if_none:"n/a" }}{{ visitor_stats.registered.pages_per_visit|floatformat|default:"n/a" }}
Guests{{ visitor_stats.guests.total }}{{ visitor_stats.guests.unique }}{{ visitor_stats.guests.return_ratio|floatformat }}%{{ visitor_stats.guests.time_on_site|default_if_none:"n/a" }}{{ visitor_stats.guests.pages_per_visit|floatformat|default:"n/a" }}
Total{{ visitor_stats.total }}{{ visitor_stats.unique }}{{ visitor_stats.return_ratio|floatformat }}%{{ visitor_stats.time_on_site|default_if_none:"n/a" }}{{ visitor_stats.pages_per_visit|floatformat|default:"n/a" }}
52 | {% else %} 53 |

No visitor stats available

54 | {% endif %} 55 | 56 |

Registered Users

57 | {% if user_stats %} 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | {% for user in user_stats %} 69 | 70 | 71 | 72 | 73 | 74 | 75 | {% endfor %} 76 | 77 |
# VisitsAvg. Time on SiteAvg. Pages/Visit
{% firstof user.get_full_name user %}{{ user.visit_count }}{{ user.time_on_site|default_if_none:"n/a" }}{{ user.pages_per_visit|floatformat|default:"n/a" }}
78 | {% else %} 79 |

No registered user stats available

80 | {% endif %} 81 | 82 | {% if pageview_stats %} 83 |

Pageviews

84 | {% if pageview_stats.total %} 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | {% if pageview_stats.guests %} 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | {% endif %} 111 | 112 |
TotalUnique
Registered{{ pageview_stats.registered.total }}{{ pageview_stats.registered.unique }}
Guests{{ pageview_stats.guests.total }}{{ pageview_stats.guests.unique }}
Total{{ pageview_stats.total }}{{ pageview_stats.unique }}
113 | {% else %} 114 |

No pageview stats available

115 | {% endif %} 116 | {% endif %} 117 | -------------------------------------------------------------------------------- /tracking/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bruth/django-tracking2/89a184e5227ec6c68e614ca9e372a4c1196b3fdc/tracking/tests/__init__.py -------------------------------------------------------------------------------- /tracking/tests/test_geoip.py: -------------------------------------------------------------------------------- 1 | from os import getenv 2 | from django.test import TestCase 3 | from unittest.mock import patch 4 | 5 | from django.contrib.gis.geoip2 import HAS_GEOIP2 6 | if HAS_GEOIP2: 7 | from django.contrib.gis.geoip2 import GeoIP2Exception 8 | 9 | from unittest import skipUnless 10 | 11 | from tracking.models import Visitor # noqa 12 | 13 | 14 | class GeoIPTestCase(TestCase): 15 | 16 | def test_geoip_none(self): 17 | v = Visitor.objects.create(ip_address='8.8.8.8') # sorry Google 18 | self.assertEqual(v.geoip_data, None) 19 | 20 | @skipUnless(getenv('CI'), 'cannot guarantee location of GeoIP data') 21 | @patch('tracking.models.TRACK_USING_GEOIP', True) 22 | def test_geoip_django_1x(self): 23 | v = Visitor.objects.create(ip_address='64.17.254.216') 24 | expected = { 25 | 'city': 'El Segundo', 26 | 'continent_code': 'NA', 27 | 'region': 'CA', 28 | 'charset': 0, 29 | 'area_code': 310, 30 | 'longitude': -118.40399932861328, 31 | 'country_code3': 'USA', 32 | 'latitude': 33.91640090942383, 33 | 'postal_code': '90245', 34 | 'dma_code': 803, 35 | 'country_code': 'US', 36 | 'country_name': 'United States' 37 | } 38 | 39 | self.assertEqual(v.geoip_data, expected) 40 | # do it again, to verify the cached version hits 41 | self.assertEqual(v.geoip_data, expected) 42 | 43 | @skipUnless(getenv('CI'), 'cannot guarantee location of GeoIP data') 44 | @patch('tracking.models.TRACK_USING_GEOIP', True) 45 | def test_geoip2_django_20(self): 46 | v = Visitor.objects.create(ip_address='81.2.69.160') 47 | expected = { 48 | 'city': 'London', 49 | 'country_code': 'GB', 50 | 'country_name': 'United Kingdom', 51 | 'dma_code': None, 52 | 'latitude': 51.5142, 53 | 'longitude': -0.0931, 54 | 'postal_code': None, 55 | 'region': 'ENG', 56 | 'time_zone': 'Europe/London' 57 | } 58 | 59 | self.assertEqual(v.geoip_data, expected) 60 | # do it again, to verify the cached version hits 61 | self.assertEqual(v.geoip_data, expected) 62 | 63 | @skipUnless(getenv('CI'), 'cannot guarantee location of GeoIP data') 64 | @patch('tracking.models.TRACK_USING_GEOIP', True) 65 | def test_geoip2_django_21(self): 66 | v = Visitor.objects.create(ip_address='81.2.69.160') 67 | expected = { 68 | 'city': 'London', 69 | 'country_code': 'GB', 70 | 'continent_code': 'EU', 71 | 'continent_name': 'Europe', 72 | 'country_name': 'United Kingdom', 73 | 'dma_code': None, 74 | 'latitude': 51.5142, 75 | 'longitude': -0.0931, 76 | 'postal_code': None, 77 | 'region': 'ENG', 78 | 'time_zone': 'Europe/London' 79 | } 80 | 81 | self.assertEqual(v.geoip_data, expected) 82 | # do it again, to verify the cached version hits 83 | self.assertEqual(v.geoip_data, expected) 84 | 85 | @patch('tracking.models.TRACK_USING_GEOIP', True) 86 | def test_geoip_exc(self): 87 | with patch('tracking.models.GeoIP2', autospec=True) as mock_geo: 88 | mock_geo.side_effect = GeoIP2Exception('bad data') 89 | v = Visitor.objects.create(ip_address='64.17.254.216') 90 | self.assertEqual(v.geoip_data, None) 91 | -------------------------------------------------------------------------------- /tracking/tests/test_managers.py: -------------------------------------------------------------------------------- 1 | from __future__ import division 2 | 3 | from datetime import timedelta 4 | 5 | from django.contrib.auth.models import User 6 | from django.test import TestCase 7 | from django.utils import timezone 8 | 9 | from tracking.models import Visitor, Pageview 10 | 11 | 12 | class VisitorManagerTestCase(TestCase): 13 | 14 | def setUp(self): 15 | self.user1 = User.objects.create_user(username='foo') 16 | self.user2 = User.objects.create_user(username='bar') 17 | self.base_time = timezone.now() 18 | self.past = self.base_time - timedelta(hours=1) 19 | self.present = self.base_time 20 | self.future = self.base_time + timedelta(days=1) 21 | 22 | def _create_visits_and_views(self): 23 | # create a visitor with visits in the past, present, and future 24 | kwargs = {'ip_address': '10.0.0.1', 'user_agent': 'django', 25 | 'time_on_site': 30, 'expiry_time': self.future} 26 | 27 | self.visitor1 = Visitor.objects.create( 28 | user=self.user1, start_time=self.past, session_key='A', **kwargs) 29 | self.visitor2 = Visitor.objects.create( 30 | user=self.user2, start_time=self.present, session_key='B', 31 | **kwargs) 32 | # visitor3 is in the future 33 | self.visitor3 = Visitor.objects.create( 34 | user=None, start_time=self.future, session_key='C', **kwargs) 35 | 36 | Pageview.objects.create(visitor=self.visitor1, view_time=self.present) 37 | Pageview.objects.create(visitor=self.visitor1, view_time=self.future) 38 | Pageview.objects.create(visitor=self.visitor2, view_time=self.past) 39 | Pageview.objects.create(visitor=self.visitor3, view_time=self.future) 40 | 41 | def test_only_anonymous(self): 42 | # only a guest user has visited an untracked page 43 | Visitor.objects.create( 44 | user=None, start_time=self.base_time, session_key='A', 45 | time_on_site=30) 46 | stats = Visitor.objects.stats( 47 | self.base_time, self.future, registered_only=True) 48 | expected = { 49 | 'time_on_site': timedelta(seconds=30), 50 | 'unique': 0, 51 | 'total': 1, 52 | 'return_ratio': 100.0, 53 | 'pages_per_visit': 0 54 | } 55 | self.assertEqual(stats, expected) 56 | 57 | def test_visitor_stats(self): 58 | self._create_visits_and_views() 59 | start_time = self.base_time - timedelta(days=1) 60 | end_time = start_time + timedelta(days=2) 61 | stats = Visitor.objects.stats(start_time, end_time) 62 | self.assertEqual(stats['time_on_site'], timedelta(seconds=30)) 63 | self.assertEqual(stats['total'], 2) 64 | self.assertEqual(stats['return_ratio'], 0.0) 65 | self.assertEqual(stats['unique'], 2) 66 | self.assertEqual(stats['pages_per_visit'], 3 / 2) 67 | registered = { 68 | 'time_on_site': timedelta(seconds=30), 69 | 'unique': 2, 70 | 'total': 2, 71 | 'return_ratio': 0.0, 72 | 'pages_per_visit': 1.5, 73 | } 74 | self.assertEqual(stats['registered'], registered) 75 | guests = { 76 | 'time_on_site': timedelta(seconds=0), 77 | 'unique': 0, 78 | 'total': 0, 79 | 'return_ratio': 0.0, 80 | 'pages_per_visit': None, 81 | } 82 | self.assertEqual(stats['guests'], guests) 83 | 84 | # now expand the end time to include `future` as well 85 | end_time = start_time + timedelta(days=3) 86 | stats = Visitor.objects.stats(start_time, end_time) 87 | self.assertEqual(stats['pages_per_visit'], 4 / 3) 88 | guests = { 89 | 'time_on_site': timedelta(seconds=30), 90 | 'unique': 1, 91 | 'total': 1, 92 | 'return_ratio': 0.0, 93 | 'pages_per_visit': 1.0, 94 | } 95 | self.assertEqual(stats['guests'], guests) 96 | 97 | def test_visitor_stats_registered(self): 98 | self._create_visits_and_views() 99 | start_time = self.base_time - timedelta(days=1) 100 | end_time = start_time + timedelta(days=3) 101 | stats = Visitor.objects.stats( 102 | start_time, end_time, registered_only=True) 103 | self.assertEqual(stats['time_on_site'], timedelta(seconds=30)) 104 | self.assertEqual(stats['total'], 3) 105 | self.assertEqual(stats['return_ratio'], (1 / 3) * 100) 106 | self.assertEqual(stats['unique'], 2) 107 | self.assertEqual(stats['pages_per_visit'], 3 / 2) 108 | registered = { 109 | 'time_on_site': timedelta(seconds=30), 110 | 'unique': 2, 111 | 'total': 2, 112 | 'return_ratio': 0.0, 113 | 'pages_per_visit': 1.5, 114 | } 115 | self.assertEqual(stats['registered'], registered) 116 | self.assertNotIn('guests', stats) 117 | 118 | def test_guests(self): 119 | qs = Visitor.objects.guests() 120 | self.assertQuerysetEqual(qs, []) 121 | 122 | self._create_visits_and_views() 123 | qs = Visitor.objects.guests() 124 | self.assertEqual(list(qs), [self.visitor3]) 125 | 126 | def test_registered(self): 127 | qs = Visitor.objects.registered() 128 | self.assertQuerysetEqual(qs, []) 129 | 130 | self._create_visits_and_views() 131 | qs = Visitor.objects.registered() 132 | self.assertEqual(list(qs), [self.visitor2, self.visitor1]) 133 | 134 | def test_active(self): 135 | qs = Visitor.objects.active() 136 | self.assertQuerysetEqual(qs, []) 137 | 138 | self._create_visits_and_views() 139 | qs = Visitor.objects.active() 140 | self.assertEqual( 141 | list(qs), 142 | [self.visitor2, self.visitor1]) 143 | 144 | qs = Visitor.objects.active(registered_only=False) 145 | self.assertEqual( 146 | list(qs), 147 | [self.visitor3, self.visitor2, self.visitor1]) 148 | 149 | def test_user_stats(self): 150 | self._create_visits_and_views() 151 | # this time range should only get self.user2 152 | start_time = self.base_time - timedelta(minutes=1) 153 | end_time = start_time + timedelta(days=1) 154 | stats = Visitor.objects.user_stats(start_time, end_time) 155 | self.assertEqual(len(stats), 1) 156 | user = stats[0] 157 | self.assertEqual(user.username, self.user2.username) 158 | self.assertEqual(user.visit_count, 1) 159 | self.assertEqual(user.time_on_site, timedelta(seconds=30)) 160 | self.assertEqual(user.pages_per_visit, 1) 161 | 162 | # no start_time 163 | stats = Visitor.objects.user_stats(None, end_time) 164 | self.assertEqual(len(stats), 2) 165 | 166 | user1 = stats[1] 167 | self.assertEqual(user1.username, self.user1.username) 168 | self.assertEqual(user1.visit_count, 1) 169 | self.assertEqual(user1.time_on_site, timedelta(seconds=30)) 170 | self.assertEqual(user1.pages_per_visit, 2.0) 171 | 172 | user2 = stats[0] 173 | self.assertEqual(user2.username, self.user2.username) 174 | self.assertEqual(user2.visit_count, 1) 175 | self.assertEqual(user2.time_on_site, timedelta(seconds=30)) 176 | self.assertEqual(user2.pages_per_visit, 1) 177 | 178 | def test_pageview_stats(self): 179 | self._create_visits_and_views() 180 | # full time range for this 181 | start_time = self.base_time - timedelta(days=1) 182 | end_time = start_time + timedelta(days=3) 183 | stats = Pageview.objects.stats(start_time, end_time) 184 | expected = { 185 | 'total': 4, 186 | 'unique': 3, 187 | 'registered': { 188 | 'total': 3, 189 | 'unique': 2, 190 | }, 191 | 'guests': { 192 | 'total': 1, 193 | 'unique': 1, 194 | } 195 | } 196 | self.assertEqual(stats, expected) 197 | -------------------------------------------------------------------------------- /tracking/tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | import re 2 | from unittest.mock import patch 3 | 4 | from django.contrib.auth.models import User 5 | from django.test import TestCase 6 | 7 | from tracking.models import Visitor, Pageview 8 | 9 | 10 | class MiddlewareTestCase(TestCase): 11 | 12 | @patch('tracking.middleware.warnings', autospec=True) 13 | def test_no_session(self, mock_warnings): 14 | # ignore if session middleware is not present 15 | tracking = 'tracking.middleware.VisitorTrackingMiddleware' 16 | with self.settings(MIDDLEWARE=[tracking]): 17 | self.client.get('/') 18 | self.assertEqual(Visitor.objects.count(), 0) 19 | self.assertEqual(Pageview.objects.count(), 0) 20 | # verify warning was issued 21 | msg = 'VisitorTrackingMiddleware installed withoutSessionMiddleware' 22 | mock_warnings.warn.assert_called_once_with(msg, RuntimeWarning) 23 | 24 | @patch('tracking.middleware.TRACK_AJAX_REQUESTS', False) 25 | def test_no_track_ajax(self): 26 | # ignore ajax-based requests 27 | self.client.get('/', HTTP_X_REQUESTED_WITH='XMLHttpRequest') 28 | self.assertEqual(Visitor.objects.count(), 0) 29 | self.assertEqual(Pageview.objects.count(), 0) 30 | 31 | @patch('tracking.middleware.TRACK_IGNORE_STATUS_CODES', [404]) 32 | def test_no_track_status(self): 33 | # ignore 404 pages 34 | self.client.get('invalid') 35 | self.assertEqual(Visitor.objects.count(), 0) 36 | self.assertEqual(Pageview.objects.count(), 0) 37 | 38 | @patch('tracking.middleware.TRACK_PAGEVIEWS', False) 39 | def test_no_track_pageviews(self): 40 | # make a non PAGEVIEW tracking request 41 | self.client.get('/') 42 | self.assertEqual(Visitor.objects.count(), 1) 43 | self.assertEqual(Pageview.objects.count(), 0) 44 | 45 | @patch('tracking.middleware.TRACK_PAGEVIEWS', True) 46 | def test_track_pageviews(self): 47 | self.client.get('/') 48 | self.assertEqual(Visitor.objects.count(), 1) 49 | self.assertEqual(Pageview.objects.count(), 1) 50 | 51 | def test_track_user_agent(self): 52 | self.client.get('/', HTTP_USER_AGENT='django') 53 | self.assertEqual(Visitor.objects.count(), 1) 54 | visitor = Visitor.objects.get() 55 | self.assertEqual(visitor.user_agent, 'django') 56 | 57 | def test_track_user_agent_unicode(self): 58 | self.client.get('/', HTTP_USER_AGENT='django') 59 | self.assertEqual(Visitor.objects.count(), 1) 60 | visitor = Visitor.objects.get() 61 | self.assertEqual(visitor.user_agent, 'django') 62 | 63 | def test_track_user_anon(self): 64 | self.client.get('/') 65 | self.assertEqual(Visitor.objects.count(), 1) 66 | visitor = Visitor.objects.get() 67 | self.assertEqual(visitor.user, None) 68 | 69 | def test_track_user_me(self): 70 | auth = {'username': 'me', 'password': 'me'} 71 | user = User.objects.create_user(**auth) 72 | self.assertTrue(self.client.login(**auth)) 73 | 74 | self.client.get('/') 75 | self.assertEqual(Visitor.objects.count(), 1) 76 | visitor = Visitor.objects.get() 77 | self.assertEqual(visitor.user, user) 78 | 79 | @patch('tracking.middleware.TRACK_ANONYMOUS_USERS', False) 80 | def test_track_anonymous_users(self): 81 | self.client.get('/') 82 | self.assertEqual(Visitor.objects.count(), 0) 83 | self.assertEqual(Pageview.objects.count(), 0) 84 | 85 | @patch('tracking.middleware.TRACK_SUPERUSERS', True) 86 | def test_track_superusers_true(self): 87 | auth = {'username': 'me', 'email': 'me@me.com', 'password': 'me'} 88 | User.objects.create_superuser(**auth) 89 | self.assertTrue(self.client.login(**auth)) 90 | 91 | self.client.get('/') 92 | self.assertEqual(Visitor.objects.count(), 1) 93 | self.assertEqual(Pageview.objects.count(), 1) 94 | 95 | @patch('tracking.middleware.TRACK_SUPERUSERS', False) 96 | def test_track_superusers_false(self): 97 | auth = {'username': 'me', 'email': 'me@me.com', 'password': 'me'} 98 | User.objects.create_superuser(**auth) 99 | self.assertTrue(self.client.login(**auth)) 100 | 101 | self.client.get('/') 102 | self.assertEqual(Visitor.objects.count(), 0) 103 | self.assertEqual(Pageview.objects.count(), 0) 104 | 105 | def test_track_ignore_url(self): 106 | ignore_urls = [re.compile('foo')] 107 | with patch('tracking.middleware.track_ignore_urls', ignore_urls): 108 | self.client.get('/') 109 | self.client.get('/foo/') 110 | # tracking turns a blind eye towards ignore_urls, no visitor, no view 111 | self.assertEqual(Visitor.objects.count(), 1) 112 | self.assertEqual(Pageview.objects.count(), 1) 113 | 114 | @patch('tracking.middleware.TRACK_PAGEVIEWS', True) 115 | @patch('tracking.middleware.TRACK_REFERER', True) 116 | @patch('tracking.middleware.TRACK_QUERY_STRING', True) 117 | def test_track_referer_string(self): 118 | self.client.get('/?foo=bar&baz=bin', HTTP_REFERER='http://foo/bar') 119 | # Vistor is still tracked, but page is not (in second case 120 | self.assertEqual(Visitor.objects.count(), 1) 121 | self.assertEqual(Pageview.objects.count(), 1) 122 | view = Pageview.objects.get() 123 | self.assertEqual(view.referer, 'http://foo/bar') 124 | self.assertEqual(view.query_string, 'foo=bar&baz=bin') 125 | -------------------------------------------------------------------------------- /tracking/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from unittest.mock import Mock 3 | 4 | from tracking.utils import get_ip_address 5 | 6 | 7 | class UtilsTestCase(TestCase): 8 | def test_get_ip_address(self): 9 | r = Mock(META={}) 10 | self.assertEqual(get_ip_address(r), None) 11 | r = Mock(META={'REMOTE_ADDR': '2001:0DB8:0:CD30::'}) 12 | self.assertEqual(get_ip_address(r), '2001:0DB8:0:CD30::') 13 | r = Mock(META={'HTTP_X_CLUSTERED_CLIENT_IP': '10.0.0.1, 10.1.1.1'}) 14 | self.assertEqual(get_ip_address(r), '10.0.0.1') 15 | -------------------------------------------------------------------------------- /tracking/tests/test_views.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from unittest.mock import patch 3 | 4 | from django.contrib.admin.sites import AdminSite 5 | from django.contrib.auth.models import User 6 | from django.test import TestCase 7 | from django.utils.timezone import now 8 | 9 | from tracking.admin import VisitorAdmin 10 | from tracking.models import Visitor 11 | 12 | 13 | class ViewsTestCase(TestCase): 14 | 15 | def setUp(self): 16 | self.auth = {'username': 'john', 'password': 'smith'} 17 | user = User.objects.create_user(**self.auth) 18 | user.is_superuser = True 19 | user.save() 20 | self.assertTrue(self.client.login(**self.auth)) 21 | 22 | def test_dashboard_default(self): 23 | # make a non PAGEVIEW tracking request 24 | response = self.client.get('/tracking/') 25 | self.assertEqual(response.status_code, 200) 26 | self.assertEqual( 27 | response.context['pageview_stats'], 28 | {'unique': 0, 'total': 0}) 29 | 30 | @patch('tracking.views.TRACK_PAGEVIEWS', False) 31 | def test_dashboard_default_no_views(self): 32 | # make a non PAGEVIEW tracking request 33 | response = self.client.get('/tracking/') 34 | self.assertEqual(response.status_code, 200) 35 | self.assertEqual(response.context['pageview_stats'], None) 36 | 37 | def test_dashboard_times(self): 38 | # make a non PAGEVIEW tracking request 39 | response = self.client.get( 40 | '/tracking/?start=2014-11&end=2014-12-01') 41 | self.assertEqual(response.status_code, 200) 42 | 43 | def test_dashboard_times_bad(self): 44 | # make a non PAGEVIEW tracking request 45 | response = self.client.get( 46 | '/tracking/?start=2014-aa&end=2014-12-01') 47 | self.assertEqual(response.status_code, 200) 48 | self.assertContains(response, 'Enter a valid date/time.') 49 | 50 | @patch('tracking.handlers.timezone.now', autospec=True) 51 | def test_logout_tracking(self, mock_end): 52 | # logout should call post-logout signal 53 | self.now = now() 54 | mock_end.return_value = self.now 55 | 56 | # ... but we didn't touch the site 57 | self.client.logout() 58 | self.assertEqual(Visitor.objects.count(), 0) 59 | 60 | # ... now we have! 61 | self.client.login(**self.auth) 62 | self.client.get('/tracking/') 63 | self.client.logout() 64 | 65 | self.assertEqual(Visitor.objects.count(), 1) 66 | visitor = Visitor.objects.get() 67 | self.assertEqual(visitor.end_time, self.now) 68 | self.assertTrue(visitor.time_on_site > 0) 69 | 70 | 71 | class AdminViewTestCase(TestCase): 72 | 73 | def setUp(self): 74 | self.site = AdminSite() 75 | 76 | @patch('tracking.middleware.TRACK_PAGEVIEWS', True) 77 | def test_admin(self): 78 | visitor = Visitor.objects.create() 79 | admin = VisitorAdmin(Visitor, self.site) 80 | self.assertFalse(admin.session_over(visitor)) 81 | visitor.expiry_time = now() - timedelta(seconds=10) 82 | self.assertTrue(admin.session_over(visitor)) 83 | visitor.expiry_time = None 84 | visitor.end_time = now() 85 | self.assertTrue(admin.session_over(visitor)) 86 | 87 | time_on_site = None 88 | self.assertEqual(admin.pretty_time_on_site(visitor), time_on_site) 89 | 90 | visitor.time_on_site = 30 91 | time_on_site = timedelta(seconds=30) 92 | self.assertEqual(admin.pretty_time_on_site(visitor), time_on_site) 93 | -------------------------------------------------------------------------------- /tracking/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import re_path 2 | 3 | from tracking.views import dashboard 4 | 5 | urlpatterns = [ 6 | re_path(r'^$', dashboard, name='tracking-dashboard'), 7 | ] 8 | -------------------------------------------------------------------------------- /tracking/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import division 2 | 3 | from django.core.exceptions import ValidationError 4 | from django.core.validators import validate_ipv46_address 5 | 6 | headers = ( 7 | 'HTTP_CLIENT_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_FORWARDED', 8 | 'HTTP_X_CLUSTERED_CLIENT_IP', 'HTTP_FORWARDED_FOR', 'HTTP_FORWARDED', 9 | 'REMOTE_ADDR' 10 | ) 11 | 12 | 13 | def get_ip_address(request): 14 | for header in headers: 15 | if request.META.get(header, None): 16 | ip = request.META[header].split(',')[0] 17 | 18 | try: 19 | validate_ipv46_address(ip) 20 | return ip 21 | except ValidationError: 22 | pass 23 | 24 | 25 | def total_seconds(delta): 26 | day_seconds = (delta.days * 24 * 3600) + delta.seconds 27 | return (delta.microseconds + day_seconds * 10**6) / 10**6 28 | -------------------------------------------------------------------------------- /tracking/views.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from datetime import timedelta 4 | 5 | from django import forms 6 | from django.shortcuts import render 7 | from django.contrib.auth.decorators import permission_required 8 | from django.utils.timezone import now 9 | 10 | from tracking.models import Visitor, Pageview 11 | from tracking.settings import TRACK_PAGEVIEWS 12 | 13 | log = logging.getLogger(__file__) 14 | 15 | # tracking wants to accept more formats than default, here they are 16 | input_formats = [ 17 | '%Y-%m-%d %H:%M:%S', # '2006-10-25 14:30:59' 18 | '%Y-%m-%d %H:%M', # '2006-10-25 14:30' 19 | '%Y-%m-%d', # '2006-10-25' 20 | '%Y-%m', # '2006-10' 21 | '%Y', # '2006' 22 | ] 23 | 24 | 25 | class DashboardForm(forms.Form): 26 | start = forms.DateTimeField(required=False, input_formats=input_formats) 27 | end = forms.DateTimeField(required=False, input_formats=input_formats) 28 | 29 | 30 | @permission_required('tracking.visitor_log') 31 | def dashboard(request): 32 | "Counts, aggregations and more!" 33 | end_time = now() 34 | start_time = end_time - timedelta(days=7) 35 | defaults = {'start': start_time, 'end': end_time} 36 | 37 | form = DashboardForm(data=request.GET or defaults) 38 | if form.is_valid(): 39 | start_time = form.cleaned_data['start'] 40 | end_time = form.cleaned_data['end'] 41 | 42 | # determine when tracking began 43 | try: 44 | obj = Visitor.objects.order_by('start_time')[0] 45 | track_start_time = obj.start_time 46 | except (IndexError, Visitor.DoesNotExist): 47 | track_start_time = now() 48 | 49 | # If the start_date is before tracking began, warn about incomplete data 50 | warn_incomplete = (start_time < track_start_time) 51 | 52 | # queries take `date` objects (for now) 53 | user_stats = Visitor.objects.user_stats(start_time, end_time) 54 | visitor_stats = Visitor.objects.stats(start_time, end_time) 55 | if TRACK_PAGEVIEWS: 56 | pageview_stats = Pageview.objects.stats(start_time, end_time) 57 | else: 58 | pageview_stats = None 59 | 60 | context = { 61 | 'form': form, 62 | 'track_start_time': track_start_time, 63 | 'warn_incomplete': warn_incomplete, 64 | 'user_stats': user_stats, 65 | 'visitor_stats': visitor_stats, 66 | 'pageview_stats': pageview_stats, 67 | } 68 | return render(request, 'tracking/dashboard.html', context) 69 | --------------------------------------------------------------------------------