├── plausible ├── py.typed ├── __init__.py ├── contrib │ ├── __init__.py │ └── wagtail │ │ ├── __init__.py │ │ ├── migrations │ │ ├── __init__.py │ │ └── 0001_initial.py │ │ ├── templatetags │ │ ├── __init__.py │ │ └── plausible_wagtail.py │ │ ├── apps.py │ │ ├── validators.py │ │ └── models.py ├── templatetags │ ├── __init__.py │ └── plausible.py └── utils.py ├── tests ├── __init__.py ├── templates │ ├── simple.html │ └── simple_wagtail.html ├── urls.py ├── test_utils.py ├── settings.py ├── test_wagtail.py └── test_django.py ├── pyproject.toml ├── dev-requirements.txt ├── renovate.json ├── manage.py ├── scripts └── test.sh ├── setup.cfg ├── .github └── workflows │ ├── deploy.yml │ └── ci.yml ├── LICENSE ├── setup.py ├── .gitignore └── README.md /plausible/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /plausible/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /plausible/contrib/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /plausible/contrib/wagtail/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /plausible/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /plausible/contrib/wagtail/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /plausible/contrib/wagtail/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/templates/simple.html: -------------------------------------------------------------------------------- 1 | {% load plausible %} 2 | 3 | {% plausible %} 4 | -------------------------------------------------------------------------------- /tests/templates/simple_wagtail.html: -------------------------------------------------------------------------------- 1 | {% load plausible_wagtail %} 2 | 3 | {% plausible %} 4 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.ruff] 2 | select = ["E", "F", "I", "W", "N", "B", "A", "C4", "T20", "DJ"] 3 | ignore = ["E501"] 4 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | -e .[wagtail] 2 | 3 | ruff==0.0.257 4 | mypy==1.1.1 5 | black==22.10.0 6 | pytest==7.2.2 7 | pytest-django==4.5.2 8 | pytest-cov==4.0.0 9 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base", 5 | ":disableDependencyDashboard" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from django.views.generic import TemplateView 3 | 4 | urlpatterns = [ 5 | path("simple", TemplateView.as_view(template_name="simple.html")), 6 | path("simple-wagtail", TemplateView.as_view(template_name="simple_wagtail.html")), 7 | ] 8 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /plausible/contrib/wagtail/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class WagtailPlausibleAppConfig(AppConfig): 6 | name = "plausible.contrib.wagtail" 7 | label = "wagtailplausible" 8 | 9 | verbose_name = _("Wagtail Plausible") 10 | -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | export PATH=env/bin:${PATH} 4 | 5 | set -ex 6 | 7 | pytest --verbose --cov plausible/ --cov-report term --cov-report html tests/ 8 | 9 | if hash black 2>/dev/null; 10 | then 11 | black plausible tests setup.py --check 12 | fi 13 | 14 | ruff check plausible tests setup.py 15 | 16 | mypy plausible tests setup.py 17 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [mypy] 2 | mypy_path = stubs 3 | no_implicit_optional = True 4 | warn_unused_ignores = True 5 | strict_optional = True 6 | check_untyped_defs = True 7 | ignore_missing_imports = True 8 | 9 | [isort] 10 | multi_line_output=3 11 | include_trailing_comma=True 12 | force_grid_wrap=0 13 | use_parentheses=True 14 | line_length=88 15 | 16 | [flake8] 17 | extend_ignore=E128,E501 18 | 19 | [tool:pytest] 20 | DJANGO_SETTINGS_MODULE=tests.settings 21 | -------------------------------------------------------------------------------- /plausible/contrib/wagtail/templatetags/plausible_wagtail.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | from plausible.contrib.wagtail.models import PlausibleSettings 4 | from plausible.templatetags.plausible import plausible as plausible_tag 5 | 6 | register = template.Library() 7 | 8 | 9 | @register.simple_tag(takes_context=True) 10 | def plausible(context): 11 | plausible_settings = PlausibleSettings.for_request(context["request"]) 12 | 13 | return plausible_tag( 14 | context, 15 | plausible_settings.site_domain 16 | or None, # `None` so it defaults to request hostname 17 | plausible_settings.plausible_domain, 18 | plausible_settings.script_name, 19 | ) 20 | -------------------------------------------------------------------------------- /plausible/contrib/wagtail/validators.py: -------------------------------------------------------------------------------- 1 | from os.path import basename 2 | 3 | from django.core.exceptions import ValidationError 4 | from django.utils.deconstruct import deconstructible 5 | from django.utils.translation import gettext_lazy as _ 6 | 7 | from plausible.utils import is_valid_plausible_script 8 | 9 | 10 | @deconstructible 11 | class PlausibleScriptNameValidator: 12 | message = _("Enter a valid value (eg 'plausible.js').") 13 | 14 | def __call__(self, value: str): 15 | if basename(value) != value: 16 | raise ValidationError(self.message, code="invalid") 17 | 18 | if not is_valid_plausible_script(basename(value)): 19 | raise ValidationError(self.message, code="invalid") 20 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - name: Set up Python 13 | uses: actions/setup-python@v4 14 | with: 15 | python-version: '3.11' 16 | - name: Install dependencies 17 | run: | 18 | pip install --upgrade pip wheel 19 | pip install setuptools twine 20 | - name: Build and publish 21 | env: 22 | TWINE_USERNAME: __token__ 23 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 24 | run: | 25 | python setup.py sdist bdist_wheel 26 | twine upload dist/* 27 | -------------------------------------------------------------------------------- /plausible/utils.py: -------------------------------------------------------------------------------- 1 | BASE_FILENAMES = ["plausible", "script", "analytics"] 2 | 3 | BASE_VARIANTS = { 4 | "hash", 5 | "outbound-links", 6 | "exclusions", 7 | "compat", 8 | "local", 9 | "manual", 10 | "file-downloads", 11 | "dimensions", 12 | } 13 | 14 | KNOWN_FILENAMES = ["p.js", "plausible.js"] 15 | 16 | 17 | def is_valid_plausible_script(filename: str) -> bool: 18 | """ 19 | Validate a script name against allowed values 20 | 21 | 22 | See also https://plausible.io/docs/script-extensions 23 | """ 24 | if filename in KNOWN_FILENAMES: 25 | return True 26 | 27 | try: 28 | base_name, *variants, extension = filename.split(".") 29 | except ValueError: 30 | return False 31 | 32 | if extension != "js" or base_name not in BASE_FILENAMES: 33 | return False 34 | 35 | return BASE_VARIANTS.issuperset(variants) 36 | -------------------------------------------------------------------------------- /plausible/contrib/wagtail/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from wagtail.contrib.settings.models import register_setting 3 | 4 | # FIXME: Remove after support for Wagtail 5.0 is dropped 5 | try: 6 | from wagtail.contrib.settings.models import BaseSiteSetting 7 | except ImportError: 8 | # Prior to Wagtail 3.0, the only setting available was based on the Site 9 | from wagtail.contrib.settings.models import BaseSetting as BaseSiteSetting 10 | 11 | from .validators import PlausibleScriptNameValidator 12 | 13 | 14 | @register_setting 15 | class PlausibleSettings(BaseSiteSetting): 16 | site_domain = models.CharField(max_length=255, null=False, blank=True) 17 | plausible_domain = models.CharField( 18 | max_length=255, 19 | null=False, 20 | blank=True, 21 | default="plausible.io", 22 | ) 23 | script_name = models.CharField( 24 | max_length=255, 25 | validators=[PlausibleScriptNameValidator()], 26 | default="plausible.js", 27 | ) 28 | 29 | class Meta: 30 | verbose_name = "Plausible Analytics" 31 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from itertools import combinations 2 | 3 | import pytest 4 | 5 | from plausible import utils 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "script_name", 10 | utils.KNOWN_FILENAMES, 11 | ) 12 | def test_known_filenames(script_name): 13 | assert utils.is_valid_plausible_script(script_name) 14 | 15 | 16 | @pytest.mark.parametrize( 17 | "script_name", 18 | [ 19 | "left-pad.js", 20 | "plausible.io", 21 | "plausible..js", 22 | "plausible.nothing.js", 23 | "plausible.hash", 24 | "hash.js", 25 | "file", 26 | "/plausible.js", 27 | ], 28 | ) 29 | def test_invalid_filenames(script_name): 30 | assert not utils.is_valid_plausible_script(script_name) 31 | 32 | 33 | @pytest.mark.parametrize( 34 | "variant", 35 | [ 36 | ".".join(sorted(v)) 37 | for n in range(1, len(utils.BASE_VARIANTS)) 38 | for v in combinations(utils.BASE_VARIANTS, n) 39 | ], 40 | ) 41 | @pytest.mark.parametrize( 42 | "base_filenames", 43 | utils.BASE_FILENAMES, 44 | ) 45 | def test_variants(variant, base_filenames): 46 | assert utils.is_valid_plausible_script(f"{base_filenames}.{variant}.js") 47 | -------------------------------------------------------------------------------- /plausible/templatetags/plausible.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.conf import settings 3 | from django.forms.utils import flatatt 4 | from django.utils.html import escape 5 | from django.utils.safestring import mark_safe 6 | 7 | from plausible.utils import is_valid_plausible_script 8 | 9 | register = template.Library() 10 | 11 | 12 | @register.simple_tag(takes_context=True) 13 | def plausible(context, site_domain=None, plausible_domain=None, script_name=None): 14 | request = context["request"] 15 | 16 | if plausible_domain is None: 17 | plausible_domain = getattr(settings, "PLAUSIBLE_DOMAIN", "plausible.io") 18 | if script_name is None: 19 | script_name = getattr(settings, "PLAUSIBLE_SCRIPT_NAME", "plausible.js") 20 | if site_domain is None: 21 | site_domain = escape(request.get_host()) # In case of XSS 22 | 23 | if not is_valid_plausible_script(script_name): 24 | raise ValueError(f"Invalid plausible script name: {script_name}") 25 | 26 | attrs = { 27 | "defer": True, 28 | "data-domain": site_domain, 29 | "src": f"https://{plausible_domain}/js/{script_name}", 30 | } 31 | 32 | # Add a target id for use with compat script 33 | if "compat" in script_name: 34 | attrs["id"] = "plausible" 35 | 36 | return mark_safe(f"") 37 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [pull_request, push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] 11 | django-version: ["3.2", "4.0", "4.1", "4.2"] 12 | wagtail-version: ["4.0", "4.1", "4.2", "5.0"] 13 | exclude: 14 | - django-version: "4.2" 15 | python-version: "3.7" 16 | - django-version: "4.1" 17 | python-version: "3.7" 18 | - django-version: "4.0" 19 | python-version: "3.7" 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v4 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | - uses: actions/cache@v3 27 | with: 28 | path: ~/.cache/pip 29 | key: ${{ runner.os }}-pip-${{ hashFiles('setup.py') }}-${{ hashFiles('dev-requirements.txt') }} 30 | - name: Install dependencies 31 | run: | 32 | pip install --upgrade pip 33 | pip install -r dev-requirements.txt 34 | pip install -U Django==${{ matrix.django-version }} 35 | pip install -U Wagtail==${{ matrix.wagtail-version }} 36 | - name: Run tests 37 | run: scripts/test.sh 38 | - name: Build package 39 | run: python setup.py clean sdist 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2021, Jake Howard 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 4 | 5 | ALLOWED_HOSTS = ["*"] 6 | 7 | INSTALLED_APPS = [ 8 | "django.contrib.staticfiles", 9 | "plausible", 10 | "tests", 11 | "django.contrib.admin", 12 | "django.contrib.contenttypes", 13 | # Wagtail stuff 14 | "plausible.contrib.wagtail", 15 | "django.contrib.auth", 16 | "django.contrib.messages", 17 | "wagtail.contrib.settings", 18 | "wagtail.contrib.redirects", 19 | "wagtail.sites", 20 | "wagtail.users", 21 | "wagtail.admin", 22 | "wagtail", 23 | ] 24 | 25 | TEMPLATES = [ 26 | { 27 | "BACKEND": "django.template.backends.django.DjangoTemplates", 28 | "DIRS": [], 29 | "APP_DIRS": True, 30 | "OPTIONS": { 31 | "context_processors": [ 32 | "django.template.context_processors.debug", 33 | "django.template.context_processors.request", 34 | "django.contrib.auth.context_processors.auth", 35 | "django.contrib.messages.context_processors.messages", 36 | ] 37 | }, 38 | }, 39 | ] 40 | 41 | SECRET_KEY = "abcde12345" 42 | 43 | ROOT_URLCONF = "tests.urls" 44 | 45 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 46 | 47 | MIDDLEWARE = [ 48 | "django.contrib.sessions.middleware.SessionMiddleware", 49 | "django.contrib.auth.middleware.AuthenticationMiddleware", 50 | "django.contrib.messages.middleware.MessageMiddleware", 51 | ] 52 | 53 | DATABASES = { 54 | "default": { 55 | "ENGINE": "django.db.backends.sqlite3", 56 | "NAME": ":memory:", 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | 3 | with open("README.md") as f: 4 | readme = f.read() 5 | 6 | setup( 7 | name="django-plausible", 8 | version="0.5.0", 9 | url="https://github.com/RealOrangeOne/django-plausible", 10 | author="Jake Howard", 11 | description=" Django module to provide easy Plausible integration, with Wagtail support", 12 | long_description=readme, 13 | long_description_content_type="text/markdown", 14 | license="BSD", 15 | packages=find_packages(include=["plausible*"]), 16 | package_data={"plausible": ["py.typed"]}, 17 | install_requires=["Django>=3.2"], 18 | python_requires=">=3.7", 19 | extras_require={"wagtail": ["wagtail>=4.0"]}, 20 | keywords="django plausible wagtail analytics", 21 | classifiers=[ 22 | "Environment :: Web Environment", 23 | "Framework :: Wagtail", 24 | "Framework :: Wagtail :: 4", 25 | "Framework :: Wagtail :: 5", 26 | "Framework :: Django", 27 | "Framework :: Django :: 3.2", 28 | "Framework :: Django :: 4.0", 29 | "Framework :: Django :: 4.1", 30 | "Framework :: Django :: 4.2", 31 | "Intended Audience :: Developers", 32 | "License :: OSI Approved :: BSD License", 33 | "Operating System :: OS Independent", 34 | "Programming Language :: Python :: 3.7", 35 | "Programming Language :: Python :: 3.8", 36 | "Programming Language :: Python :: 3.9", 37 | "Programming Language :: Python :: 3.10", 38 | "Programming Language :: Python :: 3.11", 39 | "Programming Language :: Python :: 3", 40 | "Programming Language :: Python", 41 | "Topic :: Internet :: WWW/HTTP", 42 | "Topic :: Software Development", 43 | "Typing :: Typed", 44 | ], 45 | ) 46 | -------------------------------------------------------------------------------- /plausible/contrib/wagtail/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.7 on 2021-09-05 08:47 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | import plausible.contrib.wagtail.validators 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | ("wagtailcore", "0062_comment_models_and_pagesubscription"), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name="PlausibleSettings", 20 | fields=[ 21 | ( 22 | "id", 23 | models.AutoField( 24 | auto_created=True, 25 | primary_key=True, 26 | serialize=False, 27 | verbose_name="ID", 28 | ), 29 | ), 30 | ( 31 | "site_domain", 32 | models.CharField( 33 | blank=True, 34 | max_length=255, 35 | ), 36 | ), 37 | ( 38 | "plausible_domain", 39 | models.CharField( 40 | blank=True, 41 | default="plausible.io", 42 | max_length=255, 43 | ), 44 | ), 45 | ( 46 | "script_name", 47 | models.CharField( 48 | default="plausible.js", 49 | max_length=255, 50 | validators=[ 51 | plausible.contrib.wagtail.validators.PlausibleScriptNameValidator() 52 | ], 53 | ), 54 | ), 55 | ( 56 | "site", 57 | models.OneToOneField( 58 | editable=False, 59 | on_delete=django.db.models.deletion.CASCADE, 60 | to="wagtailcore.site", 61 | ), 62 | ), 63 | ], 64 | options={ 65 | "verbose_name": "Plausible Analytics", 66 | }, 67 | ), 68 | ] 69 | -------------------------------------------------------------------------------- /.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 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /tests/test_wagtail.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.core.exceptions import ValidationError 3 | from pytest_django.asserts import assertInHTML 4 | from wagtail.models import Site 5 | 6 | from plausible.contrib.wagtail.models import PlausibleSettings 7 | from plausible.contrib.wagtail.validators import PlausibleScriptNameValidator 8 | 9 | 10 | @pytest.fixture 11 | def plausible_settings(): 12 | return PlausibleSettings.for_site(Site.objects.get()) 13 | 14 | 15 | @pytest.mark.django_db 16 | def test_simple_template_view(client): 17 | response = client.get("/simple-wagtail", HTTP_HOST="example.com") 18 | assert response.status_code == 200 19 | assertInHTML( 20 | '', 21 | response.content.decode(), 22 | ) 23 | 24 | 25 | @pytest.mark.django_db 26 | def test_hostname_from_settings(client, plausible_settings): 27 | plausible_settings.site_domain = "from-settings.com" 28 | plausible_settings.save() 29 | response = client.get("/simple-wagtail") 30 | assert response.status_code == 200 31 | assertInHTML( 32 | '', 33 | response.content.decode(), 34 | ) 35 | 36 | 37 | @pytest.mark.django_db 38 | def test_script_name_from_settings(client, plausible_settings): 39 | plausible_settings.script_name = "plausible.hash.js" 40 | plausible_settings.save() 41 | response = client.get("/simple-wagtail") 42 | assert response.status_code == 200 43 | assertInHTML( 44 | '', 45 | response.content.decode(), 46 | ) 47 | 48 | 49 | @pytest.mark.django_db 50 | def test_plausible_domain_from_settings(client, plausible_settings): 51 | plausible_settings.plausible_domain = "my-plausible.com" 52 | plausible_settings.save() 53 | response = client.get("/simple-wagtail") 54 | assert response.status_code == 200 55 | assertInHTML( 56 | '', 57 | response.content.decode(), 58 | ) 59 | 60 | 61 | @pytest.mark.parametrize( 62 | "script_name", ["plausible.js", "plausible.hash.js", "plausible.hash.compat.js"] 63 | ) 64 | def test_validates_script_name(script_name): 65 | PlausibleScriptNameValidator()(script_name) 66 | 67 | 68 | @pytest.mark.parametrize("script_name", ["/js/plausible.js", "left-pad.js"]) 69 | def test_invalid_script_name(script_name): 70 | with pytest.raises(ValidationError): 71 | PlausibleScriptNameValidator()(script_name) 72 | 73 | 74 | def test_app_label(): 75 | assert PlausibleSettings._meta.app_label == "wagtailplausible" 76 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-plausible 2 | 3 | ![CI](https://github.com/RealOrangeOne/django-plausible/workflows/CI/badge.svg) 4 | ![PyPI](https://img.shields.io/pypi/v/django-plausible.svg) 5 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-plausible.svg) 6 | ![PyPI - Status](https://img.shields.io/pypi/status/django-plausible.svg) 7 | ![PyPI - License](https://img.shields.io/pypi/l/django-plausible.svg) 8 | 9 | 10 | Django module to provide easy [Plausible](https://plausible.io/) integration, with [Wagtail](https://wagtail.io/) support. 11 | 12 | ## Installation 13 | 14 | ``` 15 | pip install django-plausible 16 | ``` 17 | 18 | Then simply add `plausible` to `INSTALLED_APPS`. 19 | 20 | ## Usage 21 | 22 | `django-plausible` provides a `plausible` template tag, which can be used to output the required [script tag](https://plausible.io/docs/plausible-script) for Plausible. 23 | 24 | ```html 25 | {% load plausible %} 26 | 27 | {% plausible %} 28 | ``` 29 | 30 | Will result in: 31 | 32 | ```html 33 | 34 | ``` 35 | 36 | ### Configuration 37 | 38 | Configuration can be changed either in `settings.py`, or when calling the `plausible` template tag: 39 | 40 | - `PLAUSIBLE_DOMAIN`: The domain Plausible is running on (defaults to `plausible.io`) 41 | - `PLAUSIBLE_SCRIPT_NAME`: The name of the script to use (defaults to `plausible.js`). See [script extensions](https://plausible.io/docs/script-extensions) for available options. 42 | 43 | These settings will affect all calls to the `plausible` template tag. To override it at call time, you can also pass them into the template tag: 44 | 45 | ``` 46 | {% plausible plausible_domain="my-plausible.com" script_name="plausible.hash.js" %} 47 | ``` 48 | 49 | By default, the domain (`data-domain`) used will be based on the request's hostname (using [`request.get_host()`](https://docs.djangoproject.com/en/dev/ref/request-response/#django.http.HttpRequest.get_host)). To override this, pass `site_domain` to the template tag. 50 | 51 | If the ["compat" script](https://plausible.io/docs/script-extensions#plausiblecompatjs) is used, `django-plausible` will automatically add the required `id` to the `script` tag. It is excluded by default to help hide Plausible's presence. 52 | 53 | ## Usage with Wagtail 54 | 55 | Additionally, `django-plausible` provides an (optional) deep integration with [Wagtail](https://wagtail.io), allowing configuration through the Wagtail admin. To enable this, additionally add `plausible.contrib.wagtail` to `INSTALLED_APPS`. 56 | 57 | Configuration is done through the "Plausible Analytics" [setting](https://docs.wagtail.io/en/stable/reference/contrib/settings.html#settings): 58 | 59 | - `site_domain`: the value for `data-domain`. If left blank (the default), the request's hostname will be used (as above), **not** the site hostname. 60 | - `plausible_domain`: The domain Plausible is running on (as above) 61 | - `script_name`: The name of the script to use (as above) 62 | 63 | To access the template tag, load `plausible_wagtail`, rather than `plausible`. The template tag itself is still `plausible`. Note that unlike the Django variant, the Wagtail template tag doesn't allow options to be passed. 64 | 65 | ```html 66 | {% load plausible_wagtail %} 67 | 68 | {% plausible %} 69 | ``` 70 | -------------------------------------------------------------------------------- /tests/test_django.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.template import RequestContext, Template 3 | from pytest_django.asserts import assertInHTML 4 | 5 | 6 | def _render_string(template, context=None) -> str: 7 | return Template(template).render(context) 8 | 9 | 10 | def test_simple_template_view(client): 11 | response = client.get("/simple", HTTP_HOST="example.com") 12 | assert response.status_code == 200 13 | assertInHTML( 14 | '', 15 | response.content.decode(), 16 | ) 17 | 18 | 19 | def test_custom_hostname(rf): 20 | request = rf.get("/", HTTP_HOST="example.com") 21 | rendered = _render_string( 22 | '{% load plausible %}{% plausible site_domain="custom.com" %}', 23 | context=RequestContext(request), 24 | ) 25 | assertInHTML( 26 | '', 27 | rendered, 28 | ) 29 | 30 | 31 | def test_custom_domain(rf): 32 | request = rf.get("/") 33 | rendered = _render_string( 34 | '{% load plausible %}{% plausible plausible_domain="my-plausible.com" %}', 35 | context=RequestContext(request), 36 | ) 37 | assertInHTML( 38 | '', 39 | rendered, 40 | ) 41 | 42 | 43 | def test_custom_script_name(rf): 44 | request = rf.get("/") 45 | rendered = _render_string( 46 | '{% load plausible %}{% plausible script_name="plausible.hash.js" %}', 47 | context=RequestContext(request), 48 | ) 49 | assertInHTML( 50 | '', 51 | rendered, 52 | ) 53 | 54 | 55 | def test_compat_script_has_id(rf): 56 | request = rf.get("/") 57 | rendered = _render_string( 58 | '{% load plausible %}{% plausible script_name="plausible.compat.js" %}', 59 | context=RequestContext(request), 60 | ) 61 | assertInHTML( 62 | '', 63 | rendered, 64 | ) 65 | 66 | 67 | def test_custom_domain_from_settings(settings, rf): 68 | settings.PLAUSIBLE_DOMAIN = "my-plausible.com" 69 | request = rf.get("/") 70 | rendered = _render_string( 71 | "{% load plausible %}{% plausible %}", 72 | context=RequestContext(request), 73 | ) 74 | assertInHTML( 75 | '', 76 | rendered, 77 | ) 78 | 79 | 80 | def test_custom_script_name_from_settings(settings, rf): 81 | settings.PLAUSIBLE_SCRIPT_NAME = "plausible.hash.js" 82 | request = rf.get("/") 83 | rendered = _render_string( 84 | "{% load plausible %}{% plausible %}", 85 | context=RequestContext(request), 86 | ) 87 | assertInHTML( 88 | '', 89 | rendered, 90 | ) 91 | 92 | 93 | def test_invalid_script_name(settings, rf): 94 | settings.PLAUSIBLE_SCRIPT_NAME = "left-pad.js" 95 | request = rf.get("/") 96 | with pytest.raises(ValueError): 97 | _render_string( 98 | "{% load plausible %}{% plausible %}", 99 | context=RequestContext(request), 100 | ) 101 | --------------------------------------------------------------------------------