├── .coveragerc ├── .editorconfig ├── .github └── workflows │ └── tox.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .prettierrc ├── .rufff.toml ├── CHANGELOG ├── LICENSE ├── README.md ├── manage.py ├── mypy.ini ├── pyproject.toml ├── pytest.ini ├── tests ├── __init__.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20210126_0955.py │ ├── 0003_alter_user_first_name.py │ └── __init__.py ├── models.py ├── settings.py ├── test_integration.py ├── test_middleware.py ├── test_models.py ├── test_request.py ├── test_session.py ├── urls.py └── views.py ├── tox.ini └── utm_tracker ├── __init__.py ├── admin.py ├── apps.py ├── middleware.py ├── migrations ├── 0001_initial.py ├── 0002_leadsource_created_at.py ├── 0003_increase_medium_chars.py ├── 0004_leadsource_gclid.py ├── 0005_auto_20210809_1259.py ├── 0006_leadsource_custom_tags.py ├── 0007_alter_leadsource_content.py ├── 0008_increase_charfield_lengths.py ├── 0009_alter_leadsource_timestamp.py └── __init__.py ├── models.py ├── request.py ├── session.py ├── settings.py └── types.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | tests/* 4 | settings$ 5 | urls$ 6 | locale$ 7 | django 8 | utm_tracker/migrations/* 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 | 12 | [*.yaml] 13 | indent_size = 2 14 | -------------------------------------------------------------------------------- /.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 | django-checks: 36 | name: Run Django checks and migration watch 37 | runs-on: ubuntu-latest 38 | env: 39 | TOXENV: django-checks 40 | 41 | steps: 42 | - name: Check out the repository 43 | uses: actions/checkout@v4 44 | 45 | - name: Set up Python 3.11 46 | uses: actions/setup-python@v4 47 | with: 48 | python-version: "3.11" 49 | 50 | - name: Install and run tox 51 | run: | 52 | pip install tox 53 | tox 54 | 55 | test: 56 | name: Run tests 57 | runs-on: ubuntu-latest 58 | strategy: 59 | matrix: 60 | python: ["3.8","3.9","3.10","3.10","3.12"] 61 | django: ["32","40","41","42","50",main] 62 | exclude: 63 | - python: "3.8" 64 | django: "50" 65 | - python: "3.8" 66 | django: "main" 67 | - python: "3.9" 68 | django: "50" 69 | - python: "3.9" 70 | django: "main" 71 | - python: "3.10" 72 | django: "main" 73 | - python: "3.11" 74 | django: "32" 75 | - python: "3.12" 76 | django: "32" 77 | env: 78 | TOXENV: django${{ matrix.django }}-py${{ matrix.python }} 79 | 80 | steps: 81 | - name: Check out the repository 82 | uses: actions/checkout@v3 83 | 84 | - name: Set up Python ${{ matrix.python }} 85 | uses: actions/setup-python@v4 86 | with: 87 | python-version: ${{ matrix.python }} 88 | 89 | - name: Install and run tox 90 | run: | 91 | pip install tox 92 | tox 93 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | poetry.lock 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | .python-version 87 | 88 | # pipenv 89 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 90 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 91 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 92 | # install all needed dependencies. 93 | #Pipfile.lock 94 | 95 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 96 | __pypackages__/ 97 | 98 | # Celery stuff 99 | celerybeat-schedule 100 | celerybeat.pid 101 | 102 | # SageMath parsed files 103 | *.sage.py 104 | 105 | # Environments 106 | .env 107 | .venv 108 | env/ 109 | venv/ 110 | ENV/ 111 | env.bak/ 112 | venv.bak/ 113 | 114 | # Spyder project settings 115 | .spyderproject 116 | .spyproject 117 | 118 | # Rope project settings 119 | .ropeproject 120 | 121 | # mkdocs documentation 122 | /site 123 | 124 | # mypy 125 | .mypy_cache/ 126 | .dmypy.json 127 | dmypy.json 128 | 129 | # Pyre type checker 130 | .pyre/ 131 | test.db 132 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | # python code formatting - will amend files 3 | - repo: https://github.com/ambv/black 4 | rev: 23.3.0 5 | hooks: 6 | - id: black 7 | 8 | - repo: https://github.com/charliermarsh/ruff-pre-commit 9 | # Ruff version. 10 | rev: "v0.1.5" 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.7.0 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 | } 13 | -------------------------------------------------------------------------------- /.rufff.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 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## [1.3.1] - 2023-09-22 6 | 7 | - Replace pylint, flake8, and isort with ruff 8 | - Add Django 5.0.x to build matrix and classifiers (h/t @sarahboyce) 9 | 10 | ## [1.3.0] - 2023-02-17 11 | 12 | - Update the way in which "timestamp" is set 13 | - Add Django 4.1 to the build matrix 14 | -------------------------------------------------------------------------------- /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 UTM Tracker 2 | 3 | Django app for extracting and storing UTM tracking values. 4 | 5 | ## Django support 6 | 7 | This package support Django 3.2+, and Python 3.8+ 8 | 9 | ## Background 10 | 11 | This app has been designed to integrate the standard `utm_*` querystring 12 | parameters that are used by online advertisers with your Django project. 13 | 14 | It does _not_ replace analytics (e.g. Google Analytics) and Adwords tracking, 15 | but does have one crucial difference - it allows you to assign a specific user 16 | to a campaign advert. 17 | 18 | This may be useful if you are trying to assess the value of multiple channels / 19 | campaigns. 20 | 21 | ### Supported querystring parameters 22 | 23 | Parameter | Definition 24 | :-- | :-- 25 | utm_medium | Identifies what type of link was used. 26 | utm_source | Identifies which site sent the traffic, and is a required parameter. 27 | utm_campaign | Identifies a specific product promotion or strategic campaign. 28 | utm_term | Identifies search terms. 29 | gclid | Identifies a google click, is used for ad tracking in Google Analytics via Google Ads. 30 | aclk | Identifies a Microsoft Ad click (bing), is used for ad tracking. 31 | msclkid | Identifies a Microsoft Ad click (MS ad network), is used for ad tracking. 32 | twclid | Identifies a Twitter Ad click, is used for ad tracking. 33 | fbclid | Identifies a Facebook Ad click, is used for ad tracking. 34 | 35 | In addition to the fixed list above, you can also specify custom tags 36 | using the `UTM_TRACKER_CUSTOM_TAGS` setting. Any querystring params that 37 | match these tags are stashed in a JSONField called `custom_tags`. 38 | 39 | ## How it works 40 | 41 | The app works as a pair of middleware classes, that extract `utm_` 42 | values from any incoming request querystring, and then store those 43 | parameters against the request.user (if authenticated), or in the 44 | request.session (if not). 45 | 46 | The following shows this workflow (pseudocode - see 47 | `test_utm_and_lead_source` for a real example): 48 | 49 | ```python 50 | client = Client() 51 | # first request stashes values, but does not create a LeadSource as user is anonymous 52 | client.get("/?utm_medium=medium&utm_source=source...") 53 | assert utm_values_in_session 54 | assert LeadSource.objects.count() == 0 55 | 56 | # subsequent request, with authenticated user, extracts values and stores LeadSource 57 | user = User.objects.create(username="fred") 58 | client.force_login(user, backend=settings.FORCED_AUTH_BACKEND) 59 | client.get("/") 60 | assert not utm_values_in_session 61 | assert LeadSource.objects.count() == 1 62 | ``` 63 | 64 | ### Why split the middleware in two? 65 | 66 | By splitting the middleware into two classes, we enable the use case where we 67 | can track leads without `utm_` querystring parameters. For instance, if you have 68 | an internal referral program, using a simple token, you can capture this as a 69 | `LeadSource` by adding sentinel values to the `request.session`: 70 | 71 | ```python 72 | def referral(request, token): 73 | # do token handling 74 | ... 75 | # medium and source are mandatory for lead source capture 76 | request.session["utm_medium"] = "referral" 77 | request.session["utm_source"] = "internal" 78 | # campaign, term and content are optional fields 79 | request.session["utm_campaign"] = "july" 80 | request.session["utm_term"] = token 81 | request.session["utm_content"] = "buy-me" 82 | return render(request, "landing_page.html") 83 | ``` 84 | 85 | ## Configuration 86 | 87 | Add the app to `INSTALLED_APPS`: 88 | 89 | ```python 90 | # settings.py 91 | INSTALLED_APPS = [ 92 | ... 93 | "utm_tracker" 94 | ] 95 | 96 | UTM_TRACKER_CUSTOM_TAGS = ["tag1", "tag2"] 97 | ``` 98 | 99 | and add both middleware classes to `MIDDLEWARE`: 100 | 101 | ```python 102 | # settings.py 103 | MIDDLEWARE = [ 104 | ... 105 | "utm_tracker.middleware.UtmSessionMiddleware", 106 | "utm_tracker.middleware.LeadSourceMiddleware", 107 | ] 108 | ``` 109 | 110 | The `UtmSession` middleware must come before `LeadSource` middleware. 111 | -------------------------------------------------------------------------------- /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 | strict_optional=True 3 | ignore_missing_imports=True 4 | follow_imports=silent 5 | warn_redundant_casts=True 6 | warn_unused_ignores = true 7 | warn_unreachable = true 8 | disallow_untyped_defs = true 9 | disallow_incomplete_defs = true 10 | 11 | # Disable mypy for migrations 12 | [mypy-*.migrations.*] 13 | ignore_errors=True 14 | 15 | # Disable mypy for settings 16 | [mypy-*.settings.*] 17 | ignore_errors=True 18 | 19 | # Disable mypy for tests 20 | [mypy-tests.*] 21 | ignore_errors=True 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "django-utm-tracker" 3 | version = "1.3.2" 4 | description = "Django app for extracting and storing UTM tracking values." 5 | license = "MIT" 6 | authors = ["YunoJuno "] 7 | maintainers = ["YunoJuno "] 8 | readme = "README.md" 9 | homepage = "https://github.com/yunojuno/django-utm-tracker" 10 | repository = "https://github.com/yunojuno/django-utm-tracker" 11 | documentation = "https://github.com/yunojuno/django-utm-tracker" 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="utm_tracker"}] 30 | 31 | [tool.poetry.dependencies] 32 | python = "^3.8" 33 | django = "^3.2 || ^4.0 || ^5.0" 34 | 35 | [tool.poetry.dev-dependencies] 36 | black = "*" 37 | coverage = "*" 38 | freezegun = "*" 39 | mypy = "*" 40 | pre-commit = "*" 41 | pytest = "*" 42 | pytest-cov = "*" 43 | pytest-django = "*" 44 | ruff = "*" 45 | tox = "*" 46 | 47 | [build-system] 48 | requires = ["poetry>=0.12"] 49 | build-backend = "poetry.masonry.api" 50 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = tests.settings -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yunojuno/django-utm-tracker/d94ccaa9485e167287a8a32099cc41bdc1db07c2/tests/__init__.py -------------------------------------------------------------------------------- /tests/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TestAppConfig(AppConfig): 5 | name = "tests" 6 | verbose_name = "Test App" 7 | -------------------------------------------------------------------------------- /tests/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.8 on 2020-07-12 22:11 2 | 3 | import django.contrib.auth.models 4 | import django.contrib.auth.validators 5 | import django.utils.timezone 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | initial = True 11 | 12 | dependencies = [ 13 | ("auth", "0011_update_proxy_permissions"), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name="User", 19 | fields=[ 20 | ( 21 | "id", 22 | models.AutoField( 23 | auto_created=True, 24 | primary_key=True, 25 | serialize=False, 26 | verbose_name="ID", 27 | ), 28 | ), 29 | ("password", models.CharField(max_length=128, verbose_name="password")), 30 | ( 31 | "last_login", 32 | models.DateTimeField( 33 | blank=True, null=True, verbose_name="last login" 34 | ), 35 | ), 36 | ( 37 | "is_superuser", 38 | models.BooleanField( 39 | default=False, 40 | help_text=( 41 | "Designates that this user has all permissions " 42 | "without explicitly assigning them." 43 | ), 44 | verbose_name="superuser status", 45 | ), 46 | ), 47 | ( 48 | "username", 49 | models.CharField( 50 | error_messages={ 51 | "unique": "A user with that username already exists." 52 | }, 53 | help_text=( 54 | "Required. 150 characters or fewer. " 55 | "Letters, digits and @/./+/-/_ only." 56 | ), 57 | max_length=150, 58 | unique=True, 59 | validators=[ 60 | django.contrib.auth.validators.UnicodeUsernameValidator() 61 | ], 62 | verbose_name="username", 63 | ), 64 | ), 65 | ( 66 | "first_name", 67 | models.CharField( 68 | blank=True, max_length=150, verbose_name="first name" 69 | ), 70 | ), 71 | ( 72 | "last_name", 73 | models.CharField( 74 | blank=True, max_length=150, verbose_name="last name" 75 | ), 76 | ), 77 | ( 78 | "email", 79 | models.EmailField( 80 | blank=True, max_length=254, verbose_name="email address" 81 | ), 82 | ), 83 | ( 84 | "is_staff", 85 | models.BooleanField( 86 | default=False, 87 | help_text=( 88 | "Designates whether the user can " 89 | "log into this admin site." 90 | ), 91 | verbose_name="staff status", 92 | ), 93 | ), 94 | ( 95 | "is_active", 96 | models.BooleanField( 97 | default=True, 98 | help_text=( 99 | "Designates whether this user should be treated as active. " 100 | "Unselect this instead of deleting accounts." 101 | ), 102 | verbose_name="active", 103 | ), 104 | ), 105 | ( 106 | "date_joined", 107 | models.DateTimeField( 108 | default=django.utils.timezone.now, verbose_name="date joined" 109 | ), 110 | ), 111 | ( 112 | "groups", 113 | models.ManyToManyField( 114 | blank=True, 115 | help_text=( 116 | "The groups this user belongs to. " 117 | "A user will get all permissions granted to " 118 | "each of their groups." 119 | ), 120 | related_name="user_set", 121 | related_query_name="user", 122 | to="auth.Group", 123 | verbose_name="groups", 124 | ), 125 | ), 126 | ( 127 | "user_permissions", 128 | models.ManyToManyField( 129 | blank=True, 130 | help_text="Specific permissions for this user.", 131 | related_name="user_set", 132 | related_query_name="user", 133 | to="auth.Permission", 134 | verbose_name="user permissions", 135 | ), 136 | ), 137 | ], 138 | options={ 139 | "verbose_name": "user", 140 | "verbose_name_plural": "users", 141 | "abstract": False, 142 | }, 143 | managers=[ 144 | ("objects", django.contrib.auth.models.UserManager()), 145 | ], 146 | ), 147 | ] 148 | -------------------------------------------------------------------------------- /tests/migrations/0002_auto_20210126_0955.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.7 on 2021-01-26 15:55 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("tests", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="user", 14 | name="first_name", 15 | field=models.CharField( 16 | blank=True, max_length=30, verbose_name="first name" 17 | ), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /tests/migrations/0003_alter_user_first_name.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.6 on 2021-08-09 17:59 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("tests", "0002_auto_20210126_0955"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="user", 14 | name="first_name", 15 | field=models.CharField( 16 | blank=True, max_length=150, verbose_name="first name" 17 | ), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /tests/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yunojuno/django-utm-tracker/d94ccaa9485e167287a8a32099cc41bdc1db07c2/tests/migrations/__init__.py -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import AbstractUser 2 | 3 | 4 | class User(AbstractUser): 5 | """Custom user model used for testing only.""" 6 | 7 | pass 8 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | 3 | DEBUG = True 4 | TEMPLATE_DEBUG = True 5 | USE_TZ = True 6 | 7 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": "test.db"}} 8 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 9 | 10 | INSTALLED_APPS = ( 11 | "django.contrib.admin", 12 | "django.contrib.auth", 13 | "django.contrib.contenttypes", 14 | "django.contrib.sessions", 15 | "django.contrib.messages", 16 | "django.contrib.staticfiles", 17 | "utm_tracker", 18 | "tests", 19 | ) 20 | 21 | MIDDLEWARE = [ 22 | # default django middleware 23 | "django.contrib.sessions.middleware.SessionMiddleware", 24 | "django.middleware.common.CommonMiddleware", 25 | "django.middleware.csrf.CsrfViewMiddleware", 26 | "django.contrib.auth.middleware.AuthenticationMiddleware", 27 | "django.contrib.messages.middleware.MessageMiddleware", 28 | # this package's middleware 29 | "utm_tracker.middleware.UtmSessionMiddleware", 30 | "utm_tracker.middleware.LeadSourceMiddleware", 31 | ] 32 | 33 | PROJECT_DIR = path.abspath(path.join(path.dirname(__file__))) 34 | 35 | TEMPLATES = [ 36 | { 37 | "BACKEND": "django.template.backends.django.DjangoTemplates", 38 | "DIRS": [path.join(PROJECT_DIR, "templates")], 39 | "APP_DIRS": True, 40 | "OPTIONS": { 41 | "context_processors": [ 42 | "django.contrib.messages.context_processors.messages", 43 | "django.contrib.auth.context_processors.auth", 44 | "django.template.context_processors.request", 45 | ] 46 | }, 47 | } 48 | ] 49 | 50 | STATIC_URL = "/static/" 51 | 52 | SECRET_KEY = "secret" # noqa: S105 53 | 54 | LOGGING = { 55 | "version": 1, 56 | "disable_existing_loggers": False, 57 | "formatters": {"simple": {"format": "%(levelname)s %(message)s"}}, 58 | "handlers": { 59 | "console": { 60 | "level": "DEBUG", 61 | "class": "logging.StreamHandler", 62 | "formatter": "simple", 63 | } 64 | }, 65 | "loggers": { 66 | "": {"handlers": ["console"], "propagate": True, "level": "DEBUG"}, 67 | }, 68 | } 69 | 70 | ROOT_URLCONF = "tests.urls" 71 | 72 | AUTH_USER_MODEL = "tests.User" 73 | 74 | if not DEBUG: 75 | raise Exception("This settings file can only be used with DEBUG=True") 76 | 77 | # == APP SETTINGS ==# 78 | 79 | UTM_TRACKER_CUSTOM_TAGS = ["mytag"] 80 | -------------------------------------------------------------------------------- /tests/test_integration.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.test import TestCase 3 | 4 | from utm_tracker.models import LeadSource 5 | from utm_tracker.session import SESSION_KEY_UTM_PARAMS 6 | 7 | User = get_user_model() 8 | 9 | 10 | class IntegrationTests(TestCase): 11 | def test_single_utm(self): 12 | self.client.get("/200/?utm_medium=medium1&utm_source=source1&foo=bar") 13 | utm_params = self.client.session[SESSION_KEY_UTM_PARAMS] 14 | assert len(utm_params) == 1 15 | assert utm_params[0]["utm_medium"] == "medium1" 16 | assert utm_params[0]["utm_source"] == "source1" 17 | assert not LeadSource.objects.exists() 18 | 19 | def test_duplicate_utm(self): 20 | self.client.get("/200/?utm_medium=medium1&utm_source=source1&foo=bar") 21 | self.client.get("/200/?utm_medium=medium1&utm_source=source1&foo=bar") 22 | utm_params = self.client.session[SESSION_KEY_UTM_PARAMS] 23 | assert len(utm_params) == 1 24 | assert utm_params[0]["utm_medium"] == "medium1" 25 | assert utm_params[0]["utm_source"] == "source1" 26 | assert not LeadSource.objects.exists() 27 | 28 | def test_multiple_utm(self): 29 | self.client.get("/200/?utm_medium=medium1&utm_source=source1&foo=bar") 30 | self.client.get("/200/?utm_medium=medium2&utm_source=source1&foo=bar") 31 | utm_params = self.client.session[SESSION_KEY_UTM_PARAMS] 32 | assert len(utm_params) == 2 33 | assert utm_params[0]["utm_medium"] == "medium1" 34 | assert utm_params[1]["utm_medium"] == "medium2" 35 | assert not LeadSource.objects.exists() 36 | 37 | def test_redirect_utm(self): 38 | response = self.client.get( 39 | "/302/?utm_medium=medium1&utm_source=source1", follow=True 40 | ) 41 | assert response.redirect_chain == [("/200/", 302)] 42 | utm_params = self.client.session[SESSION_KEY_UTM_PARAMS] 43 | assert len(utm_params) == 1 44 | assert not LeadSource.objects.exists() 45 | 46 | def test_redirect_perm_utm(self): 47 | response = self.client.get( 48 | "/301/?utm_medium=medium1&utm_source=source1", follow=True 49 | ) 50 | assert response.redirect_chain == [("/200/", 301)] 51 | utm_params = self.client.session[SESSION_KEY_UTM_PARAMS] 52 | assert len(utm_params) == 1 53 | assert not LeadSource.objects.exists() 54 | 55 | def test_dump_params(self): 56 | user = User.objects.create(username="fred") 57 | self.client.get( 58 | "/200/?utm_medium=medium1&utm_source=source1&gclid=1C5CHFA_enGB874GB874&aclk=ZASdENGG&twclid=SADFDdfaa&fbclid=ASrdfBB&msclkid=AbbbbasdasdD" 59 | ) 60 | assert not LeadSource.objects.exists() 61 | 62 | self.client.force_login(user) 63 | self.client.get( 64 | "/200/?utm_medium=medium2&utm_source=source2&gclid=1C5CHFA_enGB874GB874222&aclk=ZASdENGG&twclid=SADFDdfaa&fbclid=ASrdfBB&msclkid=AbbbbasdasdD" 65 | ) 66 | assert SESSION_KEY_UTM_PARAMS not in self.client.session 67 | # implicit test that there are exactly two objects created 68 | ls1, ls2 = list(LeadSource.objects.order_by("id")) 69 | assert ls1.medium == "medium1" 70 | assert ls1.source == "source1" 71 | assert ls1.gclid == "1C5CHFA_enGB874GB874" 72 | assert ls1.aclk == "ZASdENGG" 73 | assert ls1.msclkid == "AbbbbasdasdD" 74 | assert ls1.twclid == "SADFDdfaa" 75 | assert ls2.fbclid == "ASrdfBB" 76 | assert ls2.source == "source2" 77 | assert ls2.gclid == "1C5CHFA_enGB874GB874222" 78 | -------------------------------------------------------------------------------- /tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from django.contrib.auth import get_user_model 4 | from django.contrib.auth.models import AnonymousUser 5 | from django.contrib.sessions.backends.base import SessionBase 6 | from django.http import HttpRequest, HttpResponse 7 | 8 | from utm_tracker.middleware import LeadSourceMiddleware, UtmSessionMiddleware 9 | from utm_tracker.session import SESSION_KEY_UTM_PARAMS 10 | 11 | User = get_user_model() 12 | 13 | 14 | class TestUtmSessionMiddleware: 15 | @mock.patch("utm_tracker.middleware.parse_qs") 16 | def test_middleware(self, mock_utm): 17 | request = mock.Mock(spec=HttpRequest) 18 | request.session = SessionBase() 19 | mock_utm.return_value = { 20 | "utm_medium": "medium", 21 | "utm_source": "source", 22 | "utm_campaign": "campaign", 23 | "utm_term": "term", 24 | "utm_content": "content", 25 | "gclid": "1C5CHFA_enGB874GB874", 26 | "aclk": "2C5CHFA_enGB874GB874", 27 | "msclkid": "3C5CHFA_enGB874GB874", 28 | "twclid": "4C5CHFA_enGB874GB874", 29 | "fbclid": "5C5CHFA_enGB874GB874", 30 | } 31 | middleware = UtmSessionMiddleware(lambda r: HttpResponse()) 32 | middleware(request) 33 | assert len(request.session[SESSION_KEY_UTM_PARAMS]) == 1 34 | utm_params = request.session[SESSION_KEY_UTM_PARAMS][0] 35 | assert utm_params["utm_medium"] == "medium" 36 | assert utm_params["utm_source"] == "source" 37 | assert utm_params["utm_campaign"] == "campaign" 38 | assert utm_params["utm_term"] == "term" 39 | assert utm_params["utm_content"] == "content" 40 | assert utm_params["gclid"] == "1C5CHFA_enGB874GB874" 41 | assert utm_params["aclk"] == "2C5CHFA_enGB874GB874" 42 | assert utm_params["msclkid"] == "3C5CHFA_enGB874GB874" 43 | assert utm_params["twclid"] == "4C5CHFA_enGB874GB874" 44 | assert utm_params["fbclid"] == "5C5CHFA_enGB874GB874" 45 | 46 | @mock.patch("utm_tracker.middleware.parse_qs") 47 | def test_middleware__no_params(self, mock_utm): 48 | request = mock.Mock(spec=HttpRequest) 49 | request.session = SessionBase() 50 | mock_utm.return_value = {} 51 | middleware = UtmSessionMiddleware(lambda r: HttpResponse()) 52 | middleware(request) 53 | assert SESSION_KEY_UTM_PARAMS not in request.session 54 | 55 | 56 | class TestLeadSourceMiddleware: 57 | @mock.patch("utm_tracker.middleware.dump_utm_params") 58 | def test_middleware__unauthenticated(self, mock_flush): 59 | request = mock.Mock(spec=HttpRequest, user=AnonymousUser()) 60 | assert not request.user.is_authenticated 61 | middleware = LeadSourceMiddleware(lambda r: HttpResponse()) 62 | middleware(request) 63 | assert mock_flush.call_count == 0 64 | 65 | @mock.patch("utm_tracker.middleware.dump_utm_params") 66 | def test_middleware__authenticated(self, mock_flush): 67 | session = mock.Mock(SessionBase) 68 | request = mock.Mock(spec=HttpRequest, user=User(), session=session) 69 | middleware = LeadSourceMiddleware(lambda r: HttpResponse()) 70 | middleware(request) 71 | assert mock_flush.call_count == 1 72 | mock_flush.assert_called_once_with(request.user, session) 73 | 74 | @mock.patch("utm_tracker.middleware.dump_utm_params") 75 | def test_middleware__error(self, mock_flush): 76 | session = mock.Mock(SessionBase) 77 | request = mock.Mock(spec=HttpRequest, user=User(), session=session) 78 | mock_flush.side_effect = Exception("Panic") 79 | middleware = LeadSourceMiddleware(lambda r: HttpResponse()) 80 | middleware(request) 81 | assert mock_flush.call_count == 1 82 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | from django.contrib.auth import get_user_model 5 | 6 | from utm_tracker.models import LeadSource 7 | 8 | User = get_user_model() 9 | 10 | 11 | @pytest.mark.django_db 12 | @mock.patch("utm_tracker.request.CUSTOM_TAGS", ["tag1", "tag2"]) 13 | def test_create_from_utm_params(): 14 | user = User.objects.create(username="Bob") 15 | utm_params = { 16 | "utm_medium": "medium", 17 | "utm_source": "source", 18 | "utm_campaign": "campaign", 19 | "utm_term": "term", 20 | "utm_content": "content", 21 | "gclid": "1C5CHFA_enGB874GB874", 22 | "aclk": "2C5CHFA_enGB874GB874", 23 | "msclkid": "3C5CHFA_enGB874GB874", 24 | "twclid": "4C5CHFA_enGB874GB874", 25 | "fbclid": "5C5CHFA_enGB874GB874", 26 | "tag1": "foo", 27 | "tag2": "bar", 28 | } 29 | 30 | ls_returned = LeadSource.objects.create_from_utm_params(user, utm_params) 31 | 32 | ls = LeadSource.objects.get() 33 | assert ls == ls_returned 34 | 35 | assert ls.user == user 36 | assert ls.medium == "medium" 37 | assert ls.source == "source" 38 | assert ls.campaign == "campaign" 39 | assert ls.term == "term" 40 | assert ls.content == "content" 41 | assert ls.gclid == "1C5CHFA_enGB874GB874" 42 | assert ls.aclk == "2C5CHFA_enGB874GB874" 43 | assert ls.msclkid == "3C5CHFA_enGB874GB874" 44 | assert ls.twclid == "4C5CHFA_enGB874GB874" 45 | assert ls.fbclid == "5C5CHFA_enGB874GB874" 46 | assert ls.custom_tags == {"tag1": "foo", "tag2": "bar"} 47 | 48 | 49 | @pytest.mark.django_db 50 | def test_create_from_utm_params___missing_params(): 51 | """Check failure on missing medium and content.""" 52 | user = User.objects.create(username="Bob") 53 | utm_params = {"utm_source": "source"} 54 | with pytest.raises(ValueError): 55 | LeadSource.objects.create_from_utm_params(user, utm_params) 56 | 57 | utm_params = {"utm_medium": "source"} 58 | with pytest.raises(ValueError): 59 | LeadSource.objects.create_from_utm_params(user, utm_params) 60 | 61 | utm_params = {"utm_source": "source", "utm_medium": "medium"} 62 | LeadSource.objects.create_from_utm_params(user, utm_params) 63 | -------------------------------------------------------------------------------- /tests/test_request.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from django.http import HttpRequest, QueryDict 4 | 5 | from utm_tracker.request import parse_qs 6 | 7 | 8 | def test_parse_qs__ignores_non_utm() -> None: 9 | request = mock.Mock(spec=HttpRequest) 10 | request.GET = QueryDict( 11 | "utm_source=source" 12 | "&utm_medium=medium" 13 | "&utm_campaign=campaign" 14 | "&utm_term=term" 15 | "&utm_content=content" 16 | "&foo=bar", 17 | ) 18 | assert parse_qs(request) == { 19 | "utm_source": "source", 20 | "utm_medium": "medium", 21 | "utm_campaign": "campaign", 22 | "utm_term": "term", 23 | "utm_content": "content", 24 | } 25 | 26 | 27 | def test_parse_qs__ignores_empty_fields() -> None: 28 | request = mock.Mock(spec=HttpRequest) 29 | request.GET = QueryDict( 30 | "utm_source=Source" 31 | "&utm_medium=Medium" 32 | "&utm_campaign=" 33 | "&utm_term=" 34 | "&utm_content=" 35 | ) 36 | assert parse_qs(request) == { 37 | "utm_source": "source", 38 | "utm_medium": "medium", 39 | } 40 | 41 | 42 | @mock.patch("utm_tracker.request.CUSTOM_TAGS", ["tag1", "tag2"]) 43 | def test_parse_qs__custom_tags() -> None: 44 | request = mock.Mock(spec=HttpRequest) 45 | request.GET = QueryDict( 46 | "utm_source=Source&utm_medium=Medium&tag1=foo&tag1=bar&tag2=baz" 47 | ) 48 | assert parse_qs(request) == { 49 | "utm_source": "source", 50 | "utm_medium": "medium", 51 | "tag1": "foo,bar", 52 | "tag2": "baz", 53 | } 54 | -------------------------------------------------------------------------------- /tests/test_session.py: -------------------------------------------------------------------------------- 1 | import freezegun 2 | import pytest 3 | from django.contrib.auth import get_user_model 4 | from django.contrib.sessions.backends.base import SessionBase 5 | from django.utils.timezone import now as tz_now 6 | 7 | from utm_tracker.models import LeadSource 8 | from utm_tracker.session import ( 9 | SESSION_KEY_UTM_PARAMS, 10 | dump_utm_params, 11 | stash_utm_params, 12 | ) 13 | 14 | User = get_user_model() 15 | 16 | # just need a time to freeze - doesn't matter what it is. 17 | FROZEN_TIME = tz_now() 18 | 19 | 20 | @freezegun.freeze_time(FROZEN_TIME) 21 | def test_stash_utm_params(): 22 | session = SessionBase() 23 | assert not stash_utm_params(session, {}) 24 | 25 | assert stash_utm_params(session, {"utm_medium": "foo"}) 26 | assert session.modified 27 | assert len(session[SESSION_KEY_UTM_PARAMS]) == 1 28 | assert session[SESSION_KEY_UTM_PARAMS][0] == { 29 | "utm_medium": "foo", 30 | "timestamp": FROZEN_TIME.isoformat(), 31 | } 32 | 33 | # add a second set of params 34 | assert stash_utm_params(session, {"utm_medium": "bar"}) 35 | assert len(session[SESSION_KEY_UTM_PARAMS]) == 2 36 | assert session[SESSION_KEY_UTM_PARAMS][1] == { 37 | "utm_medium": "bar", 38 | "timestamp": FROZEN_TIME.isoformat(), 39 | } 40 | 41 | # add a duplicate set of params 42 | assert not stash_utm_params(session, {"utm_medium": "bar"}) 43 | 44 | 45 | @pytest.mark.django_db 46 | def test_dump_utm_params(): 47 | user = User.objects.create() 48 | utm_params1 = {"utm_medium": "medium1", "utm_source": "source1"} 49 | utm_params2 = {"utm_medium": "medium2", "utm_source": "source2"} 50 | session = {SESSION_KEY_UTM_PARAMS: [utm_params1, utm_params2]} 51 | created = dump_utm_params(user, session) 52 | assert LeadSource.objects.count() == 2 53 | first = LeadSource.objects.first() 54 | last = LeadSource.objects.last() 55 | assert first.medium == "medium1" 56 | assert last.medium == "medium2" 57 | assert created == [first, last] 58 | 59 | 60 | @pytest.mark.django_db 61 | def test_dump_utm_params__error(): 62 | """Check that if one utm_params fail, others continue.""" 63 | user = User.objects.create() 64 | utm_params1 = {"utm_mediumx": "medium1", "utm_source": "source1"} 65 | utm_params2 = {"utm_medium": "medium2", "utm_source": "source2"} 66 | session = {SESSION_KEY_UTM_PARAMS: [utm_params1, utm_params2]} 67 | created = dump_utm_params(user, session) 68 | # only one object will be stored 69 | source = LeadSource.objects.get() 70 | assert source.medium == "medium2" 71 | assert created == [source] 72 | # session is clean 73 | assert SESSION_KEY_UTM_PARAMS not in session 74 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path 3 | from django.views import debug 4 | 5 | from .views import test_view_200, test_view_301, test_view_302 6 | 7 | admin.autodiscover() 8 | 9 | urlpatterns = [ 10 | path("", debug.default_urlconf), 11 | path("admin/", admin.site.urls), 12 | path("200/", test_view_200, name="test_view_200"), 13 | path("301/", test_view_301, name="test_view_301"), 14 | path("302/", test_view_302, name="test_view_302"), 15 | ] 16 | -------------------------------------------------------------------------------- /tests/views.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpRequest, HttpResponse 2 | from django.http.response import HttpResponsePermanentRedirect, HttpResponseRedirect 3 | from django.urls import reverse 4 | 5 | 6 | def test_view_200(request: HttpRequest) -> HttpResponse: 7 | request.session.setdefault("foo", 0) 8 | request.session["foo"] += 1 9 | return HttpResponse("OK") 10 | 11 | 12 | def test_view_301(request: HttpRequest) -> HttpResponsePermanentRedirect: 13 | return HttpResponsePermanentRedirect(reverse("test_view_200")) 14 | 15 | 16 | def test_view_302(request: HttpRequest) -> HttpResponseRedirect: 17 | return HttpResponseRedirect(reverse("test_view_200")) 18 | -------------------------------------------------------------------------------- /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=utm_tracker --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 utm_tracker 45 | 46 | [testenv:lint] 47 | description = Python source code linting (ruff) 48 | deps = 49 | ruff 50 | 51 | commands = 52 | ruff utm_tracker 53 | 54 | [testenv:mypy] 55 | description = Python source code type hints (mypy) 56 | deps = 57 | mypy 58 | 59 | commands = 60 | mypy utm_tracker 61 | -------------------------------------------------------------------------------- /utm_tracker/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yunojuno/django-utm-tracker/d94ccaa9485e167287a8a32099cc41bdc1db07c2/utm_tracker/__init__.py -------------------------------------------------------------------------------- /utm_tracker/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import LeadSource 4 | 5 | 6 | class LeadSourceAdmin(admin.ModelAdmin): 7 | raw_id_fields = ("user",) 8 | list_display = ("user", "medium", "source", "campaign", "timestamp") 9 | search_fields = ("user__first_name", "user__last_name", "term", "content") 10 | list_filter = ("medium", "source", "timestamp") 11 | readonly_fields = ("created_at", "timestamp") 12 | 13 | 14 | admin.site.register(LeadSource, LeadSourceAdmin) 15 | -------------------------------------------------------------------------------- /utm_tracker/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class UtmTrackerConfig(AppConfig): 5 | name = "utm_tracker" 6 | verbose_name = "UTM Session Tracker" 7 | default_auto_field = "django.db.models.BigAutoField" 8 | -------------------------------------------------------------------------------- /utm_tracker/middleware.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Callable 3 | 4 | from django.http import HttpRequest, HttpResponse 5 | 6 | from .request import parse_qs 7 | from .session import dump_utm_params, stash_utm_params 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class UtmSessionMiddleware: 13 | """Extract utm values from querystring and store in session.""" 14 | 15 | def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]): 16 | self.get_response = get_response 17 | 18 | def __call__(self, request: HttpRequest) -> HttpResponse: 19 | stash_utm_params(request.session, parse_qs(request)) 20 | return self.get_response(request) 21 | 22 | 23 | class LeadSourceMiddleware: 24 | """ 25 | Store LeadSource and clear Session UTM values. 26 | 27 | If there are any utm_ params stored in the request session, this middleware 28 | will create a new LeadSource object from them, and clear out the values. This 29 | middleware should come after UtmSessionMiddleware. 30 | 31 | Only authenticated users have a LeadSource created - if the user is anonymous 32 | then the params are left in the request.session. If the session expires, or 33 | is deleted then this will be lost. If the user subsequently logs in, or is 34 | registered, then the session data will be retained, and on the first request 35 | with an authenticated user the data will be stored. 36 | 37 | """ 38 | 39 | def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]): 40 | self.get_response = get_response 41 | 42 | def __call__(self, request: HttpRequest) -> HttpResponse: 43 | if request.user.is_authenticated: 44 | try: 45 | dump_utm_params(request.user, request.session) 46 | except: # noqa E722 47 | logger.exception("Error flushing utm_params from request") 48 | 49 | return self.get_response(request) 50 | -------------------------------------------------------------------------------- /utm_tracker/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.8 on 2020-07-12 20:48 2 | 3 | import django.db.models.deletion 4 | import django.utils.timezone 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name="LeadSource", 19 | fields=[ 20 | ( 21 | "id", 22 | models.AutoField( 23 | auto_created=True, 24 | primary_key=True, 25 | serialize=False, 26 | verbose_name="ID", 27 | ), 28 | ), 29 | ( 30 | "medium", 31 | models.CharField( 32 | help_text=( 33 | "utm_medium: Identifies what type of link was used, " 34 | "such as cost per click or email.", 35 | ), 36 | max_length=10, 37 | ), 38 | ), 39 | ( 40 | "source", 41 | models.CharField( 42 | help_text=( 43 | "utm_source: Identifies which site sent the traffic, " 44 | "and is a required parameter." 45 | ), 46 | max_length=30, 47 | ), 48 | ), 49 | ( 50 | "campaign", 51 | models.CharField( 52 | blank=True, 53 | help_text=( 54 | "utm_campaign: Identifies a specific product " 55 | "promotion or strategic campaign." 56 | ), 57 | max_length=100, 58 | ), 59 | ), 60 | ( 61 | "term", 62 | models.CharField( 63 | blank=True, 64 | help_text="utm_term: Identifies search terms.", 65 | max_length=50, 66 | ), 67 | ), 68 | ( 69 | "content", 70 | models.CharField( 71 | blank=True, 72 | help_text=( 73 | "utm_content: Identifies what specifically was " 74 | "clicked to bring the user to the site, " 75 | "such as a banner ad or a text link." 76 | ), 77 | max_length=50, 78 | ), 79 | ), 80 | ( 81 | "timestamp", 82 | models.DateTimeField( 83 | default=django.utils.timezone.now, 84 | help_text="When the event occurred.", 85 | ), 86 | ), 87 | ( 88 | "user", 89 | models.ForeignKey( 90 | on_delete=django.db.models.deletion.CASCADE, 91 | related_name="lead_sources", 92 | to=settings.AUTH_USER_MODEL, 93 | ), 94 | ), 95 | ], 96 | options={ 97 | "get_latest_by": ("timestamp",), 98 | }, 99 | ), 100 | ] 101 | -------------------------------------------------------------------------------- /utm_tracker/migrations/0002_leadsource_created_at.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.8 on 2020-07-13 09:48 2 | 3 | import django.utils.timezone 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("utm_tracker", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="leadsource", 15 | name="created_at", 16 | field=models.DateTimeField( 17 | default=django.utils.timezone.now, 18 | help_text="When the event was recorded.", 19 | ), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /utm_tracker/migrations/0003_increase_medium_chars.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2020-11-20 14:58 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("utm_tracker", "0002_leadsource_created_at"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="leadsource", 14 | name="medium", 15 | field=models.CharField( 16 | help_text=( 17 | "utm_medium: Identifies what type of link was used, " 18 | "such as cost per click or email." 19 | ), 20 | max_length=30, 21 | ), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /utm_tracker/migrations/0004_leadsource_gclid.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.7 on 2021-01-26 15:55 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("utm_tracker", "0003_increase_medium_chars"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="leadsource", 14 | name="gclid", 15 | field=models.CharField( 16 | blank=True, 17 | help_text=( 18 | "Identifies a google click, is used for ad tracking in " 19 | "Google Analytics via Google Ads" 20 | ), 21 | max_length=255, 22 | ), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /utm_tracker/migrations/0005_auto_20210809_1259.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.6 on 2021-08-09 17:59 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("utm_tracker", "0004_leadsource_gclid"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="leadsource", 14 | name="aclk", 15 | field=models.CharField( 16 | blank=True, 17 | help_text=( 18 | "Identifies a Microsoft Ad click (bing), " 19 | "is used for ad tracking." 20 | ), 21 | max_length=255, 22 | ), 23 | ), 24 | migrations.AddField( 25 | model_name="leadsource", 26 | name="fbclid", 27 | field=models.CharField( 28 | blank=True, 29 | help_text="Identifies a Facebook Ad click, is used for ad tracking.", 30 | max_length=255, 31 | ), 32 | ), 33 | migrations.AddField( 34 | model_name="leadsource", 35 | name="msclkid", 36 | field=models.CharField( 37 | blank=True, 38 | help_text=( 39 | "Identifies a Microsoft Ad click (MS ad network), " 40 | "is used for ad tracking." 41 | ), 42 | max_length=255, 43 | ), 44 | ), 45 | migrations.AddField( 46 | model_name="leadsource", 47 | name="twclid", 48 | field=models.CharField( 49 | blank=True, 50 | help_text="Identifies a Twitter Ad click, is used for ad tracking.", 51 | max_length=255, 52 | ), 53 | ), 54 | ] 55 | -------------------------------------------------------------------------------- /utm_tracker/migrations/0006_leadsource_custom_tags.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2022-01-30 14:56 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("utm_tracker", "0005_auto_20210809_1259"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="leadsource", 14 | name="custom_tags", 15 | field=models.JSONField( 16 | blank=True, 17 | default=dict, 18 | help_text=( 19 | "Dict of custom tag:value pairs as defined by the " 20 | "UTM_TRACKER_CUSTOM_TAGS setting." 21 | ), 22 | null=True, 23 | ), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /utm_tracker/migrations/0007_alter_leadsource_content.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.12 on 2022-02-01 13:11 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("utm_tracker", "0006_leadsource_custom_tags"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="leadsource", 14 | name="content", 15 | field=models.CharField( 16 | blank=True, 17 | help_text=( 18 | "utm_content: Identifies what specifically was clicked " 19 | "to bring the user to the site, such as a banner ad or a text link." 20 | ), 21 | max_length=100, 22 | ), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /utm_tracker/migrations/0008_increase_charfield_lengths.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.12 on 2022-03-02 18:52 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("utm_tracker", "0007_alter_leadsource_content"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="leadsource", 14 | name="id", 15 | field=models.BigAutoField( 16 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 17 | ), 18 | ), 19 | migrations.AlterField( 20 | model_name="leadsource", 21 | name="medium", 22 | field=models.CharField( 23 | help_text=( 24 | "utm_medium: Identifies what type of link was used, " 25 | "such as cost per click or email." 26 | ), 27 | max_length=100, 28 | ), 29 | ), 30 | migrations.AlterField( 31 | model_name="leadsource", 32 | name="source", 33 | field=models.CharField( 34 | help_text=( 35 | "utm_source: Identifies which site sent the traffic, " 36 | "and is a required parameter." 37 | ), 38 | max_length=100, 39 | ), 40 | ), 41 | migrations.AlterField( 42 | model_name="leadsource", 43 | name="term", 44 | field=models.CharField( 45 | blank=True, 46 | help_text="utm_term: Identifies search terms.", 47 | max_length=100, 48 | ), 49 | ), 50 | ] 51 | -------------------------------------------------------------------------------- /utm_tracker/migrations/0009_alter_leadsource_timestamp.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.5 on 2023-02-17 09:20 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("utm_tracker", "0008_increase_charfield_lengths"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="leadsource", 14 | name="timestamp", 15 | field=models.DateTimeField( 16 | blank=True, 17 | default=None, 18 | help_text="When the event occurred (if known).", 19 | null=True, 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /utm_tracker/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yunojuno/django-utm-tracker/d94ccaa9485e167287a8a32099cc41bdc1db07c2/utm_tracker/migrations/__init__.py -------------------------------------------------------------------------------- /utm_tracker/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.conf import settings 4 | from django.db import models 5 | from django.db.models.base import Model 6 | from django.utils import timezone 7 | 8 | from .types import UtmParamsDict 9 | 10 | 11 | class LeadSourceManager(models.Manager): 12 | def create_from_utm_params( 13 | self, user: type[Model], utm_params: UtmParamsDict 14 | ) -> LeadSource: 15 | """Persist a LeadSource dictionary of utm_* values.""" 16 | try: 17 | params = utm_params.copy() 18 | return LeadSource.objects.create( 19 | user=user, 20 | timestamp=params.pop("timestamp", None), 21 | medium=params.pop("utm_medium")[:100], 22 | source=params.pop("utm_source")[:100], 23 | campaign=params.pop("utm_campaign", "")[:100], 24 | term=params.pop("utm_term", "")[:100], 25 | content=params.pop("utm_content", "")[:100], 26 | gclid=params.pop("gclid", "")[:255], 27 | msclkid=params.pop("msclkid", "")[:255], 28 | aclk=params.pop("aclk", "")[:255], 29 | twclid=params.pop("twclid", "")[:255], 30 | fbclid=params.pop("fbclid", "")[:255], 31 | # everything that hasn't already been popped is custom 32 | custom_tags=params, 33 | ) 34 | except KeyError as ex: 35 | raise ValueError(f"Missing utm param: {ex}") 36 | 37 | 38 | class LeadSource(models.Model): 39 | """ 40 | Model used to track inbound leads. 41 | 42 | The model is separate from the ClientOnboarding and FreelancerOnboarding 43 | models so that we can track both in one place, and also so that we can 44 | backfill all historical data (before those models existed). 45 | 46 | NB The User field is *not* unique - it is possible for one User to have 47 | come through multiple routes (i.e. seen multiple ads); how we determine 48 | which ad is the one that made them register is a point of debate - so 49 | in this model we just record the data - and let the analysis happen later. 50 | 51 | The fields in this model are based on the industry standard UTM fields ( 52 | see https://en.wikipedia.org/wiki/UTM_parameters for details). 53 | 54 | Most of the fields are optional, and included for completeness. Internal 55 | referrals do not have utm_* values associated with them, but the fields 56 | can be reused to identify the referrer. 57 | 58 | For example: 59 | 60 | medium: "referral" 61 | source: "internal" 62 | campaign: "profile" (e.g. book me) 63 | term: {{ referral token }} 64 | content: "book me" 65 | 66 | We are not using choices in this model as we can't predict what the params 67 | coming in from external sources may be, and we can't restrict the marketing 68 | team to specific values. 69 | 70 | """ 71 | 72 | user = models.ForeignKey( 73 | settings.AUTH_USER_MODEL, 74 | on_delete=models.CASCADE, 75 | related_name="lead_sources", 76 | ) 77 | medium = models.CharField( 78 | max_length=100, 79 | help_text=( 80 | "utm_medium: Identifies what type of link was used, " 81 | "such as cost per click or email." 82 | ), 83 | ) 84 | source = models.CharField( 85 | max_length=100, 86 | help_text=( 87 | "utm_source: Identifies which site sent the traffic, " 88 | "and is a required parameter." 89 | ), 90 | ) 91 | campaign = models.CharField( 92 | max_length=100, # can be autogenerated by email campaigns 93 | help_text=( 94 | "utm_campaign: Identifies a specific product promotion " 95 | "or strategic campaign." 96 | ), 97 | blank=True, 98 | ) 99 | term = models.CharField( 100 | max_length=100, help_text="utm_term: Identifies search terms.", blank=True 101 | ) 102 | 103 | gclid = models.CharField( 104 | max_length=255, 105 | help_text=( 106 | "Identifies a google click, is used for ad tracking" 107 | " in Google Analytics via Google Ads" 108 | ), 109 | blank=True, 110 | ) 111 | aclk = models.CharField( 112 | max_length=255, 113 | help_text=("Identifies a Microsoft Ad click (bing), is used for ad tracking."), 114 | blank=True, 115 | ) 116 | msclkid = models.CharField( 117 | max_length=255, 118 | help_text=( 119 | "Identifies a Microsoft Ad click (MS ad network), is used for ad tracking." 120 | ), 121 | blank=True, 122 | ) 123 | twclid = models.CharField( 124 | max_length=255, 125 | help_text=("Identifies a Twitter Ad click, is used for ad tracking."), 126 | blank=True, 127 | ) 128 | fbclid = models.CharField( 129 | max_length=255, 130 | help_text=("Identifies a Facebook Ad click, is used for ad tracking."), 131 | blank=True, 132 | ) 133 | content = models.CharField( 134 | max_length=100, 135 | help_text=( 136 | "utm_content: Identifies what specifically was clicked to bring " 137 | "the user to the site, such as a banner ad or a text link." 138 | ), 139 | blank=True, 140 | ) 141 | custom_tags = models.JSONField( 142 | default=dict, 143 | null=True, 144 | blank=True, 145 | help_text=( 146 | "Dict of custom tag:value pairs as defined by the " 147 | "UTM_TRACKER_CUSTOM_TAGS setting." 148 | ), 149 | ) 150 | timestamp = models.DateTimeField( 151 | default=None, 152 | help_text="When the event occurred (if known).", 153 | blank=True, 154 | null=True, 155 | ) 156 | created_at = models.DateTimeField( 157 | default=timezone.now, help_text="When the event was recorded." 158 | ) 159 | 160 | objects = LeadSourceManager() 161 | 162 | class Meta: 163 | get_latest_by = ("timestamp",) 164 | 165 | def __str__(self) -> str: 166 | return f"Lead source {self.id} for {self.user}: {self.medium}/{self.source}" 167 | 168 | def __repr__(self) -> str: 169 | return ( 170 | f"" 172 | ) 173 | -------------------------------------------------------------------------------- /utm_tracker/request.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpRequest 2 | 3 | from .settings import CUSTOM_TAGS 4 | from .types import UtmParamsDict 5 | 6 | VALID_UTM_PARAMS = [ 7 | "utm_source", 8 | "utm_medium", 9 | "utm_campaign", 10 | "utm_term", 11 | "utm_content", 12 | ] 13 | # additional service-specific parameters 14 | VALID_AD_PARAMS = [ 15 | "gclid", # Google ad click 16 | "aclk", # Bing ad click 17 | "msclkid", # MSFT ad click (non-Bing) 18 | "fbclid", # Facebook ad click 19 | "twclid", # Twitter ad click 20 | ] 21 | 22 | 23 | def parse_qs(request: HttpRequest) -> UtmParamsDict: 24 | """ 25 | Extract 'utm_*'+ values from request querystring. 26 | 27 | NB in the case where there are multiple values for the same key, 28 | this will extract the last one. Multiple values for utm_ keys should 29 | not appear in valid querystrings, so this may have an unpredictable 30 | outcome. Look after your querystrings. 31 | 32 | This function parses three separate chunks of qs values - first it 33 | parses the utm_* tags, then it adds the ad click ids, then it adds 34 | custom tags the user wishes to stash. 35 | 36 | """ 37 | utm_keys = { 38 | str(k).lower(): str(v).lower() 39 | for k, v in request.GET.items() 40 | if k in VALID_UTM_PARAMS and v != "" 41 | } 42 | 43 | for ad_key in VALID_AD_PARAMS: 44 | if akey := request.GET.get(ad_key): 45 | # We don't want to lowercase the ad key, as they are 46 | # typically BASE64 encoded 47 | utm_keys[ad_key] = akey 48 | 49 | # add in any custom tags we have decided to stash 50 | for tag in CUSTOM_TAGS: 51 | # custom tags may be a list 52 | if val := request.GET.getlist(tag): 53 | utm_keys[tag] = ",".join(val) 54 | 55 | return utm_keys 56 | -------------------------------------------------------------------------------- /utm_tracker/session.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any, List 3 | 4 | from django.contrib.sessions.backends.base import SessionBase 5 | from django.utils.timezone import now as tz_now 6 | 7 | from .models import LeadSource 8 | from .types import UtmParamsDict 9 | 10 | SESSION_KEY_UTM_PARAMS = "utm_params" 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | def strip_timestamps(params_list: List[UtmParamsDict]) -> List[UtmParamsDict]: 16 | """ 17 | Return a copy of the session params without timestamps. 18 | 19 | The stashed params include a timestamp, which we need to pop as it 20 | will change on each request, and we don't want that. 21 | 22 | """ 23 | return [{k: v for k, v in p.items() if k != "timestamp"} for p in params_list] 24 | 25 | 26 | def stash_utm_params(session: SessionBase, params: UtmParamsDict) -> bool: 27 | """ 28 | Add new utm_params to the list of utm_params in the session. 29 | 30 | If the params dict is empty ({}), or already stashed in the session, 31 | then it's ignored. 32 | 33 | Returns True if the params are stored. 34 | 35 | """ 36 | if not params: 37 | return False 38 | 39 | session.setdefault(SESSION_KEY_UTM_PARAMS, []) 40 | if params in strip_timestamps(session[SESSION_KEY_UTM_PARAMS]): 41 | return False 42 | # cast to str so that it can be serialized in session; value is 43 | # recast to datetime automatically when the object is created. 44 | params["timestamp"] = tz_now().isoformat() 45 | session[SESSION_KEY_UTM_PARAMS].append(params) 46 | # because we are adding to a list, we are not actually changing the 47 | # session object itself, so we need to force it to be saved. 48 | session.modified = True 49 | return True 50 | 51 | 52 | def pop_utm_params(session: SessionBase) -> List[UtmParamsDict]: 53 | """Pop the list of utm_param dicts from a session.""" 54 | return session.pop(SESSION_KEY_UTM_PARAMS, []) 55 | 56 | 57 | def dump_utm_params(user: Any, session: SessionBase) -> List[LeadSource]: 58 | """ 59 | Flush utm_params from the session and save as LeadSource objects. 60 | 61 | Calling this function will remove all existing utm_params from the 62 | current session. 63 | 64 | Returns a list of LeadSource objects created - one for each utm_params 65 | dict found in the session. 66 | 67 | """ 68 | created = [] 69 | for params in pop_utm_params(session): 70 | try: 71 | created.append(LeadSource.objects.create_from_utm_params(user, params)) 72 | except ValueError as ex: 73 | msg = str(ex) 74 | logger.debug("Unable to save utm_params %s: %s", params, msg) 75 | return created 76 | -------------------------------------------------------------------------------- /utm_tracker/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | # list of custom args to extract from the querystring 4 | CUSTOM_TAGS = getattr(settings, "UTM_TRACKER_CUSTOM_TAGS", []) 5 | -------------------------------------------------------------------------------- /utm_tracker/types.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | UtmParamsDict = Dict[str, str] 4 | --------------------------------------------------------------------------------