├── .coveragerc ├── .editorconfig ├── .github └── workflows │ └── tox.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .prettierrc ├── .ruff.toml ├── CHANGELOG ├── LICENSE ├── README.md ├── assets ├── screenshot-admin-edit-view.png └── screenshot-admin-list-view.png ├── manage.py ├── mypy.ini ├── pyproject.toml ├── pytest.ini ├── tests ├── __init__.py ├── settings.py ├── test_middleware.py ├── test_models.py ├── urls.py └── utils.py ├── tox.ini └── user_visit ├── __init__.py ├── admin.py ├── apps.py ├── management ├── __init__.py └── commands │ ├── __init__.py │ └── update_user_visit_user_agent_data.py ├── middleware.py ├── migrations ├── 0001_initial.py ├── 0002_add_created_at.py ├── 0003_uservisit_context.py ├── 0004_uservisit_browser_uservisit_device_uservisit_os.py └── __init__.py ├── models.py └── settings.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | include = user_visit/* 3 | omit = 4 | user_visit/tests.py 5 | user_visit/migrations/* 6 | user_visit/apps.py 7 | .tox/* 8 | .venv/* 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | -------------------------------------------------------------------------------- /.github/workflows/tox.yml: -------------------------------------------------------------------------------- 1 | name: Python / Django 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | pull_request: 9 | types: [opened, synchronize, reopened] 10 | 11 | jobs: 12 | format: 13 | name: Check formatting 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | toxenv: [fmt, lint, mypy] 18 | env: 19 | TOXENV: ${{ matrix.toxenv }} 20 | 21 | steps: 22 | - name: Check out the repository 23 | uses: actions/checkout@v3 24 | 25 | - name: Set up Python (3.11) 26 | uses: actions/setup-python@v4 27 | with: 28 | python-version: "3.11" 29 | 30 | - name: Install and run tox 31 | run: | 32 | pip install tox 33 | tox 34 | 35 | checks: 36 | name: Run Django tests 37 | runs-on: ubuntu-latest 38 | strategy: 39 | matrix: 40 | toxenv: ["django-checks"] 41 | env: 42 | TOXENV: ${{ matrix.toxenv }} 43 | 44 | steps: 45 | - name: Check out the repository 46 | uses: actions/checkout@v3 47 | 48 | - name: Set up Python (3.11) 49 | uses: actions/setup-python@v4 50 | with: 51 | python-version: "3.11" 52 | 53 | - name: Install and run tox 54 | run: | 55 | pip install tox 56 | tox 57 | 58 | test: 59 | name: Run tests 60 | runs-on: ubuntu-latest 61 | strategy: 62 | matrix: 63 | python: ["3.8", "3.9", "3.10", "3.11", "3.12"] 64 | django: ["32", "42", "50", "main"] 65 | exclude: 66 | - python: "3.8" 67 | django: "50" 68 | - python: "3.8" 69 | django: "main" 70 | - python: "3.9" 71 | django: "50" 72 | - python: "3.9" 73 | django: "main" 74 | 75 | env: 76 | TOXENV: django${{ matrix.django }}-py${{ matrix.python }} 77 | 78 | steps: 79 | - name: Check out the repository 80 | uses: actions/checkout@v3 81 | 82 | - name: Set up Python ${{ matrix.python }} 83 | uses: actions/setup-python@v4 84 | with: 85 | python-version: ${{ matrix.python }} 86 | 87 | - name: Install and run tox 88 | run: | 89 | pip install tox 90 | tox 91 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg-info 3 | *.bak 4 | .coverage 5 | .tox 6 | .venv 7 | node_modules 8 | poetry.lock 9 | test.db 10 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | # python code formatting - will amend files 3 | - repo: https://github.com/ambv/black 4 | rev: 23.10.1 5 | hooks: 6 | - id: black 7 | 8 | - repo: https://github.com/charliermarsh/ruff-pre-commit 9 | # Ruff version. 10 | rev: "v0.1.4" 11 | hooks: 12 | - id: ruff 13 | args: [--fix, --exit-non-zero-on-fix] 14 | 15 | # python static type checking 16 | - repo: https://github.com/pre-commit/mirrors-mypy 17 | rev: v1.6.1 18 | hooks: 19 | - id: mypy 20 | args: 21 | - --disallow-untyped-defs 22 | - --disallow-incomplete-defs 23 | - --check-untyped-defs 24 | - --no-implicit-optional 25 | - --ignore-missing-imports 26 | - --follow-imports=silent 27 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 4, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": false, 7 | "trailingComma": "none", 8 | "bracketSpacing": true, 9 | "jsxBracketSameLine": false, 10 | "proseWrap": "always", 11 | "endOfLine": "auto", 12 | "overrides": [ 13 | { 14 | "files": ["*.yml", "*.yaml"], 15 | "options": { 16 | "tabWidth": 2 17 | } 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.ruff.toml: -------------------------------------------------------------------------------- 1 | line-length = 88 2 | ignore = [ 3 | "D100", # Missing docstring in public module 4 | "D101", # Missing docstring in public class 5 | "D102", # Missing docstring in public method 6 | "D103", # Missing docstring in public function 7 | "D104", # Missing docstring in public package 8 | "D105", # Missing docstring in magic method 9 | "D106", # Missing docstring in public nested class 10 | "D107", # Missing docstring in __init__ 11 | "D203", # 1 blank line required before class docstring 12 | "D212", # Multi-line docstring summary should start at the first line 13 | "D213", # Multi-line docstring summary should start at the second line 14 | "D404", # First word of the docstring should not be "This" 15 | "D405", # Section name should be properly capitalized 16 | "D406", # Section name should end with a newline 17 | "D407", # Missing dashed underline after section 18 | "D410", # Missing blank line after section 19 | "D411", # Missing blank line before section 20 | "D412", # No blank lines allowed between a section header and its content 21 | "D416", # Section name should end with a colon 22 | "D417", 23 | "D417", # Missing argument description in the docstring 24 | ] 25 | select = [ 26 | "A", # flake8 builtins 27 | "C9", # mcabe 28 | "D", # pydocstyle 29 | "E", # pycodestyle (errors) 30 | "F", # Pyflakes 31 | "I", # isort 32 | "S", # flake8-bandit 33 | "T2", # flake8-print 34 | "W", # pycodestype (warnings) 35 | ] 36 | 37 | [isort] 38 | combine-as-imports = true 39 | 40 | [mccabe] 41 | max-complexity = 8 42 | 43 | [per-file-ignores] 44 | "*tests/*" = [ 45 | "D205", # 1 blank line required between summary line and description 46 | "D400", # First line should end with a period 47 | "D401", # First line should be in imperative mood 48 | "D415", # First line should end with a period, question mark, or exclamation point 49 | "E501", # Line too long 50 | "E731", # Do not assign a lambda expression, use a def 51 | "S101", # Use of assert detected 52 | "S105", # Possible hardcoded password 53 | "S106", # Possible hardcoded password 54 | "S113", # Probable use of requests call with timeout set to {value} 55 | ] 56 | "*/migrations/*" = [ 57 | "E501", # Line too long 58 | ] 59 | "*/settings.py" = [ 60 | "F403", # from {name} import * used; unable to detect undefined names 61 | "F405", # {name} may be undefined, or defined from star imports: 62 | ] 63 | "*/settings/*" = [ 64 | "F403", # from {name} import * used; unable to detect undefined names 65 | "F405", # {name} may be undefined, or defined from star imports: 66 | ] 67 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | ## 2.0 5 | 6 | **BREAKING CHANGES** 7 | 8 | * Add support for Django 4.1,4.2,5.0 and Python 3.11, 3.12. 9 | * Drop support for Python 3.7. 10 | * Replace flake8, isort with ruff. 11 | * Add browser, device, os denomralised fields (thanks @mboboc) 12 | 13 | ## 1.1 14 | 15 | * Added support for controlling logging duplicates (#20) 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 yunojuno 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-user-visit 2 | 3 | Django app for recording daily user visits 4 | 5 | #### Compatibility 6 | 7 | This package supports Python 3.8 and above and Django 3.2 and above. 8 | 9 | ## Upgrading from v1 to v2 10 | 11 | v2 added three new denormalised fields extracted from the User Agent 12 | string - device, os, browser - to make it easier to analyse directly 13 | in the database. 14 | 15 | If you want to backfill historical data you will need to run the 16 | management command `update_user_visit_user_agent_data` after the 17 | upgrade. 18 | 19 | --- 20 | 21 | This app consists of middleware to record user visits, and a single 22 | `UserVisit` model to capture that data. 23 | 24 | The principal behind this is _not_ to record every single request made 25 | by a user. It is to record each daily visit to a site. 26 | 27 | The one additional factor is that it will record a single daily visit 28 | per session / device / ip combination. This means that if a user visits 29 | a site multiple times from the same location / same device, without 30 | logging out, then they will be recorded once. If the same user logs in 31 | from a different device, IP address, then they will be recorded again. 32 | 33 | The goal is to record unique daily visits per user 'context' ( where 34 | context is the location / device combo). 35 | 36 | Admin list view: 37 | 38 | ![UserVisit list view](assets/screenshot-admin-list-view.png) 39 | 40 | Admin edit view: 41 | 42 | ![UserVisit edit view](assets/screenshot-admin-edit-view.png) 43 | -------------------------------------------------------------------------------- /assets/screenshot-admin-edit-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yunojuno/django-user-visit/6c645b9bc32857060c64225f259d410285042420/assets/screenshot-admin-edit-view.png -------------------------------------------------------------------------------- /assets/screenshot-admin-list-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yunojuno/django-user-visit/6c645b9bc32857060c64225f259d410285042420/assets/screenshot-admin-list-view.png -------------------------------------------------------------------------------- /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", "tests.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | check_untyped_defs=true 3 | disallow_incomplete_defs=true 4 | disallow_untyped_defs=true 5 | follow_imports=silent 6 | ignore_missing_imports=true 7 | no_implicit_optional=true 8 | strict_optional=true 9 | warn_redundant_casts=true 10 | warn_unreachable=true 11 | warn_unused_ignores=true 12 | 13 | # Disable mypy for migrations 14 | [mypy-*.migrations.*] 15 | ignore_errors=true 16 | 17 | # Disable mypy for settings 18 | [mypy-*.settings.*] 19 | ignore_errors=true 20 | 21 | ; # Disable mypy for tests 22 | ; [mypy-tests.*] 23 | ; ignore_errors=true 24 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "django-user-visit" 3 | version = "2.0" 4 | description = "Django app used to track user visits." 5 | license = "MIT" 6 | authors = ["YunoJuno "] 7 | maintainers = ["YunoJuno "] 8 | readme = "README.md" 9 | homepage = "https://github.com/yunojuno/django-user-visit" 10 | repository = "https://github.com/yunojuno/django-user-visit" 11 | documentation = "https://github.com/yunojuno/django-user-visit" 12 | classifiers = [ 13 | "Environment :: Web Environment", 14 | "Framework :: Django", 15 | "Framework :: Django :: 3.2", 16 | "Framework :: Django :: 4.0", 17 | "Framework :: Django :: 4.1", 18 | "Framework :: Django :: 4.2", 19 | "Framework :: Django :: 5.0", 20 | "License :: OSI Approved :: MIT License", 21 | "Operating System :: OS Independent", 22 | "Programming Language :: Python :: 3 :: Only", 23 | "Programming Language :: Python :: 3.8", 24 | "Programming Language :: Python :: 3.9", 25 | "Programming Language :: Python :: 3.10", 26 | "Programming Language :: Python :: 3.11", 27 | "Programming Language :: Python :: 3.12", 28 | ] 29 | packages = [{ include = "user_visit" }] 30 | 31 | [tool.poetry.dependencies] 32 | python = "^3.8" 33 | django = "^3.2 || ^4.0 || ^5.0" 34 | user-agents = "^2.1" 35 | 36 | [tool.poetry.dev-dependencies] 37 | black = {version = "*", allow-prereleases = true} 38 | coverage = "*" 39 | freezegun = "*" 40 | mypy = "*" 41 | pre-commit = "*" 42 | pytest = "*" 43 | pytest-cov = "*" 44 | pytest-django = "*" 45 | ruff = "*" 46 | tox = "*" 47 | 48 | [build-system] 49 | requires = ["poetry>=0.12"] 50 | build-backend = "poetry.masonry.api" 51 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = tests.settings -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yunojuno/django-user-visit/6c645b9bc32857060c64225f259d410285042420/tests/__init__.py -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | 3 | import django 4 | 5 | DEBUG = True 6 | TEMPLATE_DEBUG = True 7 | USE_TZ = True 8 | USE_L10N = True 9 | 10 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": "test.db"}} 11 | 12 | INSTALLED_APPS = ( 13 | "django.contrib.admin", 14 | "django.contrib.auth", 15 | "django.contrib.contenttypes", 16 | "django.contrib.sessions", 17 | "django.contrib.messages", 18 | "django.contrib.staticfiles", 19 | "user_visit", 20 | "tests", 21 | ) 22 | 23 | MIDDLEWARE = [ 24 | # default django middleware 25 | "django.contrib.sessions.middleware.SessionMiddleware", 26 | "django.middleware.common.CommonMiddleware", 27 | "django.middleware.csrf.CsrfViewMiddleware", 28 | "django.contrib.auth.middleware.AuthenticationMiddleware", 29 | "django.contrib.messages.middleware.MessageMiddleware", 30 | # this package's middleware 31 | "user_visit.middleware.UserVisitMiddleware", 32 | ] 33 | 34 | PROJECT_DIR = path.abspath(path.join(path.dirname(__file__))) 35 | 36 | TEMPLATES = [ 37 | { 38 | "BACKEND": "django.template.backends.django.DjangoTemplates", 39 | "DIRS": [path.join(PROJECT_DIR, "templates")], 40 | "APP_DIRS": True, 41 | "OPTIONS": { 42 | "context_processors": [ 43 | "django.contrib.messages.context_processors.messages", 44 | "django.contrib.auth.context_processors.auth", 45 | "django.template.context_processors.request", 46 | ] 47 | }, 48 | } 49 | ] 50 | 51 | 52 | STATIC_URL = "/static/" 53 | 54 | SECRET_KEY = "secret" # noqa: S105 55 | 56 | LOGGING = { 57 | "version": 1, 58 | "disable_existing_loggers": False, 59 | "formatters": {"simple": {"format": "%(levelname)s %(message)s"}}, 60 | "handlers": { 61 | "console": { 62 | "level": "DEBUG", 63 | "class": "logging.StreamHandler", 64 | "formatter": "simple", 65 | } 66 | }, 67 | "loggers": { 68 | "": {"handlers": ["console"], "propagate": True, "level": "DEBUG"}, 69 | # 'django': { 70 | # 'handlers': ['console'], 71 | # 'propagate': True, 72 | # 'level': 'WARNING', 73 | # }, 74 | # 'request_profiler': { 75 | # 'handlers': ['console'], 76 | # 'propagate': True, 77 | # 'level': 'WARNING', 78 | # }, 79 | }, 80 | } 81 | 82 | ROOT_URLCONF = "tests.urls" 83 | 84 | ################################################### 85 | # django_coverage overrides 86 | 87 | # Specify a list of regular expressions of module paths to exclude 88 | # from the coverage analysis. Examples are ``'tests$'`` and ``'urls$'``. 89 | # This setting is optional. 90 | COVERAGE_MODULE_EXCLUDES = [ 91 | "tests$", 92 | "settings$", 93 | "urls$", 94 | "locale$", 95 | "common.views.test", 96 | "__init__", 97 | "django", 98 | "migrations", 99 | "request_profiler.admin", 100 | "request_profiler.signals", 101 | ] 102 | # COVERAGE_REPORT_HTML_OUTPUT_DIR = 'coverage/html' 103 | # COVERAGE_USE_STDOUT = True 104 | 105 | # turn off caching for tests 106 | REQUEST_PROFILER_RULESET_CACHE_TIMEOUT = 0 107 | 108 | # AUTH_USER_MODEL = 'tests.CustomUser' 109 | if not DEBUG: 110 | raise django.core.exceptions.ImproperlyConfigured( 111 | "This settings file can only be used with DEBUG=True" 112 | ) 113 | -------------------------------------------------------------------------------- /tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import django.db 4 | import freezegun 5 | import pytest 6 | from django.contrib.auth.models import User 7 | from django.core.exceptions import MiddlewareNotUsed 8 | from django.http import HttpResponse 9 | from django.test import Client 10 | from django.utils import timezone 11 | 12 | from user_visit.middleware import UserVisitMiddleware, save_user_visit 13 | from user_visit.models import UserVisit, UserVisitManager 14 | 15 | 16 | @pytest.mark.django_db 17 | def test_save_user_visit() -> None: 18 | """Test standalone save method handles db.IntegrityError.""" 19 | user = User.objects.create(username="Yoda") 20 | timestamp = timezone.now() 21 | uv = UserVisit.objects.create( 22 | user=user, 23 | session_key="test", 24 | ua_string="Chrome", 25 | remote_addr="127.0.0.1", 26 | timestamp=timestamp, 27 | ) 28 | uv.id = None 29 | save_user_visit(uv) 30 | 31 | 32 | @pytest.mark.django_db 33 | @mock.patch("user_visit.middleware.logger") 34 | def test_save_user_visit__duplicate(mock_logger: mock.Mock) -> None: 35 | """Test standalone save method handles db.IntegrityError.""" 36 | user = User.objects.create(username="Yoda") 37 | timestamp = timezone.now() 38 | uv = UserVisit.objects.create( 39 | user=user, 40 | session_key="test", 41 | ua_string="Chrome", 42 | remote_addr="127.0.0.1", 43 | timestamp=timestamp, 44 | ) 45 | uv.id = None 46 | # this should raise an IntegrityError as the object hash hasn't changed. 47 | save_user_visit(uv) 48 | assert mock_logger.warning.call_count == 1 49 | 50 | 51 | @pytest.mark.django_db 52 | @mock.patch("user_visit.middleware.logger") 53 | @mock.patch("user_visit.middleware.DUPLICATE_LOG_LEVEL", "debug") 54 | def test_save_user_visit__duplicate__log_levels(mock_logger: mock.Mock) -> None: 55 | """Test standalone save method handles db.IntegrityError.""" 56 | user = User.objects.create(username="Yoda") 57 | timestamp = timezone.now() 58 | uv = UserVisit.objects.create( 59 | user=user, 60 | session_key="test", 61 | ua_string="Chrome", 62 | remote_addr="127.0.0.1", 63 | timestamp=timestamp, 64 | ) 65 | uv.id = None 66 | save_user_visit(uv) 67 | assert mock_logger.warning.call_count == 0 68 | assert mock_logger.debug.call_count == 1 69 | 70 | 71 | @pytest.mark.django_db 72 | class TestUserVisitMiddleware: 73 | """RequestTokenMiddleware tests.""" 74 | 75 | def get_middleware(self) -> UserVisitMiddleware: 76 | return UserVisitMiddleware(get_response=lambda r: HttpResponse()) 77 | 78 | def test_middleware__anon(self) -> None: 79 | """Check that anonymous users are ignored.""" 80 | client = Client() 81 | with mock.patch.object(UserVisitManager, "build") as build: 82 | client.get("/") 83 | assert build.call_count == 0 84 | 85 | def test_middleware__auth(self) -> None: 86 | """Check that authenticated users are recorded.""" 87 | client = Client() 88 | client.force_login(User.objects.create_user("Fred")) 89 | client.get("/") 90 | assert UserVisit.objects.count() == 1 91 | 92 | def test_middleware__same_day(self) -> None: 93 | """Check that same user, same day, gets only one visit recorded.""" 94 | client = Client() 95 | client.force_login(User.objects.create_user("Fred")) 96 | client.get("/") 97 | client.get("/") 98 | assert UserVisit.objects.count() == 1 99 | 100 | def test_middleware__new_day(self) -> None: 101 | """Check that same user, new day, gets new visit.""" 102 | user = User.objects.create_user("Fred") 103 | client = Client() 104 | client.force_login(user) 105 | with freezegun.freeze_time("2020-07-04"): 106 | client.get("/") 107 | assert UserVisit.objects.count() == 1 108 | # new day, new visit 109 | with freezegun.freeze_time("2020-07-05"): 110 | client.get("/") 111 | assert UserVisit.objects.count() == 2 112 | 113 | def test_middleware__db_integrity_error(self) -> None: 114 | """Check that a failing save doesn't kill middleware.""" 115 | user = User.objects.create_user("Fred") 116 | client = Client() 117 | client.force_login(user) 118 | with mock.patch.object(UserVisit, "save", side_effect=django.db.IntegrityError): 119 | client.get("/") 120 | 121 | @mock.patch("user_visit.middleware.RECORDING_DISABLED", True) 122 | def test_middleware__disabled(self) -> None: 123 | """Test update_cache and check_cache functions.""" 124 | with pytest.raises(MiddlewareNotUsed): 125 | UserVisitMiddleware(get_response=lambda r: HttpResponse()) 126 | 127 | @mock.patch( 128 | "user_visit.middleware.RECORDING_BYPASS", 129 | lambda r: r.user.username == "Fred", 130 | ) 131 | @pytest.mark.parametrize("username", ["Fred", "Ginger"]) 132 | def test_middleware__bypassed(self, username: str) -> None: 133 | """Test the RECORDING_BYPASS function.""" 134 | user = User.objects.create_user(username) 135 | client = Client() 136 | client.force_login(user) 137 | client.get("/") 138 | count = 0 if user.username == "Fred" else 1 139 | assert UserVisit.objects.count() == count 140 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from unittest import mock 3 | 4 | import django.db 5 | import pytest 6 | from django.contrib.auth.models import User 7 | from django.utils import timezone 8 | 9 | from user_visit.models import UserVisit, parse_remote_addr, parse_ua_string 10 | 11 | from .utils import mock_request 12 | 13 | ONE_DAY = datetime.timedelta(days=1) 14 | ONE_SEC = datetime.timedelta(seconds=1) 15 | 16 | 17 | class TestUserVisitFunctions: 18 | @pytest.mark.parametrize( 19 | "xff,remote,output", 20 | ( 21 | ("", "", ""), 22 | ("127.0.0.1", "", "127.0.0.1"), 23 | ("127.0.0.1,192.168.0.1", "", "127.0.0.1"), 24 | ("127.0.0.1", "192.168.0.1", "127.0.0.1"), 25 | ("", "192.168.0.1", "192.168.0.1"), 26 | ), 27 | ) 28 | def test_remote_addr(self, xff: str, remote: str, output: str) -> None: 29 | request = mock_request() 30 | request.headers["X-Forwarded-For"] = xff 31 | request.META["REMOTE_ADDR"] = remote 32 | assert parse_remote_addr(request) == output 33 | 34 | @pytest.mark.parametrize("ua_string", ("", "Chrome")) 35 | def test_ua_string(self, ua_string: str) -> None: 36 | request = mock_request() 37 | request.headers["User-Agent"] = ua_string 38 | assert parse_ua_string(request) == ua_string 39 | 40 | 41 | class TestUserVisitManager: 42 | def test_build(self) -> None: 43 | request = mock_request() 44 | timestamp = timezone.now() 45 | uv = UserVisit.objects.build(request, timestamp) 46 | assert uv.user == request.user 47 | assert uv.timestamp == timestamp 48 | assert uv.date == timestamp.date() 49 | assert uv.session_key == "test" 50 | assert uv.ua_string == "Chrome 99" 51 | assert uv.remote_addr == "127.0.0.1" 52 | assert uv.hash == uv.md5().hexdigest() 53 | assert uv.uuid is not None 54 | assert uv.pk is None 55 | 56 | def test_build__REQUEST_CONTEXT_EXTRACTOR(self) -> None: 57 | request = mock_request() 58 | timestamp = timezone.now() 59 | extractor = lambda r: {"foo": "bar"} 60 | with mock.patch("user_visit.models.REQUEST_CONTEXT_EXTRACTOR", extractor): 61 | uv = UserVisit.objects.build(request, timestamp) 62 | assert uv.context == {"foo": "bar"} 63 | 64 | 65 | class TestUserVisit: 66 | UA_STRING = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36" 67 | 68 | def test_user_agent(self) -> None: 69 | uv = UserVisit(ua_string=TestUserVisit.UA_STRING) 70 | assert str(uv.user_agent) == "PC / Mac OS X 10.15.5 / Chrome 83.0.4103" 71 | 72 | @pytest.mark.django_db 73 | def test_save(self) -> None: 74 | request = mock_request() 75 | request.user.save() 76 | timestamp = timezone.now() 77 | uv = UserVisit.objects.build(request, timestamp) 78 | uv.hash = None 79 | uv.context = {"foo": "bar"} 80 | uv.save() 81 | assert uv.hash == uv.md5().hexdigest() 82 | 83 | @pytest.mark.django_db 84 | def test_unique(self) -> None: 85 | """Check that visits on the same day but at different times, are rejected.""" 86 | user = User.objects.create(username="Bob") 87 | timestamp1 = timezone.now() 88 | uv1 = UserVisit.objects.create( 89 | user=user, 90 | session_key="test", 91 | ua_string="Chrome", 92 | remote_addr="127.0.0.1", 93 | timestamp=timestamp1, 94 | ) 95 | uv2 = UserVisit( 96 | user=uv1.user, 97 | session_key=uv1.session_key, 98 | ua_string=uv1.ua_string, 99 | remote_addr=uv1.remote_addr, 100 | timestamp=uv1.timestamp - ONE_SEC, 101 | ) 102 | assert uv1.date == uv2.date 103 | with pytest.raises(django.db.IntegrityError): 104 | uv2.save() 105 | 106 | @pytest.mark.django_db 107 | def test_get_latest_by(self) -> None: 108 | """Check that latest() is ordered by timestamp, not id.""" 109 | user = User.objects.create(username="Bob") 110 | timestamp1 = timezone.now() 111 | uv1 = UserVisit.objects.create( 112 | user=user, 113 | session_key="test", 114 | ua_string="Chrome", 115 | remote_addr="127.0.0.1", 116 | timestamp=timestamp1, 117 | ) 118 | timestamp2 = timestamp1 - datetime.timedelta(seconds=1) 119 | uv2 = UserVisit.objects.create( 120 | user=user, 121 | session_key="test", 122 | ua_string="Chrome", 123 | remote_addr="192.168.0.1", 124 | timestamp=timestamp2, 125 | ) 126 | assert uv1.timestamp > uv2.timestamp 127 | assert user.user_visits.latest() == uv1 128 | 129 | def test_md5(self) -> None: 130 | """Check that MD5 changes when properties change.""" 131 | uv = UserVisit( 132 | user=User(), 133 | session_key="test", 134 | ua_string="Chrome", 135 | remote_addr="127.0.0.1", 136 | timestamp=timezone.now(), 137 | ) 138 | h1 = uv.md5().hexdigest() 139 | uv.session_key = "test2" 140 | assert uv.md5().hexdigest() != h1 141 | uv.session_key = "test" 142 | 143 | uv.ua_string = "Chrome99" 144 | assert uv.md5().hexdigest() != h1 145 | uv.ua_string = "Chrome" 146 | 147 | uv.remote_addr = "192.168.0.1" 148 | assert uv.md5().hexdigest() != h1 149 | uv.remote_addr = "127.0.0.1" 150 | 151 | uv.user.id = 2 152 | assert uv.md5().hexdigest() != h1 153 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path 3 | 4 | admin.autodiscover() 5 | 6 | urlpatterns = [ 7 | path("admin/", admin.site.urls), 8 | ] 9 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from django.contrib.auth.models import AnonymousUser, User 4 | from django.http import HttpRequest 5 | 6 | 7 | def mock_request(is_authenticated: bool = True) -> mock.Mock: 8 | return mock.Mock( 9 | spec=HttpRequest, 10 | user=User() if is_authenticated else AnonymousUser(), 11 | session=mock.Mock(session_key="test"), 12 | headers={"X-Forwarded-For": "127.0.0.1", "User-Agent": "Chrome 99"}, 13 | META={"REMOTE_ADDR": "192.168.0.1"}, 14 | ) 15 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | isolated_build = True 3 | envlist = 4 | fmt, lint, mypy, 5 | django-checks, 6 | ; https://docs.djangoproject.com/en/5.0/releases/ 7 | django32-py{38,39,310} 8 | django40-py{38,39,310} 9 | django41-py{38,39,310,311} 10 | django42-py{38,39,310,311} 11 | django50-py{310,311,312} 12 | djangomain-py{311,312} 13 | 14 | [testenv] 15 | deps = 16 | coverage 17 | freezegun 18 | pytest 19 | pytest-cov 20 | pytest-django 21 | django32: Django>=3.2,<3.3 22 | django40: Django>=4.0,<4.1 23 | django41: Django>=4.1,<4.2 24 | django42: Django>=4.2,<4.3 25 | django50: https://github.com/django/django/archive/stable/5.0.x.tar.gz 26 | djangomain: https://github.com/django/django/archive/main.tar.gz 27 | 28 | commands = 29 | pytest --cov=user_visit --verbose tests/ 30 | 31 | [testenv:django-checks] 32 | description = Django system checks and missing migrations 33 | deps = Django 34 | commands = 35 | python manage.py check --fail-level WARNING 36 | python manage.py makemigrations --dry-run --check --verbosity 3 37 | 38 | [testenv:fmt] 39 | description = Python source code formatting (black) 40 | deps = 41 | black 42 | 43 | commands = 44 | black --check user_visit 45 | 46 | [testenv:lint] 47 | description = Python source code linting (ruff) 48 | deps = 49 | ruff 50 | 51 | commands = 52 | ruff check user_visit 53 | 54 | [testenv:mypy] 55 | description = Python source code type hints (mypy) 56 | deps = 57 | mypy 58 | 59 | commands = 60 | mypy user_visit 61 | -------------------------------------------------------------------------------- /user_visit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yunojuno/django-user-visit/6c645b9bc32857060c64225f259d410285042420/user_visit/__init__.py -------------------------------------------------------------------------------- /user_visit/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import UserVisit 4 | 5 | 6 | class UserVisitAdmin(admin.ModelAdmin): 7 | list_display = ("timestamp", "user", "session_key", "remote_addr", "user_agent") 8 | list_filter = ("timestamp",) 9 | search_fields = ( 10 | "user__first_name", 11 | "user__last_name", 12 | "user__username", 13 | "ua_string", 14 | ) 15 | raw_id_fields = ("user",) 16 | readonly_fields = ( 17 | "user", 18 | "hash", 19 | "timestamp", 20 | "session_key", 21 | "remote_addr", 22 | "device", 23 | "os", 24 | "browser", 25 | "ua_string", 26 | "context", 27 | "created_at", 28 | ) 29 | ordering = ("-timestamp",) 30 | 31 | 32 | admin.site.register(UserVisit, UserVisitAdmin) 33 | -------------------------------------------------------------------------------- /user_visit/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class UserVisitAppConfig(AppConfig): 5 | name = "user_visit" 6 | verbose_name = "User visit log" 7 | default_auto_field = "django.db.models.AutoField" 8 | -------------------------------------------------------------------------------- /user_visit/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yunojuno/django-user-visit/6c645b9bc32857060c64225f259d410285042420/user_visit/management/__init__.py -------------------------------------------------------------------------------- /user_visit/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yunojuno/django-user-visit/6c645b9bc32857060c64225f259d410285042420/user_visit/management/commands/__init__.py -------------------------------------------------------------------------------- /user_visit/management/commands/update_user_visit_user_agent_data.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse 4 | from typing import Any 5 | 6 | from django.core.management.base import BaseCommand 7 | from django.utils.translation import gettext as _, gettext_lazy as _lazy 8 | 9 | from user_visit.models import UserVisit 10 | 11 | 12 | class Command(BaseCommand): 13 | help = _lazy( # noqa: A003 14 | "Sync browser, device and OS data missing from UserVisit" 15 | ) 16 | 17 | def add_arguments(self, parser: argparse.ArgumentParser) -> None: 18 | parser.add_argument( 19 | "-f", 20 | "--force", 21 | action="store_true", 22 | default=False, 23 | help=_( 24 | "Use the --force option to update all UserVisit " 25 | "objects (defaults to backfilling empty records only)." 26 | ), 27 | ) 28 | 29 | def handle(self, *args: Any, **options: Any) -> None: 30 | visits = UserVisit.objects.all() 31 | if not options["force"]: 32 | visits = visits.filter(ua_string="") 33 | updated = 0 34 | for v in visits.iterator(): 35 | user_agent = v.user_agent 36 | v.device = user_agent.get_device() 37 | v.os = user_agent.get_os() 38 | v.browser = user_agent.get_browser() 39 | v.save(update_fields=["device", "os", "browser"]) 40 | self.stdout.write(f"Updated UserVisit #{v.pk}") 41 | updated += 1 42 | self.stdout.write("---") 43 | self.stdout.write(f"Updated {updated} UserVisit objects.") 44 | -------------------------------------------------------------------------------- /user_visit/middleware.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import typing 3 | 4 | import django.db 5 | from django.core.exceptions import MiddlewareNotUsed 6 | from django.http import HttpRequest, HttpResponse 7 | from django.utils import timezone 8 | 9 | from user_visit.models import UserVisit 10 | 11 | from .settings import DUPLICATE_LOG_LEVEL, RECORDING_BYPASS, RECORDING_DISABLED 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | @django.db.transaction.atomic 17 | def save_user_visit(user_visit: UserVisit) -> None: 18 | """Save the user visit and handle db.IntegrityError.""" 19 | try: 20 | user_visit.save() 21 | except django.db.IntegrityError: 22 | getattr(logger, DUPLICATE_LOG_LEVEL)( 23 | "Error saving user visit (hash='%s')", user_visit.hash 24 | ) 25 | 26 | 27 | class UserVisitMiddleware: 28 | """Middleware to record user visits.""" 29 | 30 | def __init__(self, get_response: typing.Callable) -> None: 31 | if RECORDING_DISABLED: 32 | raise MiddlewareNotUsed("UserVisit recording has been disabled") 33 | self.get_response = get_response 34 | 35 | def __call__(self, request: HttpRequest) -> typing.Optional[HttpResponse]: 36 | if request.user.is_anonymous: 37 | return self.get_response(request) 38 | 39 | if RECORDING_BYPASS(request): 40 | return self.get_response(request) 41 | 42 | uv = UserVisit.objects.build(request, timezone.now()) 43 | if not UserVisit.objects.filter(hash=uv.hash).exists(): 44 | save_user_visit(uv) 45 | 46 | return self.get_response(request) 47 | -------------------------------------------------------------------------------- /user_visit/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.8 on 2020-07-04 21:37 2 | 3 | import uuid 4 | 5 | import django.db.models.deletion 6 | import django.utils.timezone 7 | from django.conf import settings 8 | from django.db import migrations, models 9 | 10 | 11 | class Migration(migrations.Migration): 12 | initial = True 13 | 14 | dependencies = [ 15 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name="UserVisit", 21 | fields=[ 22 | ( 23 | "id", 24 | models.AutoField( 25 | auto_created=True, 26 | primary_key=True, 27 | serialize=False, 28 | verbose_name="ID", 29 | ), 30 | ), 31 | ( 32 | "timestamp", 33 | models.DateTimeField( 34 | default=django.utils.timezone.now, 35 | help_text=( 36 | "The time at which the first visit of the day was recorded" 37 | ), 38 | ), 39 | ), 40 | ( 41 | "session_key", 42 | models.CharField( 43 | help_text="Django session identifier", max_length=40 44 | ), 45 | ), 46 | ( 47 | "remote_addr", 48 | models.CharField( 49 | blank=True, 50 | help_text=( 51 | "Client IP address (from X-Forwarded-For HTTP header, " 52 | "or REMOTE_ADDR request property)" 53 | ), 54 | max_length=100, 55 | ), 56 | ), 57 | ( 58 | "ua_string", 59 | models.TextField( 60 | blank=True, 61 | help_text="Client User-Agent HTTP header", 62 | verbose_name="User agent (raw)", 63 | ), 64 | ), 65 | ( 66 | "uuid", 67 | models.UUIDField(default=uuid.uuid4, editable=False, unique=True), 68 | ), 69 | ( 70 | "hash", 71 | models.CharField( 72 | help_text="MD5 hash generated from request properties", 73 | max_length=32, 74 | unique=True, 75 | ), 76 | ), 77 | ( 78 | "user", 79 | models.ForeignKey( 80 | on_delete=django.db.models.deletion.CASCADE, 81 | related_name="user_visits", 82 | to=settings.AUTH_USER_MODEL, 83 | ), 84 | ), 85 | ], 86 | ), 87 | ] 88 | -------------------------------------------------------------------------------- /user_visit/migrations/0002_add_created_at.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.8 on 2020-07-05 10:15 2 | import django.utils.timezone 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("user_visit", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterModelOptions( 13 | name="uservisit", 14 | options={"get_latest_by": "timestamp"}, 15 | ), 16 | migrations.AddField( 17 | model_name="uservisit", 18 | name="created_at", 19 | field=models.DateTimeField( 20 | auto_now_add=True, 21 | default=django.utils.timezone.now, 22 | help_text=( 23 | "The time at which the database record was created (!=timestamp)" 24 | ), 25 | ), 26 | preserve_default=False, 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /user_visit/migrations/0003_uservisit_context.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.12 on 2022-03-07 17:02 2 | 3 | import django.core.serializers.json 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("user_visit", "0002_add_created_at"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="uservisit", 15 | name="context", 16 | field=models.JSONField( 17 | blank=True, 18 | default=dict, 19 | encoder=django.core.serializers.json.DjangoJSONEncoder, 20 | help_text="Used for storing ad hoc / ephemeral data - e.g. GeoIP.", 21 | null=True, 22 | ), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /user_visit/migrations/0004_uservisit_browser_uservisit_device_uservisit_os.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.7 on 2023-11-07 12:52 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("user_visit", "0003_uservisit_context"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="uservisit", 14 | name="browser", 15 | field=models.CharField(blank=True, default="", max_length=200), 16 | ), 17 | migrations.AddField( 18 | model_name="uservisit", 19 | name="device", 20 | field=models.CharField( 21 | blank=True, default="", max_length=200, verbose_name="Device type" 22 | ), 23 | ), 24 | migrations.AddField( 25 | model_name="uservisit", 26 | name="os", 27 | field=models.CharField( 28 | blank=True, default="", max_length=200, verbose_name="Operating System" 29 | ), 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /user_visit/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yunojuno/django-user-visit/6c645b9bc32857060c64225f259d410285042420/user_visit/migrations/__init__.py -------------------------------------------------------------------------------- /user_visit/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | import hashlib 5 | import uuid 6 | from typing import Any 7 | 8 | import user_agents 9 | from django.conf import settings 10 | from django.db import models 11 | from django.http import HttpRequest 12 | from django.utils import timezone 13 | from django.utils.translation import gettext_lazy as _lazy 14 | 15 | from user_visit.settings import REQUEST_CONTEXT_ENCODER, REQUEST_CONTEXT_EXTRACTOR 16 | 17 | 18 | def parse_remote_addr(request: HttpRequest) -> str: 19 | """Extract client IP from request.""" 20 | x_forwarded_for = request.headers.get("X-Forwarded-For", "") 21 | if x_forwarded_for: 22 | return x_forwarded_for.split(",")[0] 23 | return request.META.get("REMOTE_ADDR", "") 24 | 25 | 26 | def parse_ua_string(request: HttpRequest) -> str: 27 | """Extract client user-agent from request.""" 28 | return request.headers.get("User-Agent", "") 29 | 30 | 31 | class UserVisitManager(models.Manager): 32 | """Custom model manager for UserVisit objects.""" 33 | 34 | def build(self, request: HttpRequest, timestamp: datetime.datetime) -> UserVisit: 35 | """Build a new UserVisit object from a request, without saving it.""" 36 | uv = UserVisit( 37 | user=request.user, 38 | timestamp=timestamp, 39 | session_key=request.session.session_key, 40 | remote_addr=parse_remote_addr(request), 41 | ua_string=parse_ua_string(request), 42 | context=REQUEST_CONTEXT_EXTRACTOR(request), 43 | ) 44 | uv.hash = uv.md5().hexdigest() 45 | uv.browser = uv.user_agent.get_browser()[:200] 46 | uv.device = uv.user_agent.get_device()[:200] 47 | uv.os = uv.user_agent.get_os()[:200] 48 | return uv 49 | 50 | 51 | class UserVisit(models.Model): 52 | """ 53 | Record of a user visiting the site on a given day. 54 | 55 | This is used for tracking and reporting - knowing the volume of visitors 56 | to the site, and being able to report on someone's interaction with the site. 57 | 58 | We record minimal info required to identify user sessions, plus changes in 59 | IP and device. This is useful in identifying suspicious activity (multiple 60 | logins from different locations). 61 | 62 | Also helpful in identifying support issues (as getting useful browser data 63 | out of users can be very difficult over live chat). 64 | 65 | """ 66 | 67 | user = models.ForeignKey( 68 | settings.AUTH_USER_MODEL, related_name="user_visits", on_delete=models.CASCADE 69 | ) 70 | timestamp = models.DateTimeField( 71 | help_text=_lazy("The time at which the first visit of the day was recorded"), 72 | default=timezone.now, 73 | ) 74 | session_key = models.CharField(help_text="Django session identifier", max_length=40) 75 | remote_addr = models.CharField( 76 | help_text=_lazy( 77 | "Client IP address (from X-Forwarded-For HTTP header, " 78 | "or REMOTE_ADDR request property)" 79 | ), 80 | max_length=100, 81 | blank=True, 82 | ) 83 | ua_string = models.TextField( 84 | _lazy("User agent (raw)"), 85 | help_text=_lazy("Client User-Agent HTTP header"), 86 | blank=True, 87 | ) 88 | browser = models.CharField( 89 | max_length=200, 90 | blank=True, 91 | default="", 92 | ) 93 | device = models.CharField( 94 | _lazy("Device type"), 95 | max_length=200, 96 | blank=True, 97 | default="", 98 | ) 99 | os = models.CharField( 100 | _lazy("Operating System"), 101 | max_length=200, 102 | blank=True, 103 | default="", 104 | ) 105 | uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) 106 | hash = models.CharField( # noqa: A003 107 | max_length=32, 108 | help_text=_lazy("MD5 hash generated from request properties"), 109 | unique=True, 110 | ) 111 | created_at = models.DateTimeField( 112 | help_text=_lazy( 113 | "The time at which the database record was created (!=timestamp)" 114 | ), 115 | auto_now_add=True, 116 | ) 117 | context = models.JSONField( 118 | default=dict, 119 | blank=True, 120 | null=True, 121 | encoder=REQUEST_CONTEXT_ENCODER, 122 | help_text=_lazy("Used for storing ad hoc / ephemeral data - e.g. GeoIP."), 123 | ) 124 | 125 | objects = UserVisitManager() 126 | 127 | class Meta: 128 | get_latest_by = "timestamp" 129 | 130 | def __str__(self) -> str: 131 | return f"{self.user} visited the site on {self.timestamp}" 132 | 133 | def __repr__(self) -> str: 134 | return f"" 135 | 136 | def save(self, *args: Any, **kwargs: Any) -> None: 137 | """Set hash property and save object.""" 138 | self.hash = self.md5().hexdigest() 139 | super().save(*args, **kwargs) 140 | 141 | @property 142 | def user_agent(self) -> user_agents.parsers.UserAgent: 143 | """Return UserAgent object from the raw user_agent string.""" 144 | return user_agents.parsers.parse(self.ua_string) 145 | 146 | @property 147 | def date(self) -> datetime.date: 148 | """Extract the date of the visit from the timestamp.""" 149 | return self.timestamp.date() 150 | 151 | # see https://github.com/python/typeshed/issues/2928 re. return type 152 | def md5(self) -> hashlib._Hash: 153 | """Generate MD5 hash used to identify duplicate visits.""" 154 | h = hashlib.md5(str(self.user.id).encode()) # noqa: S303, S324 155 | h.update(self.date.isoformat().encode()) 156 | h.update(self.session_key.encode()) 157 | h.update(self.remote_addr.encode()) 158 | h.update(self.ua_string.encode()) 159 | return h 160 | -------------------------------------------------------------------------------- /user_visit/settings.py: -------------------------------------------------------------------------------- 1 | from os import getenv 2 | from typing import Any, Callable 3 | 4 | from django.conf import settings 5 | from django.core.serializers.json import DjangoJSONEncoder 6 | from django.http import HttpRequest 7 | 8 | 9 | def _env_or_setting(key: str, default: Any, cast_func: Callable = lambda x: x) -> Any: 10 | return cast_func(getenv(key) or getattr(settings, key, default)) 11 | 12 | 13 | RECORDING_DISABLED = _env_or_setting( 14 | "USER_VISIT_RECORDING_DISABLED", False, lambda x: bool(x) 15 | ) 16 | 17 | 18 | # function that takes a request object and returns a dictionary of info 19 | # that will be stored against the request. By default returns empty 20 | # dict. canonical example of a use case for this is extracting GeoIP 21 | # info. 22 | REQUEST_CONTEXT_EXTRACTOR: Callable[[HttpRequest], dict] = getattr( 23 | settings, "USER_VISIT_REQUEST_CONTEXT_EXTRACTOR", lambda r: {} 24 | ) 25 | 26 | 27 | # Can be used to override the JSON encoder used for the context JSON 28 | # fields 29 | REQUEST_CONTEXT_ENCODER = getattr( 30 | settings, "USER_VISIT_CONTEXT_ENCODER", DjangoJSONEncoder 31 | ) 32 | 33 | 34 | # function used to bypass recording for specific requests - this can be 35 | # used to e.g. prevent staff users from being recorded. The function 36 | # must be a Callable that takes a HttpRequest arg and returns a bool - 37 | # if True then the recording is bypassed. 38 | RECORDING_BYPASS = getattr(settings, "USER_VISIT_RECORDING_BYPASS", lambda r: False) 39 | 40 | 41 | # The log level to use when logging duplicate hashes. This is WARNING by 42 | # default, but if it's noisy you can turn this down by setting this 43 | # value. Must be one of "debug", "info", "warning", "error" 44 | DUPLICATE_LOG_LEVEL: str = getattr( 45 | settings, "USER_VISIT_DUPLICATE_LOG_LEVEL", "warning" 46 | ).lower() 47 | --------------------------------------------------------------------------------