diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index a86701e..0000000 --- a/.coveragerc +++ /dev/null @@ -1,24 +0,0 @@ -[run] -branch = True -include = wagtail_ab_testing/* -omit = */migrations/*,*/tests/* - -[report] -# Regexes for lines to exclude from consideration -exclude_lines = - # Have to re-enable the standard pragma - pragma: no cover - - # Don't complain about missing debug-only code: - def __repr__ - if self\.debug - - # Don't complain if tests don't hit defensive assertion code: - raise AssertionError - raise NotImplementedError - - # Don't complain if non-runnable code isn't run: - if 0: - if __name__ == .__main__.: - -ignore_errors = True diff --git a/.github/report_nightly_build_failure.py b/.github/report_nightly_build_failure.py index 5b73a40..17ca2f1 100644 --- a/.github/report_nightly_build_failure.py +++ b/.github/report_nightly_build_failure.py @@ -3,11 +3,11 @@ This reports an error to the #nightly-build-failures Slack channel. """ + import os import requests - if "SLACK_WEBHOOK_URL" in os.environ: # https://docs.github.com/en/free-pro-team@latest/actions/reference/environment-variables#default-environment-variables repository = os.environ["GITHUB_REPOSITORY"] @@ -27,4 +27,4 @@ else: print( "Unable to report to #nightly-build-failures slack channel because SLACK_WEBHOOK_URL is not set" - ) \ No newline at end of file + ) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e77ed32..8609d62 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,7 +30,19 @@ jobs: run: | npm run build - qa_python: + lint_python: + name: Python Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_LATEST }} + - uses: pre-commit/action@v3.0.1 + + test_python: services: postgres: image: postgres:16 @@ -49,14 +61,10 @@ jobs: continue-on-error: ${{ matrix.experimental }} strategy: matrix: - python: ["3.8", "3.9", "3.10", "3.11", "3.12"] + python: ["3.9", "3.10", "3.11", "3.12"] experimental: [false] toxenv: ["py"] include: - # Linting - - python: "3.12" - toxenv: flake8 - experimental: false # Future Wagtail release from main branch (allowed to fail) - python: "3.12" toxenv: wagtailmain-sqlite @@ -94,7 +102,7 @@ jobs: env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - # This step runs only for jobs in the include matrix and covers linting + # This step runs only for jobs in the include matrix - name: Run tox targets for Python ${{ matrix.python }} (${{ matrix.toxenv }}) if: ${{ matrix.toxenv != 'py' }} run: | diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..6d67d5d --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,9 @@ +default_language_version: + python: python3 +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: 'v0.6.5' + hooks: + - id: ruff + args: [--fix, --exit-non-zero-on-fix] + - id: ruff-format diff --git a/CHANGELOG.md b/CHANGELOG.md index d0a65b3..93e2ab2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.11] - [Unreleased] +- [Add Wagtail 6.2 support](https://github.com/wagtail-nest/wagtail-ab-testing/pull/87) - [Drop Django 3.2 support, Add Wagtail 6.1 support](https://github.com/wagtail-nest/wagtail-ab-testing/pull/83) +- [Drop support for Python 3.8 in preparation for its upcoming end of life](https://github.com/wagtail-nest/wagtail-ab-testing/pull/87) +- [Fix page chooser not working](https://github.com/wagtail-nest/wagtail-ab-testing/pull/85) +- [Fix a potential race condition during increment of AB test statistics when using any database besides PostgreSQL](https://github.com/wagtail-nest/wagtail-ab-testing/pull/87) + +**Maintenance** + +- [Resolve several deprecation warnings in the codebase](https://github.com/wagtail-nest/wagtail-ab-testing/pull/87) +- [Switch from `setup.py` to `pyproject.toml` for package metadata](https://github.com/wagtail-nest/wagtail-ab-testing/pull/87) +- [Format the codebase with `ruff`](https://github.com/wagtail-nest/wagtail-ab-testing/pull/87) ## [0.10] - 2024-03-22 diff --git a/MANIFEST.in b/MANIFEST.in index 5a8a9d6..3aec1da 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,10 +1,13 @@ -include LICENSE *.rst *.txt *.md -graft wagtail_ab_testing +exclude * +recursive-exclude * * + +include CHANGELOG.md +include LICENSE +include MANIFEST.in +include README.md +include pyproject.toml + +recursive-include wagtail_ab_testing/ *.html *.js *.py *.txt -prune wagtail_ab_testing.egg-info prune wagtail_ab_testing/test prune wagtail_ab_testing/static_src - -global-exclude __pycache__ -global-exclude *.py[co] -global-exclude *.gitkeep *.gitignore diff --git a/README.md b/README.md index aff5b3b..03fb288 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,25 @@ # Wagtail A/B Testing -[![Version](https://img.shields.io/pypi/v/wagtail-ab-testing.svg?style=flat)](https://pypi.python.org/pypi/wagtail-ab-testing/) -[![License](https://img.shields.io/badge/license-BSD-blue.svg?style=flat)](https://opensource.org/licenses/BSD-3-Clause) -[![Test & Lint](https://github.com/wagtail-nest/wagtail-ab-testing/actions/workflows/test.yml/badge.svg)](https://github.com/wagtail-nest/wagtail-ab-testing/actions/workflows/test.yml) +[![License: BSD-3-Clause](https://img.shields.io/badge/license-BSD-blue.svg?style=flat)](https://opensource.org/licenses/BSD-3-Clause) +[![Build status](https://github.com/wagtail-nest/wagtail-ab-testing/actions/workflows/test.yml/badge.svg)](https://github.com/wagtail-nest/wagtail-ab-testing/actions/workflows/test.yml) [![codecov](https://img.shields.io/codecov/c/github/wagtail-nest/wagtail-ab-testing?style=flat)](https://codecov.io/gh/wagtail-nest/wagtail-ab-testing) +[![Version](https://img.shields.io/pypi/v/wagtail-ab-testing.svg?style=flat)](https://pypi.python.org/pypi/wagtail-ab-testing/) +[![Monthly downloads](https://img.shields.io/pypi/dm/wagtail-ab-testing.svg?logo=Downloads)](https://pypi.python.org/pypi/wagtail-ab-testing/) Wagtail A/B Testing is an A/B testing package for Wagtail that allows users to create and manage A/B tests on pages through the Wagtail admin. Key features: - - Create an A/B test on any page from within Wagtail - - Tests using page revisions (no need to create separate pages for the variants) - - It prevents users from editing the page while a test is in progress - - Calculates confidence using a Pearson's chi-squared test +- Create an A/B test on any page from within Wagtail +- Tests using page revisions (no need to create separate pages for the variants) +- It prevents users from editing the page while a test is in progress +- Calculates confidence using a Pearson's chi-squared test [Changelog](https://github.com/torchbox/wagtail-ab-testing/blob/main/CHANGELOG.md) ## Usage -Wagtail A/B Testing works with Django 3.2+, Wagtail 4.1+ on Python 3.8+ environments. +Wagtail A/B Testing works with Django 3.2+, Wagtail 5.2+ on Python 3.9+ environments. ### Creating an A/B test @@ -52,11 +53,11 @@ The results from this A/B test remain accessible under the A/B testing tab or fr ## Installation -Firstly, install the ``wagtail-ab-testing`` package from PyPI: +Firstly, install the `wagtail-ab-testing` package from PyPI: pip install wagtail-ab-testing -Then add it into ``INSTALLED_APPS``: +Then add it into `INSTALLED_APPS`: ```python INSTALLED_APPS = [ @@ -95,7 +96,7 @@ Finally, add the tracking script to your base HTML template: Out of the box, Wagtail A/B testing provides a "Visit page" goal event type which you can use to track when users visit a goal page. It also supports custom goal types, which can be used for tracking other events such as making a purchase, submitting a form, or clicking a link. -To implement a custom goal event type, firstly register your type using the ``register_ab_testing_event_types`` hook, this would +To implement a custom goal event type, firstly register your type using the `register_ab_testing_event_types` hook, this would add your goal type to the list of options shown to users when they create A/B tests: ```python @@ -125,7 +126,7 @@ def register_submit_form_event_type(): ``` -Next, you need to tell Wagtail A/B testing whenever a user triggers the goal. This can be done by calling ``wagtailAbTesting.triggerEvent()`` +Next, you need to tell Wagtail A/B testing whenever a user triggers the goal. This can be done by calling `wagtailAbTesting.triggerEvent()` in the browser: ```javascript @@ -134,13 +135,13 @@ if (window.wagtailAbTesting) { } ``` -The JavaScript library tracks A/B tests using ``localStorage``, so this will only call the server if the user is participating in an A/B test with the provided goal type and the current page is the goal page. +The JavaScript library tracks A/B tests using `localStorage`, so this will only call the server if the user is participating in an A/B test with the provided goal type and the current page is the goal page. #### Example: Adding a "Submit form" event type -We will add a "Submit form" event type for a ``ContactUsFormPage`` page type in this example. +We will add a "Submit form" event type for a `ContactUsFormPage` page type in this example. -Firstly, we need to register the event type. To do this, implement a handler for the ``register_ab_testing_event_types`` hook in your app: +Firstly, we need to register the event type. To do this, implement a handler for the `register_ab_testing_event_types` hook in your app: ```python # myapp/wagtail_hooks.py @@ -202,68 +203,72 @@ Then set up a Cloudflare Worker based on the following JavaScript: const ENFORCE_HTTPS = true; export default { - async fetch(request, env, ctx) { - const url = new URL(request.url) + async fetch(request, env, ctx) { + const url = new URL(request.url); - // Set this to the domain name of your backend server - const WAGTAIL_DOMAIN = env.WAGTAIL_DOMAIN; + // Set this to the domain name of your backend server + const WAGTAIL_DOMAIN = env.WAGTAIL_DOMAIN; - // This should match the token on your Django settings - const WAGTAIL_AB_TESTING_WORKER_TOKEN = env.WAGTAIL_AB_TESTING_WORKER_TOKEN; + // This should match the token on your Django settings + const WAGTAIL_AB_TESTING_WORKER_TOKEN = + env.WAGTAIL_AB_TESTING_WORKER_TOKEN; - if (url.protocol == 'http:' && ENFORCE_HTTPS) { - url.protocol = 'https:'; - return Response.redirect(url, 301); - } + if (url.protocol == 'http:' && ENFORCE_HTTPS) { + url.protocol = 'https:'; + return Response.redirect(url, 301); + } - if (request.method === 'GET') { - const newRequest = new Request(request, { - headers: { - ...request.headers, - 'Authorization': 'Token ' + WAGTAIL_AB_TESTING_WORKER_TOKEN, - 'X-Requested-With': 'WagtailAbTestingWorker' - } - }); - - url.hostname = WAGTAIL_DOMAIN; - response = await fetch(url.toString(), newRequest); - - // If there is a test running at the URL, the worker would return - // a JSON response containing both versions of the page. Also, it - // returns the test ID in the X-WagtailAbTesting-Test header. - const testId = response.headers.get('X-WagtailAbTesting-Test'); - if (testId) { - // Participants of a test would have a cookie that tells us which - // version of the page being tested on that they should see - // If they don't have this cookie, serve a random version - const versionCookieName = `abtesting-${testId}-version`; - const cookie = request.headers.get('cookie'); - let version; - if (cookie && cookie.includes(`${versionCookieName}=control`)) { - version = 'control'; - } else if (cookie && cookie.includes(`${versionCookieName}=variant`)) { - version = 'variant'; - } else if (Math.random() < 0.5) { - version = 'control'; + if (request.method === 'GET') { + const newRequest = new Request(request, { + headers: { + ...request.headers, + Authorization: 'Token ' + WAGTAIL_AB_TESTING_WORKER_TOKEN, + 'X-Requested-With': 'WagtailAbTestingWorker', + }, + }); + + url.hostname = WAGTAIL_DOMAIN; + response = await fetch(url.toString(), newRequest); + + // If there is a test running at the URL, the worker would return + // a JSON response containing both versions of the page. Also, it + // returns the test ID in the X-WagtailAbTesting-Test header. + const testId = response.headers.get('X-WagtailAbTesting-Test'); + if (testId) { + // Participants of a test would have a cookie that tells us which + // version of the page being tested on that they should see + // If they don't have this cookie, serve a random version + const versionCookieName = `abtesting-${testId}-version`; + const cookie = request.headers.get('cookie'); + let version; + if (cookie && cookie.includes(`${versionCookieName}=control`)) { + version = 'control'; + } else if ( + cookie && + cookie.includes(`${versionCookieName}=variant`) + ) { + version = 'variant'; + } else if (Math.random() < 0.5) { + version = 'control'; + } else { + version = 'variant'; + } + + return response.json().then((json) => { + return new Response(json[version], { + headers: { + ...response.headers, + 'Content-Type': 'text/html', + }, + }); + }); + } + + return response; } else { - version = 'variant'; + return await fetch(url.toString(), request); } - - return response.json().then(json => { - return new Response(json[version], { - headers: { - ...response.headers, - 'Content-Type': 'text/html' - } - }); - }); - } - - return response; - } else { - return await fetch(url.toString(), request); - } - }, + }, }; ``` @@ -281,7 +286,7 @@ npx wrangler init Follow the CLI prompt until it generates a project for you, then add the JS script above to `src/index.js`. -Add a ``WAGTAIL_AB_TESTING_WORKER_TOKEN`` variable to the worker, giving it the same token value that you generated earlier. Make sure to also setup a ``WAGTAIL_DOMAIN`` variable with the value of the domain where your website is hosted (e.g. `"www.mysite.com"`). +Add a `WAGTAIL_AB_TESTING_WORKER_TOKEN` variable to the worker, giving it the same token value that you generated earlier. Make sure to also setup a `WAGTAIL_DOMAIN` variable with the value of the domain where your website is hosted (e.g. `"www.mysite.com"`). Finally, add a route into Cloudflare so that it routes all traffic through this worker. @@ -298,7 +303,6 @@ cd wagtail-ab-testing With your preferred virtualenv activated, install testing dependencies: - ```shell python -m pip install -e .[testing] ``` @@ -308,3 +312,21 @@ python -m pip install -e .[testing] ```shell python testmanage.py test ``` + +### Formatting and linting + +We are using `pre-commit` to ensure that all code is formatted and linted before committing. To install the pre-commit hooks, run: + +```shell +pre-commit install +``` + +The pre-commit hooks will run automatically before each commit. Or you can run them manually with: + +```shell +pre-commit run --all-files +``` + +## Credits + +`wagtail-ab-testing` was originally created by [Karl Hobley](https://github.com/kaedroho) diff --git a/package-lock.json b/package-lock.json index fb6e313..e5b4936 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2608,31 +2608,12 @@ "@types/d3-selection": "^1" } }, - "node_modules/@types/eslint": { - "version": "8.56.3", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.3.tgz", - "integrity": "sha512-PvSf1wfv2wJpVIFUMSb+i4PvqNYkB9Rkp9ZDO3oaWzq4SKhsQk4mrMBr3ZH06I0hKrVGLBacmgl8JM4WVjb9dg==", - "dev": true, - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "dev": true, - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/geojson": { "version": "7946.0.14", @@ -3029,10 +3010,11 @@ "dev": true }, "node_modules/@webassemblyjs/ast": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.6.tgz", - "integrity": "sha512-IN1xI7PwOvLPgjcf180gC1bqn3q/QaOCwYUahIOhbYUu8KA/3tw2RT/T0Gidi1l7Hhj5D/INhJxiICObqpMu4Q==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.12.1.tgz", + "integrity": "sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==", "dev": true, + "license": "MIT", "dependencies": { "@webassemblyjs/helper-numbers": "1.11.6", "@webassemblyjs/helper-wasm-bytecode": "1.11.6" @@ -3042,25 +3024,29 @@ "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz", "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@webassemblyjs/helper-api-error": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz", "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.6.tgz", - "integrity": "sha512-z3nFzdcp1mb8nEOFFk8DrYLpHvhKC3grJD2ardfKOzmbmJvEf/tPIqCY+sNcwZIY8ZD7IkB2l7/pqhUhqm7hLA==", - "dev": true + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.12.1.tgz", + "integrity": "sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==", + "dev": true, + "license": "MIT" }, "node_modules/@webassemblyjs/helper-numbers": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz", "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==", "dev": true, + "license": "MIT", "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.11.6", "@webassemblyjs/helper-api-error": "1.11.6", @@ -3071,18 +3057,20 @@ "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz", "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.6.tgz", - "integrity": "sha512-LPpZbSOwTpEC2cgn4hTydySy1Ke+XEu+ETXuoyvuyezHO3Kjdu90KK95Sh9xTbmjrCsUwvWwCOQQNta37VrS9g==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.12.1.tgz", + "integrity": "sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g==", "dev": true, + "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.11.6", - "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-buffer": "1.12.1", "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/wasm-gen": "1.11.6" + "@webassemblyjs/wasm-gen": "1.12.1" } }, "node_modules/@webassemblyjs/ieee754": { @@ -3090,6 +3078,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz", "integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==", "dev": true, + "license": "MIT", "dependencies": { "@xtuc/ieee754": "^1.2.0" } @@ -3099,6 +3088,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.6.tgz", "integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==", "dev": true, + "license": "Apache-2.0", "dependencies": { "@xtuc/long": "4.2.2" } @@ -3107,31 +3097,34 @@ "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.6.tgz", "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.6.tgz", - "integrity": "sha512-Ybn2I6fnfIGuCR+Faaz7YcvtBKxvoLV3Lebn1tM4o/IAJzmi9AWYIPWpyBfU8cC+JxAO57bk4+zdsTjJR+VTOw==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.12.1.tgz", + "integrity": "sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g==", "dev": true, + "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.11.6", - "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-buffer": "1.12.1", "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/helper-wasm-section": "1.11.6", - "@webassemblyjs/wasm-gen": "1.11.6", - "@webassemblyjs/wasm-opt": "1.11.6", - "@webassemblyjs/wasm-parser": "1.11.6", - "@webassemblyjs/wast-printer": "1.11.6" + "@webassemblyjs/helper-wasm-section": "1.12.1", + "@webassemblyjs/wasm-gen": "1.12.1", + "@webassemblyjs/wasm-opt": "1.12.1", + "@webassemblyjs/wasm-parser": "1.12.1", + "@webassemblyjs/wast-printer": "1.12.1" } }, "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.6.tgz", - "integrity": "sha512-3XOqkZP/y6B4F0PBAXvI1/bky7GryoogUtfwExeP/v7Nzwo1QLcq5oQmpKlftZLbT+ERUOAZVQjuNVak6UXjPA==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.12.1.tgz", + "integrity": "sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w==", "dev": true, + "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-wasm-bytecode": "1.11.6", "@webassemblyjs/ieee754": "1.11.6", "@webassemblyjs/leb128": "1.11.6", @@ -3139,24 +3132,26 @@ } }, "node_modules/@webassemblyjs/wasm-opt": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.6.tgz", - "integrity": "sha512-cOrKuLRE7PCe6AsOVl7WasYf3wbSo4CeOk6PkrjS7g57MFfVUF9u6ysQBBODX0LdgSvQqRiGz3CXvIDKcPNy4g==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.12.1.tgz", + "integrity": "sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg==", "dev": true, + "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.11.6", - "@webassemblyjs/helper-buffer": "1.11.6", - "@webassemblyjs/wasm-gen": "1.11.6", - "@webassemblyjs/wasm-parser": "1.11.6" + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-buffer": "1.12.1", + "@webassemblyjs/wasm-gen": "1.12.1", + "@webassemblyjs/wasm-parser": "1.12.1" } }, "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.6.tgz", - "integrity": "sha512-6ZwPeGzMJM3Dqp3hCsLgESxBGtT/OeCvCZ4TA1JUPYgmhAx38tTPR9JaKy0S5H3evQpO/h2uWs2j6Yc/fjkpTQ==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.12.1.tgz", + "integrity": "sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ==", "dev": true, + "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-api-error": "1.11.6", "@webassemblyjs/helper-wasm-bytecode": "1.11.6", "@webassemblyjs/ieee754": "1.11.6", @@ -3165,12 +3160,13 @@ } }, "node_modules/@webassemblyjs/wast-printer": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.6.tgz", - "integrity": "sha512-JM7AhRcE+yW2GWYaKeHL5vt4xqee5N2WcezptmgyhNS+ScggqcT1OtXykhAb13Sn5Yas0j2uv9tHgrjwvzAP4A==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.12.1.tgz", + "integrity": "sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==", "dev": true, + "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/ast": "1.12.1", "@xtuc/long": "4.2.2" } }, @@ -3222,13 +3218,15 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "dev": true + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/@xtuc/long": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "dev": true + "dev": true, + "license": "Apache-2.0" }, "node_modules/acorn": { "version": "8.11.3", @@ -3242,11 +3240,12 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-import-assertions": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", - "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", "dev": true, + "license": "MIT", "peerDependencies": { "acorn": "^8" } @@ -3629,12 +3628,13 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, + "license": "MIT", "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -4295,10 +4295,11 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.15.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.15.0.tgz", - "integrity": "sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg==", + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", + "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", "dev": true, + "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" @@ -5149,10 +5150,11 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, + "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" }, @@ -5350,7 +5352,8 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "dev": true + "dev": true, + "license": "BSD-2-Clause" }, "node_modules/globals": { "version": "11.12.0", @@ -5812,6 +5815,7 @@ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.12.0" } @@ -6281,12 +6285,13 @@ } }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, + "license": "MIT", "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { @@ -7781,6 +7786,7 @@ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dev": true, + "license": "MIT", "dependencies": { "is-number": "^7.0.0" }, @@ -8178,10 +8184,11 @@ "dev": true }, "node_modules/watchpack": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", - "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", + "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==", "dev": true, + "license": "MIT", "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" @@ -8191,26 +8198,26 @@ } }, "node_modules/webpack": { - "version": "5.90.3", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.90.3.tgz", - "integrity": "sha512-h6uDYlWCctQRuXBs1oYpVe6sFcWedl0dpcVaTf/YF67J9bKvwJajFulMVSYKHrksMB3I/pIagRzDxwxkebuzKA==", + "version": "5.94.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", + "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", "dev": true, + "license": "MIT", "dependencies": { - "@types/eslint-scope": "^3.7.3", "@types/estree": "^1.0.5", - "@webassemblyjs/ast": "^1.11.5", - "@webassemblyjs/wasm-edit": "^1.11.5", - "@webassemblyjs/wasm-parser": "^1.11.5", + "@webassemblyjs/ast": "^1.12.1", + "@webassemblyjs/wasm-edit": "^1.12.1", + "@webassemblyjs/wasm-parser": "^1.12.1", "acorn": "^8.7.1", - "acorn-import-assertions": "^1.9.0", + "acorn-import-attributes": "^1.9.5", "browserslist": "^4.21.10", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.15.0", + "enhanced-resolve": "^5.17.1", "es-module-lexer": "^1.2.1", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.9", + "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.2.0", "mime-types": "^2.1.27", @@ -8218,7 +8225,7 @@ "schema-utils": "^3.2.0", "tapable": "^2.1.1", "terser-webpack-plugin": "^5.3.10", - "watchpack": "^2.4.0", + "watchpack": "^2.4.1", "webpack-sources": "^3.2.3" }, "bin": { diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d061a99 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,107 @@ +[build-system] +requires = ["setuptools >= 61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "wagtail-ab-testing" +version = "0.11" +description = "A/B Testing for Wagtail" +readme = "README.md" +authors = [ + { name = "Wagtail Nest team", email = "hello@wagtail.org" }, +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Framework :: Django", + "Framework :: Django :: 4", + "Framework :: Django :: 5", + "Framework :: Wagtail", + "Framework :: Wagtail :: 5", + "Framework :: Wagtail :: 6", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +dependencies = [ + "numpy>=1.19.4,<2", + "scipy>=1.5.4,<2", + "user-agents>=2.2,<2.3", + "Wagtail>=5.2", +] + +[project.optional-dependencies] +testing = [ + "coverage[toml]>=7.2.7,<8.0", + "dj-database-url==0.5.0", + "freezegun==1.2.1", + "pre-commit>=3.4.0", +] + +[project.urls] +Changelog = "https://github.com/wagtail-nest/wagtail-ab-testing/blob/main/CHANGELOG.md" +Homepage = "https://github.com/wagtail-nest/wagtail-ab-testing/" +"Issue tracker" = "https://github.com/wagtail-nest/wagtail-ab-testing/issues/" +Source = "https://github.com/wagtail-nest/wagtail-ab-testing/" + +[tool.setuptools] +packages = ["wagtail_ab_testing"] + +[tool.coverage.run] +branch = true +source_pkgs = ["wagtail_ab_testing"] + +[tool.coverage.paths] +omit = ["**/migrations/*", "tests/"] + +[tool.coverage.report] +show_missing = true +exclude_lines = [ + # Have to re-enable the standard pragma + "pragma: no cover", + + # Don't complain about missing debug-only code: + "def __repr__", + "if self.debug", + + # Don't complain if tests don't hit defensive assertion code: + "raise AssertionError", + "raise NotImplementedError", + + # Don't complain if non-runnable code isn't run: + "if 0:", + "if __name__ == .__main__.:", + + # Don't complain about abstract methods, they aren't run: + "@(abc.)?abstractmethod", + + # Nor complain about type checking + "if TYPE_CHECKING:", +] + + +[tool.ruff] +target-version = "py39" # minimum target version + +exclude = [ + ".github", +] + +# E501: Line too long +lint.ignore = ["E501"] + +lint.select = [ + "E", # pycodestyle errors + "F", # pyflakes + "I", # isort + "T20", # flake8-print + "BLE", # flake8-blind-except + "C4", # flake8-comprehensions + "UP", # pyupgrade +] diff --git a/setup.py b/setup.py deleted file mode 100644 index 3563460..0000000 --- a/setup.py +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env python - -from setuptools import setup, find_packages - -# Hack to prevent "TypeError: 'NoneType' object is not callable" error -# in multiprocessing/util.py _exit_function when setup.py exits -# (see http://www.eby-sarna.com/pipermail/peak/2010-May/003357.html) -try: - import multiprocessing -except ImportError: - pass - -with open("README.md", "r") as fh: - long_description = fh.read() - -setup( - name="wagtail-ab-testing", - version="0.11", - description="A/B Testing for Wagtail", - long_description=long_description, - long_description_content_type="text/markdown", - author="Karl Hobley", - author_email="karl@torchbox.com", - url="", - packages=find_packages(), - include_package_data=True, - license="BSD", - classifiers=[ - "Development Status :: 3 - Alpha", - "Intended Audience :: Developers", - "License :: OSI Approved :: BSD License", - "Operating System :: OS Independent", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Framework :: Django", - "Framework :: Django :: 4.2", - "Framework :: Django :: 5.0", - "Framework :: Wagtail", - "Framework :: Wagtail :: 5", - "Framework :: Wagtail :: 6", - ], - install_requires=[ - "Wagtail>=5.2", - "user-agents>=2.2,<2.3", - "numpy>=1.19.4,<2", - "scipy>=1.5.4,<2", - ], - extras_require={ - "testing": ["dj-database-url==0.5.0", "freezegun==1.2.1"], - }, - zip_safe=False, -) diff --git a/testmanage.py b/testmanage.py index 07d49a3..c82f9bc 100644 --- a/testmanage.py +++ b/testmanage.py @@ -7,9 +7,7 @@ import warnings from django.core.management import execute_from_command_line - -from wagtail.test.settings import STATIC_ROOT, MEDIA_ROOT - +from wagtail.test.settings import MEDIA_ROOT, STATIC_ROOT os.environ["DJANGO_SETTINGS_MODULE"] = "wagtail_ab_testing.test.settings" diff --git a/tox.ini b/tox.ini index 4824607..a9ea230 100644 --- a/tox.ini +++ b/tox.ini @@ -1,27 +1,16 @@ [tox] -skipsdist = True -usedevelop = True - envlist = ; Wagtail 5.2 LTS - py{38,39,310,311,312}-django42-wagtail52-{sqlite,postgres} + py{39,310,311,312}-django42-wagtail52-{sqlite,postgres} py{310,311,312}-django50-wagtail52-{sqlite,postgres} - ; Wagtail 6.0 and 6.1 - py{38,39,310,311,312}-django42-wagtail{60,61}-{sqlite,postgres} - py{310,311,312}-django50-wagtail{60,61}-{sqlite,postgres} - -[flake8] -# E501: Line too long -# W503: line break before binary operator (superseded by W504 line break after binary operator) -ignore = E501,W503 -exclude = migrations,node_modules + ; Wagtail 6.1 + 6.2 + py{39,310,311,312}-django42-wagtail{61,62}-{sqlite,postgres} + py{310,311,312}-django50-wagtail{61,62}-{sqlite,postgres} [testenv] -install_command = pip install -e ".[testing]" -U {opts} {packages} +allowlist_externals = coverage commands = coverage run -p testmanage.py test --deprecation all --noinput - basepython = - py38: python3.8 py39: python3.9 py310: python3.10 py311: python3.11 @@ -34,25 +23,20 @@ deps = django50: django>=5.0,<5.1 wagtail52: wagtail~=5.2.0 - wagtail60: wagtail~=6.0.0 wagtail61: wagtail~=6.1.0 - wagtailmain: git+https://github.com/wagtail/wagtail.git + wagtail62: wagtail~=6.2.0 postgres: psycopg2>=2.9 +extras = testing setenv = postgres: DATABASE_URL={env:DATABASE_URL:postgres:///wagtail_ab_testing} -[testenv:flake8] -basepython=python3.12 -deps=flake8>=2.2.0 -commands=flake8 wagtail_ab_testing - [testenv:wagtailmain] -deps= +allowlist_externals = coverage +deps = coverage - git+https://github.com/wagtail/wagtail.git - postgres: psycopg2>=2.9 - + git+https://github.com/wagtail/wagtail.git@main#egg=Wagtail +extras = testing setenv = postgres: DATABASE_URL={env:DATABASE_URL:postgres:///wagtail_ab_testing} diff --git a/wagtail_ab_testing/api.py b/wagtail_ab_testing/api.py index c86bc3c..a4dd6cf 100644 --- a/wagtail_ab_testing/api.py +++ b/wagtail_ab_testing/api.py @@ -2,7 +2,6 @@ from rest_framework import fields, routers, serializers, status, viewsets from rest_framework.decorators import action from rest_framework.response import Response - from wagtail.models import Page, Site from .models import AbTest @@ -10,49 +9,57 @@ class SiteSerializer(serializers.ModelSerializer): class Meta: - fields = ['id', 'hostname'] + fields = ["id", "hostname"] model = Site class PageSerializer(serializers.ModelSerializer): - path = fields.SerializerMethodField('get_path') + path = fields.SerializerMethodField("get_path") def get_path(self, page): return page.get_url_parts()[2] class Meta: - fields = ['id', 'path'] + fields = ["id", "path"] model = Page class AbTestGoalSerializer(serializers.ModelSerializer): - page = PageSerializer(source='goal_page') - event = fields.ReadOnlyField(source='goal_event') + page = PageSerializer(source="goal_page") + event = fields.ReadOnlyField(source="goal_event") class Meta: - fields = ['page', 'event'] + fields = ["page", "event"] model = AbTest class AbTestSerializer(serializers.ModelSerializer): - site = SiteSerializer(source='page.get_site') + site = SiteSerializer(source="page.get_site") page = PageSerializer() - goal = AbTestGoalSerializer(source='*') + goal = AbTestGoalSerializer(source="*") variant_html_url = fields.SerializerMethodField() add_participant_url = fields.SerializerMethodField() log_conversion_url = fields.SerializerMethodField() def get_variant_html_url(self, test): - return reverse('wagtail_ab_testing_api:abtest-serve-variant', args=[test.id]) + return reverse("wagtail_ab_testing_api:abtest-serve-variant", args=[test.id]) def get_add_participant_url(self, test): - return reverse('wagtail_ab_testing_api:abtest-add-participant', args=[test.id]) + return reverse("wagtail_ab_testing_api:abtest-add-participant", args=[test.id]) def get_log_conversion_url(self, test): - return reverse('wagtail_ab_testing_api:abtest-log-conversion', args=[test.id]) + return reverse("wagtail_ab_testing_api:abtest-log-conversion", args=[test.id]) class Meta: - fields = ['id', 'site', 'page', 'goal', 'variant_html_url', 'add_participant_url', 'log_conversion_url'] + fields = [ + "id", + "site", + "page", + "goal", + "variant_html_url", + "add_participant_url", + "log_conversion_url", + ] model = AbTest @@ -60,35 +67,38 @@ class AbTestViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = AbTestSerializer queryset = AbTest.objects.filter(status=AbTest.STATUS_RUNNING) - @action(detail=True, methods=['get']) + @action(detail=True, methods=["get"]) def serve_variant(self, request, pk=None): test = self.get_object() request.wagtail_ab_testing_test = test request.wagtail_ab_testing_serving_variant = True return test.variant_revision.as_object().serve(request) - @action(detail=True, methods=['post']) + @action(detail=True, methods=["post"]) def add_participant(self, request, pk=None): test = self.get_object() variant = test.add_participant() - return Response({ - 'version': variant, - 'test_finished': test.status != AbTest.STATUS_RUNNING, - }, status=status.HTTP_201_CREATED) - - @action(detail=True, methods=['post']) + return Response( + { + "version": variant, + "test_finished": test.status != AbTest.STATUS_RUNNING, + }, + status=status.HTTP_201_CREATED, + ) + + @action(detail=True, methods=["post"]) def log_conversion(self, request, pk=None): test = self.get_object() - if request.data['version'] not in dict(AbTest.VERSION_CHOICES).keys(): + if request.data["version"] not in dict(AbTest.VERSION_CHOICES).keys(): return Response({}, status=status.HTTP_400_BAD_REQUEST) - test.log_conversion(request.data['version']) + test.log_conversion(request.data["version"]) return Response({}, status=status.HTTP_201_CREATED) router = routers.SimpleRouter() -router.register(r'tests', AbTestViewSet) +router.register(r"tests", AbTestViewSet) -app_name = 'wagtail_ab_testing_api' +app_name = "wagtail_ab_testing_api" urlpatterns = router.urls diff --git a/wagtail_ab_testing/compat.py b/wagtail_ab_testing/compat.py index 97a5f07..8100a28 100644 --- a/wagtail_ab_testing/compat.py +++ b/wagtail_ab_testing/compat.py @@ -1,8 +1,8 @@ import os -if os.name == 'nt': +if os.name == "nt": # Windows has a different strftime format for dates without leading 0 # https://stackoverflow.com/questions/904928/python-strftime-date-without-leading-0 - DATE_FORMAT = '%#d %B %Y' + DATE_FORMAT = "%#d %B %Y" else: - DATE_FORMAT = '%-d %B %Y' + DATE_FORMAT = "%-d %B %Y" diff --git a/wagtail_ab_testing/events.py b/wagtail_ab_testing/events.py index 22085dd..1c5d7af 100644 --- a/wagtail_ab_testing/events.py +++ b/wagtail_ab_testing/events.py @@ -1,5 +1,4 @@ from django.utils.translation import gettext_lazy as __ - from wagtail import hooks @@ -7,6 +6,7 @@ class BaseEvent: """ A base class for events that are linked to Wagtail pages. """ + name = None # When False, the user won't be asked to select a goal page @@ -39,6 +39,7 @@ class VisitPageEvent(BaseEvent): """ Triggered when a user visits a page. """ + name = __("Visit page") @@ -58,7 +59,7 @@ class VisitPageEvent(BaseEvent): BUILTIN_EVENT_TYPES = { - 'visit-page': VisitPageEvent(), + "visit-page": VisitPageEvent(), # 'submit-form': SubmitFormPageEvent(), } @@ -67,7 +68,7 @@ def get_event_types(): event_types = {} event_types.update(BUILTIN_EVENT_TYPES) - for fn in hooks.get_hooks('register_ab_testing_event_types'): + for fn in hooks.get_hooks("register_ab_testing_event_types"): event_types.update(fn()) return event_types diff --git a/wagtail_ab_testing/migrations/0001_initial.py b/wagtail_ab_testing/migrations/0001_initial.py index 71ae66b..b352135 100644 --- a/wagtail_ab_testing/migrations/0001_initial.py +++ b/wagtail_ab_testing/migrations/0001_initial.py @@ -1,29 +1,72 @@ # Generated by Django 3.1.3 on 2020-11-03 15:58 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): - initial = True dependencies = [ - ('wagtailcore', '0052_pagelogentry'), + ("wagtailcore", "0052_pagelogentry"), ] operations = [ migrations.CreateModel( - name='AbTest', + name="AbTest", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255)), - ('goal_event', models.CharField(max_length=255)), - ('sample_size', models.PositiveIntegerField()), - ('status', models.CharField(choices=[('draft', 'Draft'), ('running', 'Running'), ('paused', 'Paused'), ('cancelled', 'Cancelled'), ('completed', 'Completed')], default='draft', max_length=20)), - ('goal_page', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailcore.page')), - ('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ab_tests', to='wagtailcore.page')), - ('treatment_revision', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='wagtailcore.pagerevision')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ("goal_event", models.CharField(max_length=255)), + ("sample_size", models.PositiveIntegerField()), + ( + "status", + models.CharField( + choices=[ + ("draft", "Draft"), + ("running", "Running"), + ("paused", "Paused"), + ("cancelled", "Cancelled"), + ("completed", "Completed"), + ], + default="draft", + max_length=20, + ), + ), + ( + "goal_page", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="wagtailcore.page", + ), + ), + ( + "page", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="ab_tests", + to="wagtailcore.page", + ), + ), + ( + "treatment_revision", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="wagtailcore.pagerevision", + ), + ), ], ), ] diff --git a/wagtail_ab_testing/migrations/0001_squashed_0012_abtest_variant_revision.py b/wagtail_ab_testing/migrations/0001_squashed_0012_abtest_variant_revision.py index 9f460f9..7caf1a9 100644 --- a/wagtail_ab_testing/migrations/0001_squashed_0012_abtest_variant_revision.py +++ b/wagtail_ab_testing/migrations/0001_squashed_0012_abtest_variant_revision.py @@ -2,9 +2,9 @@ # PURPOSE OF THIS MIGRATION # -# This squashed migration was created for compatibility with Wagtail 4.0's migration from PageRevision -# to a generic Revision model. The `0001_initial` migration in this project must continue to reference the old -# PageRevision model and wagtailcore.0052_pagelogentry dependency to maintain a consistent migration history +# This squashed migration was created for compatibility with Wagtail 4.0's migration from PageRevision +# to a generic Revision model. The `0001_initial` migration in this project must continue to reference the old +# PageRevision model and wagtailcore.0052_pagelogentry dependency to maintain a consistent migration history # for existing databases. On the flip side, new projects that run wagtail_ab_testing's migrations for the first time # will encounter a PageRevision model that no longer exists which causes errors. # @@ -12,11 +12,12 @@ # This file contains no references to PageRevision and will migrate without errors. import datetime -from django.conf import settings + import django.core.validators -from django.db import migrations, models import django.db.migrations.operations.special import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models # Migrated from wagtail_ab_testing.migrations.0007_grant_moderators_add_abtest_permission diff --git a/wagtail_ab_testing/migrations/0002_abtesthourlylog.py b/wagtail_ab_testing/migrations/0002_abtesthourlylog.py index 057da47..81227d6 100644 --- a/wagtail_ab_testing/migrations/0002_abtesthourlylog.py +++ b/wagtail_ab_testing/migrations/0002_abtesthourlylog.py @@ -1,30 +1,50 @@ # Generated by Django 3.1.3 on 2020-11-05 18:30 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('wagtail_ab_testing', '0001_initial'), + ("wagtail_ab_testing", "0001_initial"), ] operations = [ migrations.CreateModel( - name='AbTestHourlyLog', + name="AbTestHourlyLog", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('variant', models.CharField(choices=[('control', 'Control'), ('treatment', 'Treatment')], max_length=9)), - ('date', models.DateField()), - ('hour', models.PositiveSmallIntegerField()), - ('participants', models.PositiveIntegerField(default=0)), - ('conversions', models.PositiveIntegerField(default=0)), - ('ab_test', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='hourly_logs', to='wagtail_ab_testing.abtest')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "variant", + models.CharField( + choices=[("control", "Control"), ("treatment", "Treatment")], + max_length=9, + ), + ), + ("date", models.DateField()), + ("hour", models.PositiveSmallIntegerField()), + ("participants", models.PositiveIntegerField(default=0)), + ("conversions", models.PositiveIntegerField(default=0)), + ( + "ab_test", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="hourly_logs", + to="wagtail_ab_testing.abtest", + ), + ), ], options={ - 'ordering': ['ab_test', 'variant', 'date', 'hour'], - 'unique_together': {('ab_test', 'variant', 'date', 'hour')}, + "ordering": ["ab_test", "variant", "date", "hour"], + "unique_together": {("ab_test", "variant", "date", "hour")}, }, ), ] diff --git a/wagtail_ab_testing/migrations/0003_abtest_winning_variant.py b/wagtail_ab_testing/migrations/0003_abtest_winning_variant.py index e5e4e31..01b706a 100644 --- a/wagtail_ab_testing/migrations/0003_abtest_winning_variant.py +++ b/wagtail_ab_testing/migrations/0003_abtest_winning_variant.py @@ -4,15 +4,18 @@ class Migration(migrations.Migration): - dependencies = [ - ('wagtail_ab_testing', '0002_abtesthourlylog'), + ("wagtail_ab_testing", "0002_abtesthourlylog"), ] operations = [ migrations.AddField( - model_name='abtest', - name='winning_variant', - field=models.CharField(choices=[('control', 'Control'), ('treatment', 'Treatment')], max_length=9, null=True), + model_name="abtest", + name="winning_variant", + field=models.CharField( + choices=[("control", "Control"), ("treatment", "Treatment")], + max_length=9, + null=True, + ), ), ] diff --git a/wagtail_ab_testing/migrations/0004_started_at_and_duration.py b/wagtail_ab_testing/migrations/0004_started_at_and_duration.py index 039dde0..e1af68e 100644 --- a/wagtail_ab_testing/migrations/0004_started_at_and_duration.py +++ b/wagtail_ab_testing/migrations/0004_started_at_and_duration.py @@ -1,29 +1,29 @@ # Generated by Django 3.1.3 on 2020-11-19 17:36 import datetime + from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('wagtail_ab_testing', '0003_abtest_winning_variant'), + ("wagtail_ab_testing", "0003_abtest_winning_variant"), ] operations = [ migrations.AddField( - model_name='abtest', - name='current_run_started_at', + model_name="abtest", + name="current_run_started_at", field=models.DateTimeField(null=True), ), migrations.AddField( - model_name='abtest', - name='first_started_at', + model_name="abtest", + name="first_started_at", field=models.DateTimeField(null=True), ), migrations.AddField( - model_name='abtest', - name='previous_run_duration', + model_name="abtest", + name="previous_run_duration", field=models.DurationField(default=datetime.timedelta(0)), ), ] diff --git a/wagtail_ab_testing/migrations/0005_hypothesis_and_created_by.py b/wagtail_ab_testing/migrations/0005_hypothesis_and_created_by.py index cd5328f..e55b994 100644 --- a/wagtail_ab_testing/migrations/0005_hypothesis_and_created_by.py +++ b/wagtail_ab_testing/migrations/0005_hypothesis_and_created_by.py @@ -1,26 +1,31 @@ # Generated by Django 3.1.3 on 2020-11-24 16:05 +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion class Migration(migrations.Migration): - dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('wagtail_ab_testing', '0004_started_at_and_duration'), + ("wagtail_ab_testing", "0004_started_at_and_duration"), ] operations = [ migrations.AddField( - model_name='abtest', - name='created_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL), + model_name="abtest", + name="created_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), ), migrations.AddField( - model_name='abtest', - name='hypothesis', + model_name="abtest", + name="hypothesis", field=models.TextField(blank=True), ), ] diff --git a/wagtail_ab_testing/migrations/0006_sample_size_min_value.py b/wagtail_ab_testing/migrations/0006_sample_size_min_value.py index 77a5b23..0c7e580 100644 --- a/wagtail_ab_testing/migrations/0006_sample_size_min_value.py +++ b/wagtail_ab_testing/migrations/0006_sample_size_min_value.py @@ -5,15 +5,16 @@ class Migration(migrations.Migration): - dependencies = [ - ('wagtail_ab_testing', '0005_hypothesis_and_created_by'), + ("wagtail_ab_testing", "0005_hypothesis_and_created_by"), ] operations = [ migrations.AlterField( - model_name='abtest', - name='sample_size', - field=models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1)]), + model_name="abtest", + name="sample_size", + field=models.PositiveIntegerField( + validators=[django.core.validators.MinValueValidator(1)] + ), ), ] diff --git a/wagtail_ab_testing/migrations/0007_grant_moderators_add_abtest_permission.py b/wagtail_ab_testing/migrations/0007_grant_moderators_add_abtest_permission.py index af37bc7..d528e77 100644 --- a/wagtail_ab_testing/migrations/0007_grant_moderators_add_abtest_permission.py +++ b/wagtail_ab_testing/migrations/0007_grant_moderators_add_abtest_permission.py @@ -4,24 +4,29 @@ def grant_moderators_add_abtest_permission(apps, schema_editor): - ContentType = apps.get_model('contenttypes.ContentType') - Permission = apps.get_model('auth.Permission') - Group = apps.get_model('auth.Group') - - abtest_content_type, created = ContentType.objects.get_or_create(app_label='wagtail_ab_testing', model='abtest') - add_abtest_permission, created = Permission.objects.get_or_create(content_type=abtest_content_type, codename='add_abtest') - - moderators_group = Group.objects.filter(name='Moderators').first() + ContentType = apps.get_model("contenttypes.ContentType") + Permission = apps.get_model("auth.Permission") + Group = apps.get_model("auth.Group") + + abtest_content_type, created = ContentType.objects.get_or_create( + app_label="wagtail_ab_testing", model="abtest" + ) + add_abtest_permission, created = Permission.objects.get_or_create( + content_type=abtest_content_type, codename="add_abtest" + ) + + moderators_group = Group.objects.filter(name="Moderators").first() if moderators_group: moderators_group.permissions.add(add_abtest_permission) class Migration(migrations.Migration): - dependencies = [ - ('wagtail_ab_testing', '0006_sample_size_min_value'), + ("wagtail_ab_testing", "0006_sample_size_min_value"), ] operations = [ - migrations.RunPython(grant_moderators_add_abtest_permission, migrations.RunPython.noop), + migrations.RunPython( + grant_moderators_add_abtest_permission, migrations.RunPython.noop + ), ] diff --git a/wagtail_ab_testing/migrations/0008_finished_status.py b/wagtail_ab_testing/migrations/0008_finished_status.py index 16ef024..4aef55b 100644 --- a/wagtail_ab_testing/migrations/0008_finished_status.py +++ b/wagtail_ab_testing/migrations/0008_finished_status.py @@ -4,15 +4,25 @@ class Migration(migrations.Migration): - dependencies = [ - ('wagtail_ab_testing', '0007_grant_moderators_add_abtest_permission'), + ("wagtail_ab_testing", "0007_grant_moderators_add_abtest_permission"), ] operations = [ migrations.AlterField( - model_name='abtest', - name='status', - field=models.CharField(choices=[('draft', 'Draft'), ('running', 'Running'), ('paused', 'Paused'), ('cancelled', 'Cancelled'), ('finished', 'Finished'), ('completed', 'Completed')], default='draft', max_length=20), + model_name="abtest", + name="status", + field=models.CharField( + choices=[ + ("draft", "Draft"), + ("running", "Running"), + ("paused", "Paused"), + ("cancelled", "Cancelled"), + ("finished", "Finished"), + ("completed", "Completed"), + ], + default="draft", + max_length=20, + ), ), ] diff --git a/wagtail_ab_testing/migrations/0009_rename_variant_to_version.py b/wagtail_ab_testing/migrations/0009_rename_variant_to_version.py index 9d5aa0b..5312ef3 100644 --- a/wagtail_ab_testing/migrations/0009_rename_variant_to_version.py +++ b/wagtail_ab_testing/migrations/0009_rename_variant_to_version.py @@ -4,28 +4,27 @@ class Migration(migrations.Migration): - dependencies = [ - ('wagtail_ab_testing', '0008_finished_status'), + ("wagtail_ab_testing", "0008_finished_status"), ] operations = [ migrations.RenameField( - model_name='abtest', - old_name='winning_variant', - new_name='winning_version', + model_name="abtest", + old_name="winning_variant", + new_name="winning_version", ), migrations.AlterModelOptions( - name='abtesthourlylog', - options={'ordering': ['ab_test', 'version', 'date', 'hour']}, + name="abtesthourlylog", + options={"ordering": ["ab_test", "version", "date", "hour"]}, ), migrations.RenameField( - model_name='abtesthourlylog', - old_name='variant', - new_name='version', + model_name="abtesthourlylog", + old_name="variant", + new_name="version", ), migrations.AlterUniqueTogether( - name='abtesthourlylog', - unique_together={('ab_test', 'version', 'date', 'hour')}, + name="abtesthourlylog", + unique_together={("ab_test", "version", "date", "hour")}, ), ] diff --git a/wagtail_ab_testing/migrations/0010_rename_treatment_to_variant.py b/wagtail_ab_testing/migrations/0010_rename_treatment_to_variant.py index 1bc7e2c..46a9549 100644 --- a/wagtail_ab_testing/migrations/0010_rename_treatment_to_variant.py +++ b/wagtail_ab_testing/migrations/0010_rename_treatment_to_variant.py @@ -4,25 +4,30 @@ class Migration(migrations.Migration): - dependencies = [ - ('wagtail_ab_testing', '0009_rename_variant_to_version'), + ("wagtail_ab_testing", "0009_rename_variant_to_version"), ] operations = [ migrations.RenameField( - model_name='abtest', - old_name='treatment_revision', - new_name='variant_revision', + model_name="abtest", + old_name="treatment_revision", + new_name="variant_revision", ), migrations.AlterField( - model_name='abtest', - name='winning_version', - field=models.CharField(choices=[('control', 'Control'), ('variant', 'Variant')], max_length=9, null=True), + model_name="abtest", + name="winning_version", + field=models.CharField( + choices=[("control", "Control"), ("variant", "Variant")], + max_length=9, + null=True, + ), ), migrations.AlterField( - model_name='abtesthourlylog', - name='version', - field=models.CharField(choices=[('control', 'Control'), ('variant', 'Variant')], max_length=9), + model_name="abtesthourlylog", + name="version", + field=models.CharField( + choices=[("control", "Control"), ("variant", "Variant")], max_length=9 + ), ), ] diff --git a/wagtail_ab_testing/migrations/0011_rename_treatment_to_variant_data.py b/wagtail_ab_testing/migrations/0011_rename_treatment_to_variant_data.py index b24245c..2426268 100644 --- a/wagtail_ab_testing/migrations/0011_rename_treatment_to_variant_data.py +++ b/wagtail_ab_testing/migrations/0011_rename_treatment_to_variant_data.py @@ -1,30 +1,31 @@ # Generated by Django 3.1.3 on 2020-12-16 13:40 -from django.db import migrations, models +from django.db import migrations def rename_treatment_to_variant_forwards(apps, schema_editor): - AbTest = apps.get_model('wagtail_ab_testing.AbTest') - AbTest.objects.filter(winning_version='treatment').update(winning_version='variant') + AbTest = apps.get_model("wagtail_ab_testing.AbTest") + AbTest.objects.filter(winning_version="treatment").update(winning_version="variant") - AbTestHourlyLog = apps.get_model('wagtail_ab_testing.AbTestHourlyLog') - AbTestHourlyLog.objects.filter(version='treatment').update(version='variant') + AbTestHourlyLog = apps.get_model("wagtail_ab_testing.AbTestHourlyLog") + AbTestHourlyLog.objects.filter(version="treatment").update(version="variant") def rename_treatment_to_variant_backwards(apps, schema_editor): - AbTest = apps.get_model('wagtail_ab_testing.AbTest') - AbTest.objects.filter(winning_version='variant').update(winning_version='treatment') + AbTest = apps.get_model("wagtail_ab_testing.AbTest") + AbTest.objects.filter(winning_version="variant").update(winning_version="treatment") - AbTestHourlyLog = apps.get_model('wagtail_ab_testing.AbTestHourlyLog') - AbTestHourlyLog.objects.filter(version='variant').update(version='treatment') + AbTestHourlyLog = apps.get_model("wagtail_ab_testing.AbTestHourlyLog") + AbTestHourlyLog.objects.filter(version="variant").update(version="treatment") class Migration(migrations.Migration): - dependencies = [ - ('wagtail_ab_testing', '0010_rename_treatment_to_variant'), + ("wagtail_ab_testing", "0010_rename_treatment_to_variant"), ] operations = [ - migrations.RunPython(rename_treatment_to_variant_forwards, rename_treatment_to_variant_backwards) + migrations.RunPython( + rename_treatment_to_variant_forwards, rename_treatment_to_variant_backwards + ) ] diff --git a/wagtail_ab_testing/migrations/0012_abtest_variant_revision.py b/wagtail_ab_testing/migrations/0012_abtest_variant_revision.py index 86e5848..9e6305e 100644 --- a/wagtail_ab_testing/migrations/0012_abtest_variant_revision.py +++ b/wagtail_ab_testing/migrations/0012_abtest_variant_revision.py @@ -1,20 +1,23 @@ # Generated by Django 4.1.6 on 2023-03-02 13:34 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('wagtailcore', '0078_referenceindex'), - ('wagtail_ab_testing', '0011_rename_treatment_to_variant_data'), + ("wagtailcore", "0078_referenceindex"), + ("wagtail_ab_testing", "0011_rename_treatment_to_variant_data"), ] operations = [ migrations.AlterField( - model_name='abtest', - name='variant_revision', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='wagtailcore.revision'), + model_name="abtest", + name="variant_revision", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="wagtailcore.revision", + ), ), ] diff --git a/wagtail_ab_testing/migrations/0013_alter_abtest_variant_revision.py b/wagtail_ab_testing/migrations/0013_alter_abtest_variant_revision.py index 898365f..461f84e 100644 --- a/wagtail_ab_testing/migrations/0013_alter_abtest_variant_revision.py +++ b/wagtail_ab_testing/migrations/0013_alter_abtest_variant_revision.py @@ -1,20 +1,23 @@ # Generated by Django 4.1.13 on 2023-11-28 14:31 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('wagtailcore', '0078_referenceindex'), - ('wagtail_ab_testing', '0001_squashed_0012_abtest_variant_revision'), + ("wagtailcore", "0078_referenceindex"), + ("wagtail_ab_testing", "0001_squashed_0012_abtest_variant_revision"), ] operations = [ migrations.AlterField( - model_name='abtest', - name='variant_revision', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='wagtailcore.revision'), + model_name="abtest", + name="variant_revision", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + to="wagtailcore.revision", + ), ), ] diff --git a/wagtail_ab_testing/models.py b/wagtail_ab_testing/models.py index f45a9da..f1732ea 100644 --- a/wagtail_ab_testing/models.py +++ b/wagtail_ab_testing/models.py @@ -1,9 +1,9 @@ import random +from datetime import datetime, timedelta +from datetime import timezone as tz -from datetime import datetime, timedelta, timezone as tz - -import scipy.stats import numpy as np +import scipy.stats from django.conf import settings from django.core.validators import MinValueValidator from django.db import connection, models, transaction @@ -11,8 +11,8 @@ from django.dispatch import receiver from django.urls import reverse from django.utils import timezone -from django.utils.translation import gettext as _, gettext_lazy as __ - +from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as __ from wagtail.signals import page_unpublished from .events import get_event_types @@ -20,7 +20,12 @@ class AbTestManager(models.Manager): def get_current_for_page(self, page): - return self.get_queryset().filter(page=page).exclude(status__in=[AbTest.STATUS_CANCELLED, AbTest.STATUS_COMPLETED]).first() + return ( + self.get_queryset() + .filter(page=page) + .exclude(status__in=[AbTest.STATUS_CANCELLED, AbTest.STATUS_COMPLETED]) + .first() + ) class AbTest(models.Model): @@ -31,39 +36,39 @@ class AbTest(models.Model): the `.variant_revision` field contains the changes that are being tested. """ - STATUS_DRAFT = 'draft' - STATUS_RUNNING = 'running' - STATUS_PAUSED = 'paused' - STATUS_CANCELLED = 'cancelled' + STATUS_DRAFT = "draft" + STATUS_RUNNING = "running" + STATUS_PAUSED = "paused" + STATUS_CANCELLED = "cancelled" # These two sound similar, but there's a difference: # 'Finished' means that we've reached the sample size and testing has stopped # but the user still needs to decide whether to publish the variant version # or revert back to the control. # Once they've decided and that action has taken place, the test status is # updated to 'Completed'. - STATUS_FINISHED = 'finished' - STATUS_COMPLETED = 'completed' + STATUS_FINISHED = "finished" + STATUS_COMPLETED = "completed" STATUS_CHOICES = [ - (STATUS_DRAFT, __('Draft')), - (STATUS_RUNNING, __('Running')), - (STATUS_PAUSED, __('Paused')), - (STATUS_CANCELLED, __('Cancelled')), - (STATUS_FINISHED, __('Finished')), - (STATUS_COMPLETED, __('Completed')), + (STATUS_DRAFT, __("Draft")), + (STATUS_RUNNING, __("Running")), + (STATUS_PAUSED, __("Paused")), + (STATUS_CANCELLED, __("Cancelled")), + (STATUS_FINISHED, __("Finished")), + (STATUS_COMPLETED, __("Completed")), ] - VERSION_CONTROL = 'control' - VERSION_VARIANT = 'variant' + VERSION_CONTROL = "control" + VERSION_VARIANT = "variant" VERSION_CHOICES = [ - (VERSION_CONTROL, __('Control')), - (VERSION_VARIANT, __('Variant')), + (VERSION_CONTROL, __("Control")), + (VERSION_VARIANT, __("Variant")), ] - COMPLETION_ACTION_DO_NOTHING = 'do-nothing' - COMPLETION_ACTION_REVERT = 'revert' - COMPLETION_ACTION_PUBLISH = 'publish' + COMPLETION_ACTION_DO_NOTHING = "do-nothing" + COMPLETION_ACTION_REVERT = "revert" + COMPLETION_ACTION_PUBLISH = "publish" COMPLETION_ACTION_CHOICES = [ (COMPLETION_ACTION_DO_NOTHING, "Do nothing"), @@ -71,15 +76,33 @@ class AbTest(models.Model): (COMPLETION_ACTION_PUBLISH, "Publish"), ] - page = models.ForeignKey('wagtailcore.Page', on_delete=models.CASCADE, related_name='ab_tests') + page = models.ForeignKey( + "wagtailcore.Page", on_delete=models.CASCADE, related_name="ab_tests" + ) name = models.CharField(max_length=255) hypothesis = models.TextField(blank=True) - variant_revision = models.ForeignKey('wagtailcore.Revision', on_delete=models.PROTECT, related_name='+') + variant_revision = models.ForeignKey( + "wagtailcore.Revision", on_delete=models.PROTECT, related_name="+" + ) goal_event = models.CharField(max_length=255) - goal_page = models.ForeignKey('wagtailcore.Page', null=True, blank=True, on_delete=models.SET_NULL, related_name='+') + goal_page = models.ForeignKey( + "wagtailcore.Page", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="+", + ) sample_size = models.PositiveIntegerField(validators=[MinValueValidator(1)]) - created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name='+') - status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_DRAFT) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="+", + ) + status = models.CharField( + max_length=20, choices=STATUS_CHOICES, default=STATUS_DRAFT + ) winning_version = models.CharField(max_length=9, null=True, choices=VERSION_CHOICES) first_started_at = models.DateTimeField(null=True) @@ -111,7 +134,9 @@ def start(self): self.status = self.STATUS_RUNNING - self.save(update_fields=['status', 'current_run_started_at', 'first_started_at']) + self.save( + update_fields=["status", "current_run_started_at", "first_started_at"] + ) def pause(self): """ @@ -121,10 +146,18 @@ def pause(self): self.status = self.STATUS_PAUSED if self.current_run_started_at is not None: - self.previous_run_duration += timezone.now() - self.current_run_started_at + self.previous_run_duration += ( + timezone.now() - self.current_run_started_at + ) self.current_run_started_at = None - self.save(update_fields=['status', 'previous_run_duration', 'current_run_started_at']) + self.save( + update_fields=[ + "status", + "previous_run_duration", + "current_run_started_at", + ] + ) def get_results_url(self): """ @@ -135,10 +168,12 @@ def get_results_url(self): page editor returns to normal. """ if self.status in [AbTest.STATUS_COMPLETED, AbTest.STATUS_CANCELLED]: - return reverse('wagtail_ab_testing_admin:results', args=[self.page_id, self.id]) + return reverse( + "wagtail_ab_testing_admin:results", args=[self.page_id, self.id] + ) else: - return reverse('wagtailadmin_pages:edit', args=[self.page_id]) + return reverse("wagtailadmin_pages:edit", args=[self.page_id]) def total_running_duration(self): """ @@ -157,7 +192,7 @@ def cancel(self): """ self.status = self.STATUS_CANCELLED - self.save(update_fields=['status']) + self.save(update_fields=["status"]) def finish(self): """ @@ -172,7 +207,7 @@ def finish(self): self.status = self.STATUS_FINISHED self.winning_version = self.check_for_winner() - self.save(update_fields=['status', 'winning_version']) + self.save(update_fields=["status", "winning_version"]) @transaction.atomic def complete(self, action, user=None): @@ -192,14 +227,16 @@ def complete(self, action, user=None): and also publishes the variant revision. """ self.status = self.STATUS_COMPLETED - self.save(update_fields=['status']) + self.save(update_fields=["status"]) if action == AbTest.COMPLETION_ACTION_DO_NOTHING: pass elif action == AbTest.COMPLETION_ACTION_REVERT: # Create a new revision with the content of the live page and publish it - self.page.specific.save_revision(user=user, previous_revision=self.page.live_revision).publish(user=user) + self.page.specific.save_revision( + user=user, previous_revision=self.page.live_revision + ).publish(user=user) elif action == AbTest.COMPLETION_ACTION_PUBLISH: self.variant_revision.publish(user=user) @@ -209,11 +246,15 @@ def get_participation_numbers(self): Returns a 2-tuple containing the number of participants who were given the control or variant version of the page respectively. """ stats = self.hourly_logs.aggregate( - control_participants=Sum('participants', filter=Q(version=self.VERSION_CONTROL)), - variant_participants=Sum('participants', filter=Q(version=self.VERSION_VARIANT)), + control_participants=Sum( + "participants", filter=Q(version=self.VERSION_CONTROL) + ), + variant_participants=Sum( + "participants", filter=Q(version=self.VERSION_VARIANT) + ), ) - control_participants = stats['control_participants'] or 0 - variant_participants = stats['variant_participants'] or 0 + control_participants = stats["control_participants"] or 0 + variant_participants = stats["variant_participants"] or 0 return control_participants, variant_participants @@ -234,10 +275,12 @@ def get_new_participant_version(self, participation_numbers=None): return self.VERSION_VARIANT else: - return random.choice([ - self.VERSION_CONTROL, - self.VERSION_VARIANT, - ]) + return random.choice( + [ + self.VERSION_CONTROL, + self.VERSION_VARIANT, + ] + ) def add_participant(self, version=None): """ @@ -249,7 +292,9 @@ def add_participant(self, version=None): # Create an equal number of participants for each version if version is None: # Note, pass participation numbers we already have to save a database query - version = self.get_new_participant_version(participation_numbers=(control_participants, variant_participants)) + version = self.get_new_participant_version( + participation_numbers=(control_participants, variant_participants) + ) # Add new participant to statistics model AbTestHourlyLog._increment_stats(self, version, 1, 0) @@ -285,20 +330,31 @@ def check_for_winner(self): """ # Fetch stats from database stats = self.hourly_logs.aggregate( - control_participants=Sum('participants', filter=Q(version=self.VERSION_CONTROL)), - control_conversions=Sum('conversions', filter=Q(version=self.VERSION_CONTROL)), - variant_participants=Sum('participants', filter=Q(version=self.VERSION_VARIANT)), - variant_conversions=Sum('conversions', filter=Q(version=self.VERSION_VARIANT)), + control_participants=Sum( + "participants", filter=Q(version=self.VERSION_CONTROL) + ), + control_conversions=Sum( + "conversions", filter=Q(version=self.VERSION_CONTROL) + ), + variant_participants=Sum( + "participants", filter=Q(version=self.VERSION_VARIANT) + ), + variant_conversions=Sum( + "conversions", filter=Q(version=self.VERSION_VARIANT) + ), ) - control_participants = stats['control_participants'] or 0 - control_conversions = stats['control_conversions'] or 0 - variant_participants = stats['variant_participants'] or 0 - variant_conversions = stats['variant_conversions'] or 0 + control_participants = stats["control_participants"] or 0 + control_conversions = stats["control_conversions"] or 0 + variant_participants = stats["variant_participants"] or 0 + variant_conversions = stats["variant_conversions"] or 0 if not control_conversions and not variant_conversions: return - if control_conversions > control_participants or variant_conversions > variant_participants: + if ( + control_conversions > control_participants + or variant_conversions > variant_participants + ): # Something's up. I'm sure it's already clear in the UI what's going on, so let's not crash return @@ -310,7 +366,12 @@ def check_for_winner(self): # Prevent this error: "The internally computed table of expected frequencies has a zero element at (0, 1)." return - T = np.array([[control_conversions, control_failures], [variant_conversions, variant_failures]]) + T = np.array( + [ + [control_conversions, control_failures], + [variant_conversions, variant_failures], + ] + ) # Perform Chi-Squared test p = scipy.stats.chi2_contingency(T, correction=False)[1] @@ -320,7 +381,9 @@ def check_for_winner(self): if 1 - p > required_confidence_level: # There is a clear winner! # Return the one with the highest success rate - if (control_conversions / control_participants) > (variant_conversions / variant_participants): + if (control_conversions / control_participants) > ( + variant_conversions / variant_participants + ): return self.VERSION_CONTROL else: return self.VERSION_VARIANT @@ -332,7 +395,12 @@ def get_status_description(self): status = self.get_status_display() if self.status == AbTest.STATUS_RUNNING: - participants = self.hourly_logs.aggregate(participants=Sum('participants'))['participants'] or 0 + participants = ( + self.hourly_logs.aggregate(participants=Sum("participants"))[ + "participants" + ] + or 0 + ) completeness_percentange = int((participants * 100) / self.sample_size) return status + f" ({completeness_percentange}%)" @@ -351,7 +419,9 @@ def get_status_description(self): class AbTestHourlyLog(models.Model): - ab_test = models.ForeignKey(AbTest, on_delete=models.CASCADE, related_name='hourly_logs') + ab_test = models.ForeignKey( + AbTest, on_delete=models.CASCADE, related_name="hourly_logs" + ) version = models.CharField(max_length=9, choices=AbTest.VERSION_CHOICES) date = models.DateField() # UTC hour. Values range from 0 to 23 @@ -364,65 +434,85 @@ class AbTestHourlyLog(models.Model): conversions = models.PositiveIntegerField(default=0) @classmethod - def _increment_stats(cls, ab_test, version, participants, conversions, *, time=None): + def _increment_stats( + cls, ab_test, version, participants, conversions, *, time=None + ): """ Increments the participants/conversions statistics for the given ab_test/version. This will create a new AbTestHourlyLog record if one doesn't exist for the current hour. """ - time = time.astimezone(tz.utc) if time else datetime.utcnow() + time = time.astimezone(tz.utc) if time else datetime.now(tz.utc) date = time.date() hour = time.hour - if connection.vendor == 'postgresql': + if connection.vendor == "postgresql": # Use fast, atomic UPSERT query on PostgreSQL + # This needs to be done as a raw query because Django's ORM doesn't support atomic UPSERTs with connection.cursor() as cursor: table_name = connection.ops.quote_name(cls._meta.db_table) - query = """ + + query = ( + """ INSERT INTO %s (ab_test_id, version, date, hour, participants, conversions) VALUES (%%s, %%s, %%s, %%s, %%s, %%s) ON CONFLICT (ab_test_id, version, date, hour) DO UPDATE SET participants = %s.participants + %%s, conversions = %s.conversions + %%s; - """ % (table_name, table_name, table_name) - - cursor.execute(query, [ - ab_test.id, - version, - date, - hour, - participants, - conversions, - participants, - conversions - ]) + """ # noqa: UP031 - percent format is fine here + % ( + table_name, + table_name, + table_name, + ) + ) + + cursor.execute( + query, + [ + ab_test.id, + version, + date, + hour, + participants, + conversions, + participants, + conversions, + ], + ) else: - # Fall back to running two queries (with small potential for race conditions if things run slowly) + # Fall back to running two queries. This is less efficient. + # We cannot use the simpler update_or_create here + # because it holds a lock on the row for the duration + # it takes to run the update query hourly_log, created = cls.objects.get_or_create( ab_test=ab_test, version=version, date=date, hour=hour, defaults={ - 'participants': participants, - 'conversions': conversions, - } + "participants": participants, + "conversions": conversions, + }, ) if not created: - hourly_log.participants += participants - hourly_log.conversions += conversions - hourly_log.save(update_fields=['participants', 'conversions']) + hourly_log.participants = models.F("participants") + participants + hourly_log.conversions = models.F("conversions") + conversions + hourly_log.save(update_fields=["participants", "conversions"]) class Meta: - ordering = ['ab_test', 'version', 'date', 'hour'] + ordering = ["ab_test", "version", "date", "hour"] unique_together = [ - ('ab_test', 'version', 'date', 'hour'), + ("ab_test", "version", "date", "hour"), ] @receiver(page_unpublished) def cancel_on_page_unpublish(instance, **kwargs): - for ab_test in AbTest.objects.filter(page=instance, status__in=[AbTest.STATUS_DRAFT, AbTest.STATUS_RUNNING, AbTest.STATUS_PAUSED]): + for ab_test in AbTest.objects.filter( + page=instance, + status__in=[AbTest.STATUS_DRAFT, AbTest.STATUS_RUNNING, AbTest.STATUS_PAUSED], + ): ab_test.cancel() for ab_test in AbTest.objects.filter(page=instance, status=AbTest.STATUS_FINISHED): diff --git a/wagtail_ab_testing/templates/wagtail_ab_testing/_compat/report.html b/wagtail_ab_testing/templates/wagtail_ab_testing/_compat/report.html new file mode 100644 index 0000000..9749fcc --- /dev/null +++ b/wagtail_ab_testing/templates/wagtail_ab_testing/_compat/report.html @@ -0,0 +1,52 @@ +{% extends 'wagtailadmin/reports/base_report.html' %} +{% load i18n wagtailadmin_tags %} + +{% block results %} + {% if object_list %} + + + + + + + + + + + {% for ab_test in object_list %} + + + + + + + {% endfor %} + +
+ {% trans 'Start date' %} + + {% trans 'Page title' %} + + {% trans 'Test name' %} + + {% trans 'Status' %} +
+ {% trans "Not started" as not_started_str %} + {{ ab_test.first_started_at|default:not_started_str }} + + + {{ ab_test.page.get_admin_display_title }} + + + + {{ ab_test.name }} + + + + {{ ab_test.get_status_description }} + +
+ {% else %} +

{% trans "No A/B tests have been created yet" %}

+ {% endif %} +{% endblock %} diff --git a/wagtail_ab_testing/templates/wagtail_ab_testing/report.html b/wagtail_ab_testing/templates/wagtail_ab_testing/report.html index 9749fcc..a7cb76f 100644 --- a/wagtail_ab_testing/templates/wagtail_ab_testing/report.html +++ b/wagtail_ab_testing/templates/wagtail_ab_testing/report.html @@ -1,52 +1,52 @@ -{% extends 'wagtailadmin/reports/base_report.html' %} +{% extends 'wagtailadmin/reports/base_report_results.html' %} {% load i18n wagtailadmin_tags %} {% block results %} - {% if object_list %} - - +
+ + + + + + + + + + {% for ab_test in object_list %} - - - - + + + + - - - {% for ab_test in object_list %} - - - - - - - {% endfor %} - -
+ {% trans 'Start date' %} + + {% trans 'Page title' %} + + {% trans 'Test name' %} + + {% trans 'Status' %} +
- {% trans 'Start date' %} - - {% trans 'Page title' %} - - {% trans 'Test name' %} - - {% trans 'Status' %} - + {% trans "Not started" as not_started_str %} + {{ ab_test.first_started_at|default:not_started_str }} + + + {{ ab_test.page.get_admin_display_title }} + + + + {{ ab_test.name }} + + + + {{ ab_test.get_status_description }} + +
- {% trans "Not started" as not_started_str %} - {{ ab_test.first_started_at|default:not_started_str }} - - - {{ ab_test.page.get_admin_display_title }} - - - - {{ ab_test.name }} - - - - {{ ab_test.get_status_description }} - -
- {% else %} -

{% trans "No A/B tests have been created yet" %}

- {% endif %} + {% endfor %} + + {% endblock %} + +{% block no_results_message %} +

{% trans "No A/B tests have been created yet" %}

+{% endblock no_results_message %} diff --git a/wagtail_ab_testing/templatetags/wagtail_ab_testing_tags.py b/wagtail_ab_testing/templatetags/wagtail_ab_testing_tags.py index c1c3430..9df5673 100644 --- a/wagtail_ab_testing/templatetags/wagtail_ab_testing_tags.py +++ b/wagtail_ab_testing/templatetags/wagtail_ab_testing_tags.py @@ -7,10 +7,10 @@ register = template.Library() -@register.inclusion_tag('wagtail_ab_testing/script.html', takes_context=True) +@register.inclusion_tag("wagtail_ab_testing/script.html", takes_context=True) def wagtail_ab_testing_script(context): - request = context['request'] - serving_variant = getattr(request, 'wagtail_ab_testing_serving_variant', False) + request = context["request"] + serving_variant = getattr(request, "wagtail_ab_testing_serving_variant", False) track = request_is_trackable(request) if not track: @@ -19,8 +19,8 @@ def wagtail_ab_testing_script(context): "tracking_parameters": None, } - register_participant_url = reverse('wagtail_ab_testing:register_participant') - goal_reached_url = reverse('wagtail_ab_testing:goal_reached') + register_participant_url = reverse("wagtail_ab_testing:register_participant") + goal_reached_url = reverse("wagtail_ab_testing:goal_reached") tracking_parameters = { "urls": { @@ -29,21 +29,23 @@ def wagtail_ab_testing_script(context): }, } - page = context.get('page', None) + page = context.get("page", None) page_id = page.id if page else None if page_id: tracking_parameters["pageId"] = page_id version = AbTest.VERSION_VARIANT if serving_variant else AbTest.VERSION_CONTROL - test = getattr(request, 'wagtail_ab_testing_test', None) + test = getattr(request, "wagtail_ab_testing_test", None) - if (test and version): + if test and version: tracking_parameters["testId"] = test.id tracking_parameters["version"] = version tracking_parameters["goalEvent"] = test.goal_event - tracking_parameters["goalPageId"] = test.goal_page.id if test.goal_page else None + tracking_parameters["goalPageId"] = ( + test.goal_page.id if test.goal_page else None + ) return { - 'track': track, - 'tracking_parameters': tracking_parameters, + "track": track, + "tracking_parameters": tracking_parameters, } diff --git a/wagtail_ab_testing/test/migrations/0001_initial.py b/wagtail_ab_testing/test/migrations/0001_initial.py index b4ab41f..cd4fc00 100644 --- a/wagtail_ab_testing/test/migrations/0001_initial.py +++ b/wagtail_ab_testing/test/migrations/0001_initial.py @@ -1,26 +1,35 @@ # Generated by Django 3.1.3 on 2020-11-03 17:03 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): - initial = True dependencies = [ - ('wagtailcore', '0052_pagelogentry'), + ("wagtailcore", "0052_pagelogentry"), ] operations = [ migrations.CreateModel( - name='SimplePage', + name="SimplePage", fields=[ - ('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')), + ( + "page_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="wagtailcore.page", + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, - bases=('wagtailcore.page',), + bases=("wagtailcore.page",), ), ] diff --git a/wagtail_ab_testing/test/settings.py b/wagtail_ab_testing/test/settings.py index 5a4edc2..794d60f 100644 --- a/wagtail_ab_testing/test/settings.py +++ b/wagtail_ab_testing/test/settings.py @@ -11,6 +11,7 @@ """ import os + import dj_database_url # Build paths inside the project like this: os.path.join(PROJECT_DIR, ...) @@ -103,8 +104,8 @@ # while search isn't used in the tests, wagtail.core checks for the presence of it # because it's in INSTALLED_APPS WAGTAILSEARCH_BACKENDS = { - 'default': { - 'BACKEND': 'wagtail.search.backends.database', + "default": { + "BACKEND": "wagtail.search.backends.database", } } diff --git a/wagtail_ab_testing/test/tests/test_abtest_model.py b/wagtail_ab_testing/test/tests/test_abtest_model.py index 89bc2d6..d8dec86 100644 --- a/wagtail_ab_testing/test/tests/test_abtest_model.py +++ b/wagtail_ab_testing/test/tests/test_abtest_model.py @@ -1,14 +1,14 @@ import datetime +from django.db.models.deletion import ProtectedError from django.test import TestCase from freezegun import freeze_time -from django.db.models.deletion import ProtectedError from wagtail.models import Page from wagtail_ab_testing.models import AbTest, AbTestHourlyLog -@freeze_time('2020-11-04T22:37:00Z') +@freeze_time("2020-11-04T22:37:00Z") class TestAbTestModel(TestCase): def setUp(self): home_page = Page.objects.get(id=2) @@ -104,7 +104,13 @@ def test_log_conversion(self): self.assertEqual(log.participants, 0) self.assertEqual(log.conversions, 2) - def set_up_test(self, control_participants, control_conversions, variant_participants, variant_conversions): + def set_up_test( + self, + control_participants, + control_conversions, + variant_participants, + variant_conversions, + ): AbTestHourlyLog.objects.create( ab_test=self.ab_test, version=AbTest.VERSION_CONTROL, diff --git a/wagtail_ab_testing/test/tests/test_add_abtest.py b/wagtail_ab_testing/test/tests/test_add_abtest.py index 5cb01ac..4f3bb8c 100644 --- a/wagtail_ab_testing/test/tests/test_add_abtest.py +++ b/wagtail_ab_testing/test/tests/test_add_abtest.py @@ -4,8 +4,6 @@ from django.contrib.contenttypes.models import ContentType from django.test import TestCase from django.urls import reverse - -from wagtail import VERSION as WAGTAIL_VERSION from wagtail.models import Page from wagtail.test.utils import WagtailTestUtils @@ -21,30 +19,49 @@ def setUp(self): # Convert the user into an moderator self.moderators_group = Group.objects.get(name="Moderators") - for permission in Permission.objects.filter(content_type=ContentType.objects.get_for_model(AbTest)): + for permission in Permission.objects.filter( + content_type=ContentType.objects.get_for_model(AbTest) + ): self.moderators_group.permissions.add(permission) self.user.is_superuser = False self.user.groups.add(self.moderators_group) self.user.save() # Create test page with a draft revision - self.page = Page.objects.get(id=1).add_child(instance=SimplePage(title="Test", slug="test")) + self.page = Page.objects.get(id=1).add_child( + instance=SimplePage(title="Test", slug="test") + ) self.page.save_revision().publish() def test_shows_on_page_edit(self): - response = self.client.get(reverse('wagtailadmin_pages:edit', args=[self.page.id])) + response = self.client.get( + reverse("wagtailadmin_pages:edit", args=[self.page.id]) + ) self.assertContains(response, "Save and create A/B Test") def test_click_on_page_edit(self): - response = self.client.post(reverse('wagtailadmin_pages:edit', args=[self.page.id]), { - 'title': "Test version", - 'slug': "test", - 'create-ab-test': '', - }) - self.assertRedirects(response, reverse('wagtail_ab_testing_admin:add_ab_test_compare', args=[self.page.id])) + response = self.client.post( + reverse("wagtailadmin_pages:edit", args=[self.page.id]), + { + "title": "Test version", + "slug": "test", + "create-ab-test": "", + }, + ) + self.assertRedirects( + response, + reverse( + "wagtail_ab_testing_admin:add_ab_test_compare", args=[self.page.id] + ), + ) def test_doesnt_show_on_page_create(self): - response = self.client.get(reverse('wagtailadmin_pages:add', args=["wagtail_ab_testing_test", "simplepage", self.page.id])) + response = self.client.get( + reverse( + "wagtailadmin_pages:add", + args=["wagtail_ab_testing_test", "simplepage", self.page.id], + ) + ) self.assertNotContains(response, "Save and create A/B Test") @@ -57,7 +74,7 @@ def test_without_page_edit_permission(self): assert_permission_denied(self, response) def test_without_add_abtest_permission(self): - add_abtest_permission = Permission.objects.get(codename='add_abtest') + add_abtest_permission = Permission.objects.get(codename="add_abtest") self.moderators_group.permissions.remove(add_abtest_permission) response = self.get(self.page.id) @@ -76,19 +93,25 @@ def test_with_existing_draft_abtest(self): self._create_abtest(AbTest.STATUS_DRAFT) response = self.get(self.page.id) - self.assertRedirects(response, reverse('wagtailadmin_pages:edit', args=[self.page.id])) + self.assertRedirects( + response, reverse("wagtailadmin_pages:edit", args=[self.page.id]) + ) def test_with_existing_running_abtest(self): self._create_abtest(AbTest.STATUS_RUNNING) response = self.get(self.page.id) - self.assertRedirects(response, reverse('wagtailadmin_pages:edit', args=[self.page.id])) + self.assertRedirects( + response, reverse("wagtailadmin_pages:edit", args=[self.page.id]) + ) def test_with_existing_paused_abtest(self): self._create_abtest(AbTest.STATUS_PAUSED) response = self.get(self.page.id) - self.assertRedirects(response, reverse('wagtailadmin_pages:edit', args=[self.page.id])) + self.assertRedirects( + response, reverse("wagtailadmin_pages:edit", args=[self.page.id]) + ) def test_with_existing_cancelled_abtest(self): self._create_abtest(AbTest.STATUS_CANCELLED) @@ -100,7 +123,9 @@ def test_with_existing_finished_abtest(self): self._create_abtest(AbTest.STATUS_FINISHED) response = self.get(self.page.id) - self.assertRedirects(response, reverse('wagtailadmin_pages:edit', args=[self.page.id])) + self.assertRedirects( + response, reverse("wagtailadmin_pages:edit", args=[self.page.id]) + ) def test_with_existing_completed_abtest(self): self._create_abtest(AbTest.STATUS_COMPLETED) @@ -115,20 +140,26 @@ def setUp(self): # Convert the user into an moderator self.moderators_group = Group.objects.get(name="Moderators") - for permission in Permission.objects.filter(content_type=ContentType.objects.get_for_model(AbTest)): + for permission in Permission.objects.filter( + content_type=ContentType.objects.get_for_model(AbTest) + ): self.moderators_group.permissions.add(permission) self.user.is_superuser = False self.user.groups.add(self.moderators_group) self.user.save() # Create test page with a draft revision - self.page = Page.objects.get(id=1).add_child(instance=SimplePage(title="Test", slug="test")) + self.page = Page.objects.get(id=1).add_child( + instance=SimplePage(title="Test", slug="test") + ) self.page.save_revision().publish() self.page.title = "Test version" self.latest_revision = self.page.save_revision() def get(self, page_id): - return self.client.get(reverse('wagtail_ab_testing_admin:add_ab_test_compare', args=[page_id])) + return self.client.get( + reverse("wagtail_ab_testing_admin:add_ab_test_compare", args=[page_id]) + ) def test_get_add_compare(self): response = self.get(self.page.id) @@ -141,84 +172,110 @@ def setUp(self): # Convert the user into an moderator self.moderators_group = Group.objects.get(name="Moderators") - for permission in Permission.objects.filter(content_type=ContentType.objects.get_for_model(AbTest)): + for permission in Permission.objects.filter( + content_type=ContentType.objects.get_for_model(AbTest) + ): self.moderators_group.permissions.add(permission) self.user.is_superuser = False self.user.groups.add(self.moderators_group) self.user.save() # Create test page with a draft revision - self.page = Page.objects.get(id=1).add_child(instance=SimplePage(title="Test", slug="test")) + self.page = Page.objects.get(id=1).add_child( + instance=SimplePage(title="Test", slug="test") + ) self.page.save_revision().publish() self.page.title = "Test version" self.latest_revision = self.page.save_revision() def get(self, page_id): - return self.client.get(reverse('wagtail_ab_testing_admin:add_ab_test_form', args=[page_id])) + return self.client.get( + reverse("wagtail_ab_testing_admin:add_ab_test_form", args=[page_id]) + ) def test_get_add_form(self): response = self.get(self.page.id) self.assertEqual(response.status_code, 200) - self.assertEqual(json.loads(response.context['goal_selector_props']), { - "goalTypesByPageType": { - "wagtailcore.page": [{"slug": "visit-page", "name": "Visit page"}], - "wagtail_ab_testing_test.simplepage": [{"slug": "visit-page", "name": "Visit page"}] + self.assertEqual( + json.loads(response.context["goal_selector_props"]), + { + "goalTypesByPageType": { + "wagtailcore.page": [{"slug": "visit-page", "name": "Visit page"}], + "wagtail_ab_testing_test.simplepage": [ + {"slug": "visit-page", "name": "Visit page"} + ], + }, + "globalGoalTypes": [{"name": "Global Event", "slug": "global-event"}], }, - "globalGoalTypes": [ - {'name': 'Global Event', 'slug': 'global-event'} - ] - }) + ) def test_post_add_form(self): - response = self.client.post(reverse('wagtail_ab_testing_admin:add_ab_test_form', args=[self.page.id]), { - 'name': 'Test', - 'hypothesis': 'Does changing the title to "Donate now!" increase donations?', - 'goal_event': 'visit-page', - 'goal_page': '', - 'sample_size': '100' - }) - self.assertRedirects(response, reverse('wagtailadmin_pages:edit', args=[self.page.id])) + response = self.client.post( + reverse("wagtail_ab_testing_admin:add_ab_test_form", args=[self.page.id]), + { + "name": "Test", + "hypothesis": 'Does changing the title to "Donate now!" increase donations?', + "goal_event": "visit-page", + "goal_page": "", + "sample_size": "100", + }, + ) + self.assertRedirects( + response, reverse("wagtailadmin_pages:edit", args=[self.page.id]) + ) ab_test = AbTest.objects.get() self.assertEqual(ab_test.page, self.page.page_ptr) self.assertEqual(ab_test.variant_revision, self.latest_revision) - self.assertEqual(ab_test.name, 'Test') - self.assertEqual(ab_test.hypothesis, 'Does changing the title to "Donate now!" increase donations?') - self.assertEqual(ab_test.goal_event, 'visit-page') + self.assertEqual(ab_test.name, "Test") + self.assertEqual( + ab_test.hypothesis, + 'Does changing the title to "Donate now!" increase donations?', + ) + self.assertEqual(ab_test.goal_event, "visit-page") self.assertIsNone(ab_test.goal_page) self.assertEqual(ab_test.sample_size, 100) self.assertEqual(ab_test.created_by, self.user) self.assertEqual(ab_test.status, AbTest.STATUS_DRAFT) def test_post_add_form_start(self): - response = self.client.post(reverse('wagtail_ab_testing_admin:add_ab_test_form', args=[self.page.id]), { - 'name': 'Test', - 'hypothesis': 'Does changing the title to "Donate now!" increase donations?', - 'goal_event': 'visit-page', - 'goal_page': '', - 'sample_size': '100', - 'start': 'on' - }) - self.assertRedirects(response, reverse('wagtailadmin_pages:edit', args=[self.page.id])) + response = self.client.post( + reverse("wagtail_ab_testing_admin:add_ab_test_form", args=[self.page.id]), + { + "name": "Test", + "hypothesis": 'Does changing the title to "Donate now!" increase donations?', + "goal_event": "visit-page", + "goal_page": "", + "sample_size": "100", + "start": "on", + }, + ) + self.assertRedirects( + response, reverse("wagtailadmin_pages:edit", args=[self.page.id]) + ) ab_test = AbTest.objects.get() self.assertEqual(ab_test.status, AbTest.STATUS_RUNNING) def test_post_add_form_start_without_publish_permission(self): - if WAGTAIL_VERSION >= (5, 1): - self.moderators_group.page_permissions.filter(permission__codename='publish_page').delete() - else: - self.moderators_group.page_permissions.filter(permission_type='publish').delete() - - response = self.client.post(reverse('wagtail_ab_testing_admin:add_ab_test_form', args=[self.page.id]), { - 'name': 'Test', - 'hypothesis': 'Does changing the title to "Donate now!" increase donations?', - 'goal_event': 'visit-page', - 'goal_page': '', - 'sample_size': '100', - 'start': 'on' - }) - self.assertRedirects(response, reverse('wagtailadmin_pages:edit', args=[self.page.id])) + self.moderators_group.page_permissions.filter( + permission__codename="publish_page" + ).delete() + + response = self.client.post( + reverse("wagtail_ab_testing_admin:add_ab_test_form", args=[self.page.id]), + { + "name": "Test", + "hypothesis": 'Does changing the title to "Donate now!" increase donations?', + "goal_event": "visit-page", + "goal_page": "", + "sample_size": "100", + "start": "on", + }, + ) + self.assertRedirects( + response, reverse("wagtailadmin_pages:edit", args=[self.page.id]) + ) ab_test = AbTest.objects.get() self.assertEqual(ab_test.status, AbTest.STATUS_DRAFT) diff --git a/wagtail_ab_testing/test/tests/test_api.py b/wagtail_ab_testing/test/tests/test_api.py index ebb7c1b..5daea1f 100644 --- a/wagtail_ab_testing/test/tests/test_api.py +++ b/wagtail_ab_testing/test/tests/test_api.py @@ -3,7 +3,6 @@ from django.urls import reverse from freezegun import freeze_time from rest_framework.test import APITestCase - from wagtail.models import Page from wagtail_ab_testing.models import AbTest @@ -13,7 +12,9 @@ class TestAbTestsListingAPI(APITestCase): def setUp(self): # Create test page with a draft revision - self.page = Page.objects.get(id=2).add_child(instance=SimplePage(title="Test", slug="test")) + self.page = Page.objects.get(id=2).add_child( + instance=SimplePage(title="Test", slug="test") + ) self.page.save_revision().publish() # Create an A/B test @@ -23,67 +24,57 @@ def setUp(self): variant_revision=self.page.get_latest_revision(), status=AbTest.STATUS_RUNNING, goal_page_id=2, - goal_event='visit-page', + goal_event="visit-page", sample_size=100, ) def test_get_list(self): - response = self.client.get(reverse('ab_testing_api:abtest-list')) - - self.assertEqual(response.json(), [ - { - 'id': self.ab_test.id, - 'site': { - 'id': self.page.get_site().id, - 'hostname': 'localhost', - }, - 'page': { - 'id': self.page.id, - 'path': '/test/' - }, - 'goal': { - 'page': { - 'id': 2, - 'path': '/' + response = self.client.get(reverse("ab_testing_api:abtest-list")) + + self.assertEqual( + response.json(), + [ + { + "id": self.ab_test.id, + "site": { + "id": self.page.get_site().id, + "hostname": "localhost", }, - 'event': 'visit-page' - }, - 'variant_html_url': f'/abtestingapi/tests/{self.ab_test.id}/serve_variant/', - 'add_participant_url': f'/abtestingapi/tests/{self.ab_test.id}/add_participant/', - 'log_conversion_url': f'/abtestingapi/tests/{self.ab_test.id}/log_conversion/' - } - ]) + "page": {"id": self.page.id, "path": "/test/"}, + "goal": {"page": {"id": 2, "path": "/"}, "event": "visit-page"}, + "variant_html_url": f"/abtestingapi/tests/{self.ab_test.id}/serve_variant/", + "add_participant_url": f"/abtestingapi/tests/{self.ab_test.id}/add_participant/", + "log_conversion_url": f"/abtestingapi/tests/{self.ab_test.id}/log_conversion/", + } + ], + ) def test_get_detail(self): - response = self.client.get(reverse('ab_testing_api:abtest-detail', args=[self.ab_test.id])) + response = self.client.get( + reverse("ab_testing_api:abtest-detail", args=[self.ab_test.id]) + ) - self.assertEqual(response.json(), { - 'id': self.ab_test.id, - 'site': { - 'id': self.page.get_site().id, - 'hostname': 'localhost', - }, - 'page': { - 'id': self.page.id, - 'path': '/test/' - }, - 'goal': { - 'page': { - 'id': 2, - 'path': '/' + self.assertEqual( + response.json(), + { + "id": self.ab_test.id, + "site": { + "id": self.page.get_site().id, + "hostname": "localhost", }, - 'event': 'visit-page' + "page": {"id": self.page.id, "path": "/test/"}, + "goal": {"page": {"id": 2, "path": "/"}, "event": "visit-page"}, + "variant_html_url": f"/abtestingapi/tests/{self.ab_test.id}/serve_variant/", + "add_participant_url": f"/abtestingapi/tests/{self.ab_test.id}/add_participant/", + "log_conversion_url": f"/abtestingapi/tests/{self.ab_test.id}/log_conversion/", }, - 'variant_html_url': f'/abtestingapi/tests/{self.ab_test.id}/serve_variant/', - 'add_participant_url': f'/abtestingapi/tests/{self.ab_test.id}/add_participant/', - 'log_conversion_url': f'/abtestingapi/tests/{self.ab_test.id}/log_conversion/' - }) + ) def test_doesnt_show_draft(self): self.ab_test.status = AbTest.STATUS_DRAFT self.ab_test.save() - response = self.client.get(reverse('ab_testing_api:abtest-list')) + response = self.client.get(reverse("ab_testing_api:abtest-list")) self.assertEqual(response.json(), []) @@ -91,7 +82,7 @@ def test_doesnt_show_paused(self): self.ab_test.status = AbTest.STATUS_PAUSED self.ab_test.save() - response = self.client.get(reverse('ab_testing_api:abtest-list')) + response = self.client.get(reverse("ab_testing_api:abtest-list")) self.assertEqual(response.json(), []) @@ -99,7 +90,7 @@ def test_doesnt_show_cancelled(self): self.ab_test.status = AbTest.STATUS_CANCELLED self.ab_test.save() - response = self.client.get(reverse('ab_testing_api:abtest-list')) + response = self.client.get(reverse("ab_testing_api:abtest-list")) self.assertEqual(response.json(), []) @@ -107,7 +98,7 @@ def test_doesnt_show_completed(self): self.ab_test.status = AbTest.STATUS_COMPLETED self.ab_test.save() - response = self.client.get(reverse('ab_testing_api:abtest-list')) + response = self.client.get(reverse("ab_testing_api:abtest-list")) self.assertEqual(response.json(), []) @@ -115,7 +106,9 @@ def test_doesnt_show_completed(self): class TestServeVariantAPI(APITestCase): def setUp(self): # Create test page with a draft revision - self.page = Page.objects.get(id=2).add_child(instance=Page(title="Test", slug="test")) + self.page = Page.objects.get(id=2).add_child( + instance=Page(title="Test", slug="test") + ) self.page.title = "Changed title" self.page.save_revision() @@ -126,22 +119,26 @@ def setUp(self): variant_revision=self.page.get_latest_revision(), status=AbTest.STATUS_RUNNING, goal_page_id=2, - goal_event='visit-page', + goal_event="visit-page", sample_size=100, ) def test_serve_variant(self): - response = self.client.get(reverse('ab_testing_api:abtest-serve-variant', args=[self.ab_test.id])) + response = self.client.get( + reverse("ab_testing_api:abtest-serve-variant", args=[self.ab_test.id]) + ) self.assertEqual(response.status_code, 200) self.assertContains(response, "Changed title") -@freeze_time('2020-11-04T22:37:00Z') +@freeze_time("2020-11-04T22:37:00Z") class TestAddParticipantAPI(APITestCase): def setUp(self): # Create test page with a draft revision - self.page = Page.objects.get(id=2).add_child(instance=Page(title="Test", slug="test")) + self.page = Page.objects.get(id=2).add_child( + instance=Page(title="Test", slug="test") + ) self.page.title = "Changed title" self.page.save_revision() @@ -152,7 +149,7 @@ def setUp(self): variant_revision=self.page.get_latest_revision(), status=AbTest.STATUS_RUNNING, goal_page_id=2, - goal_event='visit-page', + goal_event="visit-page", sample_size=100, ) @@ -161,16 +158,17 @@ def test_add_participant(self): # This will make the new participant use control self.ab_test.add_participant(AbTest.VERSION_VARIANT) - response = self.client.post(reverse('ab_testing_api:abtest-add-participant', args=[self.ab_test.id])) + response = self.client.post( + reverse("ab_testing_api:abtest-add-participant", args=[self.ab_test.id]) + ) self.assertEqual(response.status_code, 201) - self.assertEqual(response.json(), { - 'version': 'control', - 'test_finished': False - }) + self.assertEqual( + response.json(), {"version": "control", "test_finished": False} + ) # This should've created a history log - log = self.ab_test.hourly_logs.order_by('id').last() + log = self.ab_test.hourly_logs.order_by("id").last() self.assertEqual(log.date, datetime.date(2020, 11, 4)) self.assertEqual(log.hour, 22) @@ -187,20 +185,21 @@ def test_add_participant_finish(self): self.ab_test.sample_size = 2 self.ab_test.save() - response = self.client.post(reverse('ab_testing_api:abtest-add-participant', args=[self.ab_test.id])) + response = self.client.post( + reverse("ab_testing_api:abtest-add-participant", args=[self.ab_test.id]) + ) self.assertEqual(response.status_code, 201) - self.assertEqual(response.json(), { - 'version': 'control', - 'test_finished': True - }) + self.assertEqual(response.json(), {"version": "control", "test_finished": True}) -@freeze_time('2020-11-04T22:37:00Z') +@freeze_time("2020-11-04T22:37:00Z") class TestLogConversionAPI(APITestCase): def setUp(self): # Create test page with a draft revision - self.page = Page.objects.get(id=2).add_child(instance=Page(title="Test", slug="test")) + self.page = Page.objects.get(id=2).add_child( + instance=Page(title="Test", slug="test") + ) self.page.title = "Changed title" self.page.save_revision() @@ -211,14 +210,15 @@ def setUp(self): variant_revision=self.page.get_latest_revision(), status=AbTest.STATUS_RUNNING, goal_page_id=2, - goal_event='visit-page', + goal_event="visit-page", sample_size=100, ) def test_log_conversion_for_control(self): - response = self.client.post(reverse('ab_testing_api:abtest-log-conversion', args=[self.ab_test.id]), { - 'version': 'control' - }) + response = self.client.post( + reverse("ab_testing_api:abtest-log-conversion", args=[self.ab_test.id]), + {"version": "control"}, + ) self.assertEqual(response.status_code, 201) self.assertEqual(response.json(), {}) @@ -233,9 +233,10 @@ def test_log_conversion_for_control(self): self.assertEqual(log.conversions, 1) def test_log_conversion_for_variant(self): - response = self.client.post(reverse('ab_testing_api:abtest-log-conversion', args=[self.ab_test.id]), { - 'version': 'variant' - }) + response = self.client.post( + reverse("ab_testing_api:abtest-log-conversion", args=[self.ab_test.id]), + {"version": "variant"}, + ) self.assertEqual(response.status_code, 201) self.assertEqual(response.json(), {}) @@ -250,9 +251,10 @@ def test_log_conversion_for_variant(self): self.assertEqual(log.conversions, 1) def test_log_conversion_for_something_else(self): - response = self.client.post(reverse('ab_testing_api:abtest-log-conversion', args=[self.ab_test.id]), { - 'version': 'something-else' - }) + response = self.client.post( + reverse("ab_testing_api:abtest-log-conversion", args=[self.ab_test.id]), + {"version": "something-else"}, + ) self.assertEqual(response.status_code, 400) self.assertEqual(response.json(), {}) diff --git a/wagtail_ab_testing/test/tests/test_compare_draft.py b/wagtail_ab_testing/test/tests/test_compare_draft.py index 9dadbb6..687cfbe 100644 --- a/wagtail_ab_testing/test/tests/test_compare_draft.py +++ b/wagtail_ab_testing/test/tests/test_compare_draft.py @@ -1,6 +1,5 @@ from django.test import TestCase from django.urls import reverse - from wagtail.models import Page from wagtail.test.utils import WagtailTestUtils @@ -13,7 +12,9 @@ def setUp(self): self.user = self.login() # Create test page with a draft revision - self.page = Page.objects.get(id=1).add_child(instance=SimplePage(title="Test", slug="test")) + self.page = Page.objects.get(id=1).add_child( + instance=SimplePage(title="Test", slug="test") + ) self.page.save_revision().publish() # Create an A/B test @@ -26,6 +27,8 @@ def setUp(self): ) def test_get_compare_draft(self): - response = self.client.get(reverse('wagtail_ab_testing_admin:compare_draft', args=[self.page.id])) + response = self.client.get( + reverse("wagtail_ab_testing_admin:compare_draft", args=[self.page.id]) + ) self.assertTemplateUsed(response, "wagtail_ab_testing/compare.html") diff --git a/wagtail_ab_testing/test/tests/test_migrations.py b/wagtail_ab_testing/test/tests/test_migrations.py index 1b9951a..71e1d37 100644 --- a/wagtail_ab_testing/test/tests/test_migrations.py +++ b/wagtail_ab_testing/test/tests/test_migrations.py @@ -1,4 +1,5 @@ from io import StringIO + from django.core import management from django.test import TestCase diff --git a/wagtail_ab_testing/test/tests/test_progress.py b/wagtail_ab_testing/test/tests/test_progress.py index 85a24cf..7a88596 100644 --- a/wagtail_ab_testing/test/tests/test_progress.py +++ b/wagtail_ab_testing/test/tests/test_progress.py @@ -1,10 +1,7 @@ from django.contrib.auth.models import Group, Permission - from django.contrib.contenttypes.models import ContentType from django.test import TestCase from django.urls import reverse - -from wagtail import VERSION as WAGTAIL_VERSION from wagtail.models import Page from wagtail.test.utils import WagtailTestUtils @@ -18,14 +15,18 @@ def setUp(self): # Convert the user into an moderator self.moderators_group = Group.objects.get(name="Moderators") - for permission in Permission.objects.filter(content_type=ContentType.objects.get_for_model(AbTest)): + for permission in Permission.objects.filter( + content_type=ContentType.objects.get_for_model(AbTest) + ): self.moderators_group.permissions.add(permission) self.user.is_superuser = False self.user.groups.add(self.moderators_group) self.user.save() # Create test page with a draft revision - self.page = Page.objects.get(id=1).add_child(instance=SimplePage(title="Test", slug="test")) + self.page = Page.objects.get(id=1).add_child( + instance=SimplePage(title="Test", slug="test") + ) self.page.save_revision().publish() # Edit the page and save a revision @@ -42,7 +43,9 @@ def setUp(self): ) def test_get_progress(self): - response = self.client.get(reverse('wagtailadmin_pages:edit', args=[self.page.id])) + response = self.client.get( + reverse("wagtailadmin_pages:edit", args=[self.page.id]) + ) self.assertNotContains(response, "Save draft") self.assertTemplateUsed(response, "wagtail_ab_testing/progress.html") @@ -51,22 +54,32 @@ def test_post_start(self): self.ab_test.status = AbTest.STATUS_DRAFT self.ab_test.save() - response = self.client.post(reverse('wagtailadmin_pages:edit', args=[self.page.id]), { - 'action-start-ab-test': 'on', - }) + response = self.client.post( + reverse("wagtailadmin_pages:edit", args=[self.page.id]), + { + "action-start-ab-test": "on", + }, + ) - self.assertRedirects(response, reverse('wagtailadmin_pages:edit', args=[self.page.id])) + self.assertRedirects( + response, reverse("wagtailadmin_pages:edit", args=[self.page.id]) + ) self.ab_test.refresh_from_db() self.assertEqual(self.ab_test.status, AbTest.STATUS_RUNNING) def test_post_pause(self): - response = self.client.post(reverse('wagtailadmin_pages:edit', args=[self.page.id]), { - 'action-pause-ab-test': 'on', - }) + response = self.client.post( + reverse("wagtailadmin_pages:edit", args=[self.page.id]), + { + "action-pause-ab-test": "on", + }, + ) - self.assertRedirects(response, reverse('wagtailadmin_pages:edit', args=[self.page.id])) + self.assertRedirects( + response, reverse("wagtailadmin_pages:edit", args=[self.page.id]) + ) self.ab_test.refresh_from_db() @@ -76,91 +89,117 @@ def test_post_restart(self): self.ab_test.status = AbTest.STATUS_PAUSED self.ab_test.save() - response = self.client.post(reverse('wagtailadmin_pages:edit', args=[self.page.id]), { - 'action-restart-ab-test': 'on', - }) + response = self.client.post( + reverse("wagtailadmin_pages:edit", args=[self.page.id]), + { + "action-restart-ab-test": "on", + }, + ) - self.assertRedirects(response, reverse('wagtailadmin_pages:edit', args=[self.page.id])) + self.assertRedirects( + response, reverse("wagtailadmin_pages:edit", args=[self.page.id]) + ) self.ab_test.refresh_from_db() self.assertEqual(self.ab_test.status, AbTest.STATUS_RUNNING) def test_post_end(self): - response = self.client.post(reverse('wagtailadmin_pages:edit', args=[self.page.id]), { - 'action-end-ab-test': 'on', - }) + response = self.client.post( + reverse("wagtailadmin_pages:edit", args=[self.page.id]), + { + "action-end-ab-test": "on", + }, + ) - self.assertRedirects(response, reverse('wagtailadmin_pages:edit', args=[self.page.id])) + self.assertRedirects( + response, reverse("wagtailadmin_pages:edit", args=[self.page.id]) + ) self.ab_test.refresh_from_db() self.assertEqual(self.ab_test.status, AbTest.STATUS_CANCELLED) def test_post_start_without_publish_permission(self): - if WAGTAIL_VERSION >= (5, 1): - self.moderators_group.page_permissions.filter(permission__codename='publish_page').delete() - else: - self.moderators_group.page_permissions.filter(permission_type='publish').delete() + self.moderators_group.page_permissions.filter( + permission__codename="publish_page" + ).delete() self.ab_test.status = AbTest.STATUS_DRAFT self.ab_test.save() - response = self.client.post(reverse('wagtailadmin_pages:edit', args=[self.page.id]), { - 'action-start-ab-test': 'on', - }) + response = self.client.post( + reverse("wagtailadmin_pages:edit", args=[self.page.id]), + { + "action-start-ab-test": "on", + }, + ) - self.assertRedirects(response, reverse('wagtailadmin_pages:edit', args=[self.page.id])) + self.assertRedirects( + response, reverse("wagtailadmin_pages:edit", args=[self.page.id]) + ) self.ab_test.refresh_from_db() self.assertEqual(self.ab_test.status, AbTest.STATUS_DRAFT) def test_post_pause_without_publish_permission(self): - if WAGTAIL_VERSION >= (5, 1): - self.moderators_group.page_permissions.filter(permission__codename='publish_page').delete() - else: - self.moderators_group.page_permissions.filter(permission_type='publish').delete() - - response = self.client.post(reverse('wagtailadmin_pages:edit', args=[self.page.id]), { - 'action-pause-ab-test': 'on', - }) + self.moderators_group.page_permissions.filter( + permission__codename="publish_page" + ).delete() + + response = self.client.post( + reverse("wagtailadmin_pages:edit", args=[self.page.id]), + { + "action-pause-ab-test": "on", + }, + ) - self.assertRedirects(response, reverse('wagtailadmin_pages:edit', args=[self.page.id])) + self.assertRedirects( + response, reverse("wagtailadmin_pages:edit", args=[self.page.id]) + ) self.ab_test.refresh_from_db() self.assertEqual(self.ab_test.status, AbTest.STATUS_RUNNING) def test_post_restart_without_publish_permission(self): - if WAGTAIL_VERSION >= (5, 1): - self.moderators_group.page_permissions.filter(permission__codename='publish_page').delete() - else: - self.moderators_group.page_permissions.filter(permission_type='publish').delete() + self.moderators_group.page_permissions.filter( + permission__codename="publish_page" + ).delete() self.ab_test.status = AbTest.STATUS_PAUSED self.ab_test.save() - response = self.client.post(reverse('wagtailadmin_pages:edit', args=[self.page.id]), { - 'action-restart-ab-test': 'on', - }) + response = self.client.post( + reverse("wagtailadmin_pages:edit", args=[self.page.id]), + { + "action-restart-ab-test": "on", + }, + ) - self.assertRedirects(response, reverse('wagtailadmin_pages:edit', args=[self.page.id])) + self.assertRedirects( + response, reverse("wagtailadmin_pages:edit", args=[self.page.id]) + ) self.ab_test.refresh_from_db() self.assertEqual(self.ab_test.status, AbTest.STATUS_PAUSED) def test_post_end_without_publish_permission(self): - if WAGTAIL_VERSION >= (5, 1): - self.moderators_group.page_permissions.filter(permission__codename='publish_page').delete() - else: - self.moderators_group.page_permissions.filter(permission_type='publish').delete() - - response = self.client.post(reverse('wagtailadmin_pages:edit', args=[self.page.id]), { - 'action-end-ab-test': 'on', - }) + self.moderators_group.page_permissions.filter( + permission__codename="publish_page" + ).delete() + + response = self.client.post( + reverse("wagtailadmin_pages:edit", args=[self.page.id]), + { + "action-end-ab-test": "on", + }, + ) - self.assertRedirects(response, reverse('wagtailadmin_pages:edit', args=[self.page.id])) + self.assertRedirects( + response, reverse("wagtailadmin_pages:edit", args=[self.page.id]) + ) self.ab_test.refresh_from_db() self.assertEqual(self.ab_test.status, AbTest.STATUS_RUNNING) @@ -169,11 +208,16 @@ def test_post_end_when_finished(self): self.ab_test.status = AbTest.STATUS_FINISHED self.ab_test.save() - response = self.client.post(reverse('wagtailadmin_pages:edit', args=[self.page.id]), { - 'action-end-ab-test': 'on', - }) + response = self.client.post( + reverse("wagtailadmin_pages:edit", args=[self.page.id]), + { + "action-end-ab-test": "on", + }, + ) - self.assertRedirects(response, reverse('wagtailadmin_pages:edit', args=[self.page.id])) + self.assertRedirects( + response, reverse("wagtailadmin_pages:edit", args=[self.page.id]) + ) self.ab_test.refresh_from_db() @@ -185,11 +229,16 @@ def test_post_select_control(self): self.ab_test.status = AbTest.STATUS_FINISHED self.ab_test.save() - response = self.client.post(reverse('wagtailadmin_pages:edit', args=[self.page.id]), { - 'action-select-control': 'on', - }) + response = self.client.post( + reverse("wagtailadmin_pages:edit", args=[self.page.id]), + { + "action-select-control": "on", + }, + ) - self.assertRedirects(response, reverse('wagtailadmin_pages:edit', args=[self.page.id])) + self.assertRedirects( + response, reverse("wagtailadmin_pages:edit", args=[self.page.id]) + ) self.ab_test.refresh_from_db() self.assertEqual(self.ab_test.status, AbTest.STATUS_COMPLETED) @@ -200,11 +249,16 @@ def test_post_select_variant(self): self.ab_test.status = AbTest.STATUS_FINISHED self.ab_test.save() - response = self.client.post(reverse('wagtailadmin_pages:edit', args=[self.page.id]), { - 'action-select-variant': 'on', - }) + response = self.client.post( + reverse("wagtailadmin_pages:edit", args=[self.page.id]), + { + "action-select-variant": "on", + }, + ) - self.assertRedirects(response, reverse('wagtailadmin_pages:edit', args=[self.page.id])) + self.assertRedirects( + response, reverse("wagtailadmin_pages:edit", args=[self.page.id]) + ) self.ab_test.refresh_from_db() self.assertEqual(self.ab_test.status, AbTest.STATUS_COMPLETED) diff --git a/wagtail_ab_testing/test/tests/test_report.py b/wagtail_ab_testing/test/tests/test_report.py index e131721..ccc3473 100644 --- a/wagtail_ab_testing/test/tests/test_report.py +++ b/wagtail_ab_testing/test/tests/test_report.py @@ -2,7 +2,7 @@ from django.contrib.contenttypes.models import ContentType from django.test import TestCase from django.urls import reverse - +from wagtail import VERSION as WAGTAIL_VERSION from wagtail.models import Page from wagtail.test.utils import WagtailTestUtils @@ -16,14 +16,18 @@ def setUp(self): # Convert the user into an moderator self.moderators_group = Group.objects.get(name="Moderators") - for permission in Permission.objects.filter(content_type=ContentType.objects.get_for_model(AbTest)): + for permission in Permission.objects.filter( + content_type=ContentType.objects.get_for_model(AbTest) + ): self.moderators_group.permissions.add(permission) self.user.is_superuser = False self.user.groups.add(self.moderators_group) self.user.save() # Create test page with a draft revision - self.page = Page.objects.get(id=1).add_child(instance=SimplePage(title="Test", slug="test")) + self.page = Page.objects.get(id=1).add_child( + instance=SimplePage(title="Test", slug="test") + ) self.page.save_revision().publish() # Create an A/B test @@ -36,6 +40,10 @@ def setUp(self): ) def test_get_report(self): - response = self.client.get(reverse('wagtail_ab_testing_admin:report')) + response = self.client.get(reverse("wagtail_ab_testing_admin:report")) - self.assertTemplateUsed(response, "wagtail_ab_testing/report.html") + # TODO: compatibility: remove this check when dropping support for < 6.2 + if WAGTAIL_VERSION < (6, 2): + self.assertTemplateUsed(response, "wagtail_ab_testing/_compat/report.html") + else: + self.assertTemplateUsed(response, "wagtail_ab_testing/report.html") diff --git a/wagtail_ab_testing/test/tests/test_results.py b/wagtail_ab_testing/test/tests/test_results.py index bade4a5..d2fccb0 100644 --- a/wagtail_ab_testing/test/tests/test_results.py +++ b/wagtail_ab_testing/test/tests/test_results.py @@ -2,7 +2,6 @@ from django.contrib.contenttypes.models import ContentType from django.test import TestCase from django.urls import reverse - from wagtail.models import Page from wagtail.test.utils import WagtailTestUtils @@ -16,14 +15,18 @@ def setUp(self): # Convert the user into an moderator self.moderators_group = Group.objects.get(name="Moderators") - for permission in Permission.objects.filter(content_type=ContentType.objects.get_for_model(AbTest)): + for permission in Permission.objects.filter( + content_type=ContentType.objects.get_for_model(AbTest) + ): self.moderators_group.permissions.add(permission) self.user.is_superuser = False self.user.groups.add(self.moderators_group) self.user.save() # Create test page with a draft revision - self.page = Page.objects.get(id=1).add_child(instance=SimplePage(title="Test", slug="test")) + self.page = Page.objects.get(id=1).add_child( + instance=SimplePage(title="Test", slug="test") + ) self.page.save_revision().publish() # Create an A/B test @@ -36,6 +39,10 @@ def setUp(self): ) def test_get_results(self): - response = self.client.get(reverse('wagtail_ab_testing_admin:results', args=[self.page.id, self.ab_test.id])) + response = self.client.get( + reverse( + "wagtail_ab_testing_admin:results", args=[self.page.id, self.ab_test.id] + ) + ) self.assertTemplateUsed(response, "wagtail_ab_testing/results.html") diff --git a/wagtail_ab_testing/test/tests/test_serve.py b/wagtail_ab_testing/test/tests/test_serve.py index bbf2fa2..ff6192c 100644 --- a/wagtail_ab_testing/test/tests/test_serve.py +++ b/wagtail_ab_testing/test/tests/test_serve.py @@ -1,5 +1,4 @@ from django.test import TestCase, override_settings - from wagtail.models import Page from wagtail_ab_testing.models import AbTest @@ -15,24 +14,30 @@ def setUp(self): name="Test", variant_revision=revision, goal_event="visit-page", - goal_page=self.home_page.add_child(instance=Page(title="Goal", slug="goal")), + goal_page=self.home_page.add_child( + instance=Page(title="Goal", slug="goal") + ), sample_size=10, status=AbTest.STATUS_RUNNING, ) def test_serves_control_from_cookie(self): - self.client.cookies[f'wagtail-ab-testing_{self.ab_test.id}_version'] = AbTest.VERSION_CONTROL + self.client.cookies[f"wagtail-ab-testing_{self.ab_test.id}_version"] = ( + AbTest.VERSION_CONTROL + ) - response = self.client.get('/') + response = self.client.get("/") self.assertEqual(response.status_code, 200) self.assertContains(response, "Welcome to your new Wagtail site!") self.assertNotContains(response, "Changed title") def test_serves_variant_from_cookie(self): - self.client.cookies[f'wagtail-ab-testing_{self.ab_test.id}_version'] = AbTest.VERSION_VARIANT + self.client.cookies[f"wagtail-ab-testing_{self.ab_test.id}_version"] = ( + AbTest.VERSION_VARIANT + ) - response = self.client.get('/') + response = self.client.get("/") self.assertEqual(response.status_code, 200) self.assertNotContains(response, "Welcome to your new Wagtail site!") @@ -43,7 +48,7 @@ def test_serves_control_to_new_participant(self): # This will make the new participant use control to balance the numbers self.ab_test.add_participant(AbTest.VERSION_VARIANT) - response = self.client.get('/') + response = self.client.get("/") self.assertEqual(response.status_code, 200) self.assertContains(response, "Welcome to your new Wagtail site!") @@ -54,7 +59,7 @@ def test_serves_variant_to_new_participant(self): # This will make the new participant use variant to balance the numbers self.ab_test.add_participant(AbTest.VERSION_CONTROL) - response = self.client.get('/') + response = self.client.get("/") self.assertEqual(response.status_code, 200) self.assertNotContains(response, "Welcome to your new Wagtail site!") @@ -68,13 +73,15 @@ def test_serves_control_when_paused(self): # This time, the next viewer will still see the control as the test is not running self.ab_test.add_participant(AbTest.VERSION_CONTROL) - response = self.client.get('/') + response = self.client.get("/") self.assertEqual(response.status_code, 200) self.assertContains(response, "Welcome to your new Wagtail site!") self.assertNotContains(response, "Changed title") - self.assertNotIn(f'wagtail-ab-testing_{self.ab_test.id}_version', self.client.session) + self.assertNotIn( + f"wagtail-ab-testing_{self.ab_test.id}_version", self.client.session + ) def test_doesnt_track_bots(self): # Add a participant for control @@ -82,47 +89,59 @@ def test_doesnt_track_bots(self): self.ab_test.add_participant(AbTest.VERSION_CONTROL) response = self.client.get( - '/', - HTTP_USER_AGENT='Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' + "/", + HTTP_USER_AGENT="Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)", ) self.assertEqual(response.status_code, 200) # The control should be served self.assertContains(response, "Welcome to your new Wagtail site!") self.assertNotContains(response, "Changed title") - self.assertNotIn(f'wagtail-ab-testing_{self.ab_test.id}_version', self.client.session) + self.assertNotIn( + f"wagtail-ab-testing_{self.ab_test.id}_version", self.client.session + ) def test_doesnt_track_dnt_users(self): # Add a participant for control # This will make it serve the variant if it does incorrectly decide to track the user self.ab_test.add_participant(AbTest.VERSION_CONTROL) - response = self.client.get('/', HTTP_DNT='1') + response = self.client.get("/", HTTP_DNT="1") self.assertEqual(response.status_code, 200) # The control should be served self.assertContains(response, "Welcome to your new Wagtail site!") self.assertNotContains(response, "Changed title") - self.assertNotIn(f'wagtail-ab-testing_{self.ab_test.id}_version', self.client.session) + self.assertNotIn( + f"wagtail-ab-testing_{self.ab_test.id}_version", self.client.session + ) - @override_settings(WAGTAIL_AB_TESTING_WORKER_TOKEN='abc123') + @override_settings(WAGTAIL_AB_TESTING_WORKER_TOKEN="abc123") def test_serves_dual_response_for_worker(self): - response = self.client.get('/', HTTP_X_REQUESTED_WITH='WagtailAbTestingWorker', HTTP_AUTHORIZATION='Token abc123') + response = self.client.get( + "/", + HTTP_X_REQUESTED_WITH="WagtailAbTestingWorker", + HTTP_AUTHORIZATION="Token abc123", + ) self.assertEqual(response.status_code, 200) - self.assertEqual(response['X-WagtailAbTesting-Test'], str(self.ab_test.id)) + self.assertEqual(response["X-WagtailAbTesting-Test"], str(self.ab_test.id)) response_data = response.json() - self.assertIn("Welcome to your new Wagtail site!", response_data['control']) - self.assertIn("Changed title", response_data['variant']) + self.assertIn("Welcome to your new Wagtail site!", response_data["control"]) + self.assertIn("Changed title", response_data["variant"]) - @override_settings(WAGTAIL_AB_TESTING_WORKER_TOKEN='abc123') + @override_settings(WAGTAIL_AB_TESTING_WORKER_TOKEN="abc123") def test_worker_requires_token(self): - response = self.client.get('/', HTTP_X_REQUESTED_WITH='WagtailAbTestingWorker') + response = self.client.get("/", HTTP_X_REQUESTED_WITH="WagtailAbTestingWorker") self.assertEqual(response.status_code, 403) - @override_settings(WAGTAIL_AB_TESTING_WORKER_TOKEN='abc123') + @override_settings(WAGTAIL_AB_TESTING_WORKER_TOKEN="abc123") def test_worker_requires_correct_token(self): - response = self.client.get('/', HTTP_X_REQUESTED_WITH='WagtailAbTestingWorker', HTTP_AUTHORIZATION='Token wrongtoken') + response = self.client.get( + "/", + HTTP_X_REQUESTED_WITH="WagtailAbTestingWorker", + HTTP_AUTHORIZATION="Token wrongtoken", + ) self.assertEqual(response.status_code, 403) diff --git a/wagtail_ab_testing/test/tests/test_templatetags.py b/wagtail_ab_testing/test/tests/test_templatetags.py index 0c56241..c381b7d 100644 --- a/wagtail_ab_testing/test/tests/test_templatetags.py +++ b/wagtail_ab_testing/test/tests/test_templatetags.py @@ -1,8 +1,7 @@ from unittest.mock import patch -from django.urls import reverse from django.test import RequestFactory, TestCase - +from django.urls import reverse from wagtail.models import Page from wagtail_ab_testing.models import AbTest @@ -150,7 +149,9 @@ def test_ab_testing_script_tag_without_test_in_request( # There is still a test running and this user is being tracked: self.assertEqual(result["track"], True) - self.assertDictEqual(result["tracking_parameters"], expected_tracking_parameters) + self.assertDictEqual( + result["tracking_parameters"], expected_tracking_parameters + ) def test_ab_testing_script_tag_request_without_tracking( self, mock_request_is_callable diff --git a/wagtail_ab_testing/test/tests/test_views.py b/wagtail_ab_testing/test/tests/test_views.py index 1cf365c..fd1c1fa 100644 --- a/wagtail_ab_testing/test/tests/test_views.py +++ b/wagtail_ab_testing/test/tests/test_views.py @@ -4,17 +4,18 @@ from django.urls import reverse from freezegun import freeze_time from rest_framework.test import APIClient, APITestCase - from wagtail.models import Page from wagtail_ab_testing.models import AbTest -@freeze_time('2020-11-04T22:37:00Z') +@freeze_time("2020-11-04T22:37:00Z") class TestRegisterParticipant(APITestCase): def setUp(self): # Create test page with a draft revision - self.page = Page.objects.get(id=2).add_child(instance=Page(title="Test", slug="test")) + self.page = Page.objects.get(id=2).add_child( + instance=Page(title="Test", slug="test") + ) self.page.title = "Changed title" self.page.save_revision() @@ -25,7 +26,7 @@ def setUp(self): variant_revision=self.page.get_latest_revision(), status=AbTest.STATUS_RUNNING, goal_page_id=2, - goal_event='visit-page', + goal_event="visit-page", sample_size=100, ) @@ -35,17 +36,17 @@ def test_register_participant(self): self.ab_test.add_participant(AbTest.VERSION_VARIANT) response = self.client.post( - reverse('wagtail_ab_testing:register_participant'), + reverse("wagtail_ab_testing:register_participant"), { - 'test_id': self.ab_test.id, - 'version': 'control', - } + "test_id": self.ab_test.id, + "version": "control", + }, ) self.assertEqual(response.status_code, 200) # This should've created a history log - log = self.ab_test.hourly_logs.order_by('id').last() + log = self.ab_test.hourly_logs.order_by("id").last() self.assertEqual(log.date, datetime.date(2020, 11, 4)) self.assertEqual(log.hour, 22) @@ -63,11 +64,11 @@ def test_register_participant_finish(self): self.ab_test.save() response = self.client.post( - reverse('wagtail_ab_testing:register_participant'), + reverse("wagtail_ab_testing:register_participant"), { - 'test_id': self.ab_test.id, - 'version': 'control', - } + "test_id": self.ab_test.id, + "version": "control", + }, ) self.assertEqual(response.status_code, 200) @@ -81,26 +82,28 @@ def test_register_participant_authenticated_user(self): client = APIClient(enforce_csrf_checks=True) User = get_user_model() - User.objects.create_user('foo', 'myemail@test.com', 'bar') - client.login(username='foo', password='bar') + User.objects.create_user("foo", "myemail@test.com", "bar") + client.login(username="foo", password="bar") response = client.post( - reverse('wagtail_ab_testing:register_participant'), + reverse("wagtail_ab_testing:register_participant"), { - 'test_id': self.ab_test.id, - 'version': 'control', - } + "test_id": self.ab_test.id, + "version": "control", + }, ) # Shouldn't give 403 error self.assertEqual(response.status_code, 200) -@freeze_time('2020-11-04T22:37:00Z') +@freeze_time("2020-11-04T22:37:00Z") class TestGoalReached(APITestCase): def setUp(self): # Create test page with a draft revision - self.page = Page.objects.get(id=2).add_child(instance=Page(title="Test", slug="test")) + self.page = Page.objects.get(id=2).add_child( + instance=Page(title="Test", slug="test") + ) self.page.title = "Changed title" self.page.save_revision() @@ -111,17 +114,14 @@ def setUp(self): variant_revision=self.page.get_latest_revision(), status=AbTest.STATUS_RUNNING, goal_page_id=2, - goal_event='visit-page', + goal_event="visit-page", sample_size=100, ) def test_log_conversion_for_control(self): response = self.client.post( - reverse('wagtail_ab_testing:goal_reached', args=[]), - { - 'test_id': self.ab_test.id, - 'version': 'control' - } + reverse("wagtail_ab_testing:goal_reached", args=[]), + {"test_id": self.ab_test.id, "version": "control"}, ) self.assertEqual(response.status_code, 200) @@ -137,11 +137,8 @@ def test_log_conversion_for_control(self): def test_log_conversion_for_variant(self): response = self.client.post( - reverse('wagtail_ab_testing:goal_reached', args=[]), - { - 'test_id': self.ab_test.id, - 'version': 'variant' - } + reverse("wagtail_ab_testing:goal_reached", args=[]), + {"test_id": self.ab_test.id, "version": "variant"}, ) self.assertEqual(response.status_code, 200) @@ -157,11 +154,8 @@ def test_log_conversion_for_variant(self): def test_log_conversion_for_something_else(self): response = self.client.post( - reverse('wagtail_ab_testing:goal_reached', args=[]), - { - 'test_id': self.ab_test.id, - 'version': 'something-else' - } + reverse("wagtail_ab_testing:goal_reached", args=[]), + {"test_id": self.ab_test.id, "version": "something-else"}, ) self.assertEqual(response.status_code, 400) @@ -175,15 +169,12 @@ def test_log_conversion_authenticated_user(self): client = APIClient(enforce_csrf_checks=True) User = get_user_model() - User.objects.create_user('foo', 'myemail@test.com', 'bar') - client.login(username='foo', password='bar') + User.objects.create_user("foo", "myemail@test.com", "bar") + client.login(username="foo", password="bar") response = client.post( - reverse('wagtail_ab_testing:goal_reached', args=[]), - { - 'test_id': self.ab_test.id, - 'version': 'control' - } + reverse("wagtail_ab_testing:goal_reached", args=[]), + {"test_id": self.ab_test.id, "version": "control"}, ) # Shouldn't give 403 error diff --git a/wagtail_ab_testing/test/tests/utils.py b/wagtail_ab_testing/test/tests/utils.py index 65b0a2f..3fc5c96 100644 --- a/wagtail_ab_testing/test/tests/utils.py +++ b/wagtail_ab_testing/test/tests/utils.py @@ -4,10 +4,13 @@ def assert_permission_denied(self, response): # Checks for Wagtail's permission denied response - self.assertRedirects(response, reverse('wagtailadmin_home')) + self.assertRedirects(response, reverse("wagtailadmin_home")) raised_messages = [ (message.level_tag, message.message) for message in messages.get_messages(response.wsgi_request) ] - self.assertIn(('error', 'Sorry, you do not have permission to access this area.\n\n\n\n\n'), raised_messages) + self.assertIn( + ("error", "Sorry, you do not have permission to access this area.\n\n\n\n\n"), + raised_messages, + ) diff --git a/wagtail_ab_testing/test/urls.py b/wagtail_ab_testing/test/urls.py index 4ae1d5e..f4e3e54 100644 --- a/wagtail_ab_testing/test/urls.py +++ b/wagtail_ab_testing/test/urls.py @@ -1,6 +1,5 @@ -from django.urls import include, path from django.contrib import admin - +from django.urls import include, path from wagtail import urls as wagtail_urls from wagtail.admin import urls as wagtailadmin_urls from wagtail.documents import urls as wagtaildocs_urls @@ -8,13 +7,11 @@ from wagtail_ab_testing import api as ab_testing_api from wagtail_ab_testing import urls as ab_testing_urls - urlpatterns = [ path("django-admin/", admin.site.urls), path("admin/", include(wagtailadmin_urls)), path("documents/", include(wagtaildocs_urls)), - path('abtestingapi/', include(ab_testing_api, namespace='ab_testing_api')), - path('abtesting/', include(ab_testing_urls, namespace='wagtail_ab_testing')), - + path("abtestingapi/", include(ab_testing_api, namespace="ab_testing_api")), + path("abtesting/", include(ab_testing_urls, namespace="wagtail_ab_testing")), path("", include(wagtail_urls)), ] diff --git a/wagtail_ab_testing/test/wagtail_hooks.py b/wagtail_ab_testing/test/wagtail_hooks.py index 7450a3b..a074a75 100644 --- a/wagtail_ab_testing/test/wagtail_hooks.py +++ b/wagtail_ab_testing/test/wagtail_hooks.py @@ -8,8 +8,8 @@ class GlobalEvent(BaseEvent): requires_page = False -@hooks.register('register_ab_testing_event_types') +@hooks.register("register_ab_testing_event_types") def register_submit_form_event_type(): return { - 'global-event': GlobalEvent(), + "global-event": GlobalEvent(), } diff --git a/wagtail_ab_testing/urls.py b/wagtail_ab_testing/urls.py index 532b1fa..b9b81ff 100644 --- a/wagtail_ab_testing/urls.py +++ b/wagtail_ab_testing/urls.py @@ -2,8 +2,10 @@ from . import views -app_name = 'wagtail_ab_testing' +app_name = "wagtail_ab_testing" urlpatterns = [ - path('register-participant/', views.register_participant, name='register_participant'), - path('goal-reached/', views.goal_reached, name='goal_reached'), + path( + "register-participant/", views.register_participant, name="register_participant" + ), + path("goal-reached/", views.goal_reached, name="goal_reached"), ] diff --git a/wagtail_ab_testing/utils.py b/wagtail_ab_testing/utils.py index a4ace2a..e4b448f 100644 --- a/wagtail_ab_testing/utils.py +++ b/wagtail_ab_testing/utils.py @@ -13,13 +13,13 @@ def request_is_trackable(request): Returns True if we can track the specified request. """ # Don't track anyone with HTTP Do-Not-Track enabled - if 'HTTP_DNT' in request.META and request.META['HTTP_DNT'] == '1': + if "HTTP_DNT" in request.META and request.META["HTTP_DNT"] == "1": return False # Don't track bots - ua_string = request.META.get('HTTP_USER_AGENT', '') + ua_string = request.META.get("HTTP_USER_AGENT", "") if not isinstance(ua_string, str): - ua_string = ua_string.decode('utf-8', 'ignore') + ua_string = ua_string.decode("utf-8", "ignore") if is_bot(ua_string): return False diff --git a/wagtail_ab_testing/views.py b/wagtail_ab_testing/views.py index 2663e3f..0501c9a 100644 --- a/wagtail_ab_testing/views.py +++ b/wagtail_ab_testing/views.py @@ -1,17 +1,18 @@ import datetime import json +import django_filters from django import forms from django.core.exceptions import PermissionDenied -from django.db.models import Sum, Q, F from django.core.serializers.json import DjangoJSONEncoder +from django.db.models import F, Q, Sum from django.shortcuts import get_object_or_404, redirect, render from django.template.loader import render_to_string from django.utils import formats, timezone from django.utils.functional import cached_property -from django.utils.translation import gettext as _, gettext_lazy +from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy from django.views.decorators.csrf import csrf_exempt -import django_filters from django_filters.constants import EMPTY_VALUES from rest_framework import status from rest_framework.decorators import ( @@ -20,14 +21,15 @@ permission_classes, ) from rest_framework.response import Response +from wagtail import VERSION as WAGTAIL_VERSION from wagtail.admin import messages, panels from wagtail.admin.action_menu import ActionMenuItem from wagtail.admin.filters import DateRangePickerWidget, WagtailFilterSet from wagtail.admin.views.reports import ReportView -from wagtail.models import Page, PAGE_MODEL_CLASSES +from wagtail.models import PAGE_MODEL_CLASSES, Page -from .models import AbTest from .events import get_event_types +from .models import AbTest class CreateAbTestForm(forms.ModelForm): @@ -318,8 +320,7 @@ def render_html(self): "default_menu_item": self.default_item.render_html(self.context), "show_menu": bool(self.menu_items), "rendered_menu_items": [ - menu_item.render_html(self.context) - for menu_item in self.menu_items + menu_item.render_html(self.context) for menu_item in self.menu_items ], }, request=self.request, @@ -401,12 +402,16 @@ def get_progress_and_results_common_context(request, page, ab_test): ) # Format stats for display - control_conversions_percent = formats.localize( - round(control_conversions / control_participants * 100, 1) - ) if control_participants else 0 - variant_conversions_percent = formats.localize( - round(variant_conversions / variant_participants * 100, 1) - ) if variant_conversions else 0 + control_conversions_percent = ( + formats.localize(round(control_conversions / control_participants * 100, 1)) + if control_participants + else 0 + ) + variant_conversions_percent = ( + formats.localize(round(variant_conversions / variant_participants * 100, 1)) + if variant_conversions + else 0 + ) return { "page": page, @@ -601,6 +606,9 @@ def filter(self, qs, value): class AbTestingReportFilterSet(WagtailFilterSet): + name = django_filters.CharFilter( + lookup_expr="icontains", label=gettext_lazy("Name") + ) page = SearchPageTitleFilter() first_started_at = django_filters.DateFromToRangeFilter( label=gettext_lazy("Started at"), widget=DateRangePickerWidget @@ -608,16 +616,36 @@ class AbTestingReportFilterSet(WagtailFilterSet): class Meta: model = AbTest - fields = ["status", "page", "first_started_at"] + fields = ["name", "status", "page", "first_started_at"] class AbTestingReportView(ReportView): - template_name = "wagtail_ab_testing/report.html" title = gettext_lazy("A/B testing") - header_icon = "" + index_results_url_name = "wagtail_ab_testing_admin:report_results" + index_url_name = "wagtail_ab_testing_admin:report" + results_template_name = "wagtail_ab_testing/report.html" + header_icon = "people-arrows" filterset_class = AbTestingReportFilterSet + @property + def template_name(self): + # Upgrade consideration: https://docs.wagtail.org/en/stable/releases/6.2.html#adjust-the-templates + # TODO: compatibility: remove `template_name` getter once Wagtail 6.2 + # is the minimum supported version + + # If we are on Wagtail 6.1 or below, we need to provide the 'old'-style report template + if WAGTAIL_VERSION < (6, 2): + return "wagtail_ab_testing/_compat/report.html" + + return ReportView.template_name + + @property + # TODO: compatibility: replace `title` attribute with `page_title` and + # delete this getter once Wagtail 6.2 is the minimum supported version + def page_title(self): + return self.title + def get_queryset(self): return AbTest.objects.all().order_by( F("first_started_at").desc(nulls_first=True) @@ -650,9 +678,7 @@ def register_participant(request): if version not in [AbTest.VERSION_CONTROL, AbTest.VERSION_VARIANT]: return Response( - "version must be either '{}' or '{}'".format( - AbTest.VERSION_CONTROL, AbTest.VERSION_VARIANT - ), + f"version must be either '{AbTest.VERSION_CONTROL}' or '{AbTest.VERSION_VARIANT}'", status=status.HTTP_400_BAD_REQUEST, ) @@ -679,9 +705,7 @@ def goal_reached(request): if version not in [AbTest.VERSION_CONTROL, AbTest.VERSION_VARIANT]: return Response( - "version must be either '{}' or '{}'".format( - AbTest.VERSION_CONTROL, AbTest.VERSION_VARIANT - ), + f"version must be either '{AbTest.VERSION_CONTROL}' or '{AbTest.VERSION_VARIANT}'", status=status.HTTP_400_BAD_REQUEST, ) diff --git a/wagtail_ab_testing/wagtail_hooks.py b/wagtail_ab_testing/wagtail_hooks.py index cc2441d..657b622 100644 --- a/wagtail_ab_testing/wagtail_hooks.py +++ b/wagtail_ab_testing/wagtail_hooks.py @@ -5,12 +5,11 @@ from django.core.exceptions import PermissionDenied from django.http import JsonResponse from django.shortcuts import redirect -from django.urls import path, include, reverse -from django.utils.html import format_html, escapejs -from django.utils.translation import gettext as _, gettext_lazy as __ +from django.urls import include, path, reverse +from django.utils.html import escapejs, format_html +from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as __ from django.views.i18n import JavaScriptCatalog - - from wagtail import hooks from wagtail.admin.action_menu import ActionMenuItem from wagtail.admin.menu import MenuItem @@ -25,12 +24,23 @@ @hooks.register("register_admin_urls") def register_admin_urls(): urls = [ - path('jsi18n/', JavaScriptCatalog.as_view(packages=['wagtail_ab_testing']), name='javascript_catalog'), - path('add//compare/', views.add_compare, name='add_ab_test_compare'), - path('/compare-draft/', views.compare_draft, name='compare_draft'), - path('add//', views.add_form, name='add_ab_test_form'), - path('report/', views.AbTestingReportView.as_view(), name='report'), - path('results///', views.results, name='results'), + path( + "jsi18n/", + JavaScriptCatalog.as_view(packages=["wagtail_ab_testing"]), + name="javascript_catalog", + ), + path( + "add//compare/", views.add_compare, name="add_ab_test_compare" + ), + path("/compare-draft/", views.compare_draft, name="compare_draft"), + path("add//", views.add_form, name="add_ab_test_form"), + path("report/", views.AbTestingReportView.as_view(), name="report"), + path( + "report/results/", + views.AbTestingReportView.as_view(results_only=True), + name="report_results", + ), + path("results///", views.results, name="results"), ] return [ @@ -45,80 +55,94 @@ def register_admin_urls(): class CreateAbTestActionMenuItem(ActionMenuItem): - name = 'create-ab-test' + name = "create-ab-test" label = __("Save and create A/B Test") - icon_name = 'people-arrows' + icon_name = "people-arrows" def is_shown(self, context): - if context['view'] != 'edit': + if context["view"] != "edit": return False # User must have permission to add A/B tests - if not self.check_user_permissions(context['request'].user): + if not self.check_user_permissions(context["request"].user): return False return True @staticmethod def check_user_permissions(user): - return user.has_perm('wagtail_ab_testing.add_abtest') + return user.has_perm("wagtail_ab_testing.add_abtest") -@hooks.register('register_page_action_menu_item') +@hooks.register("register_page_action_menu_item") def register_create_abtest_action_menu_item(): return CreateAbTestActionMenuItem(order=100) # This is the only way to inject custom JS into the editor with knowledge of the page being edited class AbTestingTabActionMenuItem(ActionMenuItem): - def render_html(self, context): - if 'page' in context: - return self.format_html(context['request'].user, context) + if "page" in context: + return self.format_html(context["request"].user, context) - return '' + return "" @staticmethod def format_html(user, context): return format_html( '', - reverse('wagtail_ab_testing_admin:javascript_catalog'), - versioned_static('wagtail_ab_testing/js/wagtail-ab-testing.js'), - escapejs(json.dumps({ - 'tests': [ + reverse("wagtail_ab_testing_admin:javascript_catalog"), + versioned_static("wagtail_ab_testing/js/wagtail-ab-testing.js"), + escapejs( + json.dumps( { - 'id': ab_test.id, - 'name': ab_test.name, - 'started_at': ab_test.first_started_at.strftime(DATE_FORMAT) if ab_test.first_started_at else _("Not started"), - 'status': ab_test.get_status_description(), - 'results_url': reverse('wagtail_ab_testing_admin:results', args=[ab_test.page_id, ab_test.id]), + "tests": [ + { + "id": ab_test.id, + "name": ab_test.name, + "started_at": ( + ab_test.first_started_at.strftime(DATE_FORMAT) + if ab_test.first_started_at + else _("Not started") + ), + "status": ab_test.get_status_description(), + "results_url": reverse( + "wagtail_ab_testing_admin:results", + args=[ab_test.page_id, ab_test.id], + ), + } + for ab_test in AbTest.objects.filter( + page=context["page"] + ).order_by("-id") + ], + "can_create_abtest": user.has_perm( + "wagtail_ab_testing.add_abtest" + ), } - for ab_test in AbTest.objects.filter(page=context['page']).order_by('-id') - ], - 'can_create_abtest': user.has_perm('wagtail_ab_testing.add_abtest'), - })) + ) + ), ) -@hooks.register('register_page_action_menu_item') +@hooks.register("register_page_action_menu_item") def register_ab_testing_tab_action_menu_item(): return AbTestingTabActionMenuItem() -@hooks.register('after_edit_page') +@hooks.register("after_edit_page") def redirect_to_create_ab_test(request, page): - if 'create-ab-test' in request.POST: - return redirect('wagtail_ab_testing_admin:add_ab_test_compare', page.id) + if "create-ab-test" in request.POST: + return redirect("wagtail_ab_testing_admin:add_ab_test_compare", page.id) -@hooks.register('before_edit_page') +@hooks.register("before_edit_page") def check_for_running_ab_test(request, page): running_experiment = AbTest.objects.get_current_for_page(page=page) if running_experiment: return views.progress(request, page, running_experiment) -@hooks.register('before_serve_page') +@hooks.register("before_serve_page") def before_serve_page(page, request, serve_args, serve_kwargs): # Check if the user is trackable if not request_is_trackable(request): @@ -135,8 +159,11 @@ def before_serve_page(page, request, serve_args, serve_kwargs): # If this request is coming from a frontend worker, return both the control and variant versions # The worker will decide which version to serve to the user - if request.META.get('HTTP_X_REQUESTED_WITH') == 'WagtailAbTestingWorker': - if request.META.get('HTTP_AUTHORIZATION', '') != 'Token ' + settings.WAGTAIL_AB_TESTING_WORKER_TOKEN: + if request.META.get("HTTP_X_REQUESTED_WITH") == "WagtailAbTestingWorker": + if ( + request.META.get("HTTP_AUTHORIZATION", "") + != "Token " + settings.WAGTAIL_AB_TESTING_WORKER_TOKEN + ): raise PermissionDenied control_response = page.serve(request, *serve_args, **serve_kwargs) @@ -147,23 +174,27 @@ def before_serve_page(page, request, serve_args, serve_kwargs): request.wagtail_ab_testing_serving_variant = True - variant_response = test.variant_revision.as_object().serve(request, *serve_args, **serve_kwargs) + variant_response = test.variant_revision.as_object().serve( + request, *serve_args, **serve_kwargs + ) if hasattr(variant_response, "render"): variant_response.render() - response = JsonResponse({ - 'control': control_response.content.decode('utf-8'), - 'variant': variant_response.content.decode('utf-8'), - }) + response = JsonResponse( + { + "control": control_response.content.decode("utf-8"), + "variant": variant_response.content.decode("utf-8"), + } + ) - response['X-WagtailAbTesting-Test'] = str(test.id) + response["X-WagtailAbTesting-Test"] = str(test.id) return response # If the user visiting is a participant, show them the same version they saw before - if f'wagtail-ab-testing_{test.id}_version' in request.COOKIES: - version = request.COOKIES[f'wagtail-ab-testing_{test.id}_version'] + if f"wagtail-ab-testing_{test.id}_version" in request.COOKIES: + version = request.COOKIES[f"wagtail-ab-testing_{test.id}_version"] else: # Otherwise, show them the version of the page that the next participant should see. # Note: In order to exclude bots, the browser must call a JavaScript API to sign up as a participant @@ -173,27 +204,35 @@ def before_serve_page(page, request, serve_args, serve_kwargs): # If the user should be shown the variant, serve that from the revision. Otherwise return to keep the control if version == AbTest.VERSION_VARIANT: request.wagtail_ab_testing_serving_variant = True - return test.variant_revision.as_object().serve(request, *serve_args, **serve_kwargs) + return test.variant_revision.as_object().serve( + request, *serve_args, **serve_kwargs + ) class AbTestingReportMenuItem(MenuItem): - def is_shown(self, request): return True -@hooks.register('register_reports_menu_item') +@hooks.register("register_reports_menu_item") def register_ab_testing_report_menu_item(): - return AbTestingReportMenuItem(_('A/B testing'), reverse('wagtail_ab_testing_admin:report'), icon_name='people-arrows', order=1000) + return AbTestingReportMenuItem( + _("A/B testing"), + reverse("wagtail_ab_testing_admin:report"), + icon_name="people-arrows", + order=1000, + ) -@hooks.register('register_icons') +@hooks.register("register_icons") def register_icons(icons): - icons.append('wagtail_ab_testing/icons/people-arrows.svg') - icons.append('wagtail_ab_testing/icons/crown.svg') + icons.append("wagtail_ab_testing/icons/people-arrows.svg") + icons.append("wagtail_ab_testing/icons/crown.svg") return icons -@hooks.register('register_permissions') +@hooks.register("register_permissions") def register_add_abtest_permission(): - return Permission.objects.filter(content_type__app_label='wagtail_ab_testing', codename='add_abtest') + return Permission.objects.filter( + content_type__app_label="wagtail_ab_testing", codename="add_abtest" + )