Compare commits
200 Commits
Author | SHA1 | Date | |
---|---|---|---|
16e24b6791 | |||
477bfb9665 | |||
6108469047 | |||
686f180081 | |||
9b1dbe35cb | |||
7e0594e341 | |||
0c19456053 | |||
18140f76ab | |||
88b17ceeb8 | |||
570de7d128 | |||
b82d5165c3 | |||
8d802dbbf4 | |||
9274073c68 | |||
1f1264cf95 | |||
3f16ad686e | |||
7101b63122 | |||
ffd839159b | |||
d074ef85b9 | |||
f3e403bec6 | |||
137b5b411c | |||
39f3500813 | |||
6a6c3e8d7b | |||
336ed2317c | |||
06569a3cc1 | |||
da6e5127ed | |||
3d054ec585 | |||
43b5b62e60 | |||
40a9959680 | |||
13e13ccae5 | |||
318b65b3eb | |||
6b9b4e0af2 | |||
69a4514129 | |||
585cb0b16a | |||
4ae8a5e60b | |||
d7ad1be51f | |||
bd5b85cedb | |||
956c1bf4f5 | |||
d775ef57e6 | |||
d34c449638 | |||
23af862798 | |||
88263dea60 | |||
2e1e09f60b | |||
86e669e4f4 | |||
807005461e | |||
7f9e0971f5 | |||
a4a1a2ddca | |||
9cc6e966ba | |||
311abeb6c8 | |||
60675203c6 | |||
ceef806301 | |||
650e061f91 | |||
9235932f00 | |||
1e0efc975a | |||
7517dcd051 | |||
94c9efa315 | |||
f73e59421b | |||
f054b86e07 | |||
cbb56847ae | |||
b135e79c77 | |||
5cd8751450 | |||
c07b280276 | |||
28266c4500 | |||
94a5c6b289 | |||
875d8302de | |||
4c09ad4ca7 | |||
0d260a12a4 | |||
7888f0b615 | |||
02e63ed82c | |||
a411ad1ccc | |||
1a1df18bf3 | |||
56d28faec8 | |||
f95b8dcb93 | |||
d3f4d42d82 | |||
4c08581919 | |||
11886ae135 | |||
83cc7f790e | |||
dcdeb4e9a2 | |||
2e827be41a | |||
0f9bfb0343 | |||
1c74e6cfb9 | |||
9c45ac56db | |||
2f7b92fb2e | |||
1e69d929aa | |||
a178a8b533 | |||
f2e01c803a | |||
eb9d4f3e31 | |||
4ceb59c719 | |||
6fcab3ac11 | |||
1f464adaa7 | |||
d15f6c37d3 | |||
7d679d7111 | |||
b11a6ce4ca | |||
4e9a6e902d | |||
3ce0aef8d5 | |||
a47803eca5 | |||
e42e1a865b | |||
293004fdc6 | |||
6f0425cd5f | |||
0fd6d4d2e5 | |||
e0fbefd53f | |||
c87b2936e9 | |||
7010f5acea | |||
d94890848d | |||
f8d226efaf | |||
037381f79f | |||
0f5501ceef | |||
4e5454b348 | |||
9919d76741 | |||
7e9dd8624b | |||
6514bc1763 | |||
65a46f2bd9 | |||
f1b62a7546 | |||
03e02e8b91 | |||
8d8975ac36 | |||
2324a30afd | |||
0bdb80f25a | |||
1c1a7ce1b8 | |||
2a48eb3498 | |||
4ad097b4fa | |||
939247c147 | |||
12f110d913 | |||
c8fe62d2b1 | |||
83c2a4289e | |||
84ac76f33e | |||
f6598ca1f7 | |||
726c0cd70f | |||
4f3f9a4d40 | |||
3a378830e0 | |||
8a151e3bab | |||
bb34bddaf4 | |||
9710d3b479 | |||
5536adc3ec | |||
5b8d578493 | |||
bdba6b65cf | |||
cbcd80d248 | |||
9b1c5a6ab6 | |||
62d258fd9e | |||
32e73329c3 | |||
fde53ea0ef | |||
22a7367211 | |||
0d89d47735 | |||
92189a3be8 | |||
6c9d8b2730 | |||
e141e5396e | |||
c0e2b969e8 | |||
7b5e3d4c9d | |||
6b7a1ed591 | |||
9b25cd2a94 | |||
3a86c189dc | |||
82c26f9772 | |||
03eb812e45 | |||
e3522d0acb | |||
7f5e958ee3 | |||
241bfb5240 | |||
d5df6e0e58 | |||
865efd0792 | |||
454c936e0f | |||
74d3123084 | |||
9bfd816430 | |||
02e06bd9f3 | |||
c7ad3251cf | |||
cb8b7da496 | |||
0efd3ae937 | |||
d335e4fd7b | |||
db2f82967e | |||
37243365a7 | |||
43a2b590b4 | |||
cc1dd337bb | |||
a677846ff7 | |||
7d7861b862 | |||
8e854d0abe | |||
0051061d96 | |||
f898dfe017 | |||
8ced5bd81c | |||
9a86b0c8cc | |||
9408f90789 | |||
ba6056e3f8 | |||
fdc0a7f2e1 | |||
12b0cd9231 | |||
330557be8d | |||
aa917dee9c | |||
364cb1a7e6 | |||
8f789b3e17 | |||
bedbe06c65 | |||
362f15e5ff | |||
8a0dba2efb | |||
59f4877e04 | |||
2ff29cc375 | |||
8527e6ff23 | |||
d7c07cb238 | |||
6e83366df6 | |||
55364f8906 | |||
4fd0b30c66 | |||
c909852b08 | |||
ea1ecc2a98 | |||
0f0aecf673 | |||
c11960f921 | |||
37d49dcdfb | |||
9c88ec1582 | |||
785d1486e4 |
89
.github/workflows/python-test.yml
vendored
Normal file
@ -0,0 +1,89 @@
|
||||
---
|
||||
name: Python Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python 3.8
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.8
|
||||
- name: Install dependencies
|
||||
run: pip install tox
|
||||
- name: Validate formatting
|
||||
run: tox -e format
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
tox_env:
|
||||
- py36-dj22-wt211
|
||||
- py36-dj22-wt212
|
||||
- py36-dj22-wt213
|
||||
- py37-dj22-wt211
|
||||
- py37-dj22-wt212
|
||||
- py37-dj22-wt213
|
||||
- py38-dj22-wt211
|
||||
- py38-dj22-wt212
|
||||
- py38-dj22-wt213
|
||||
- py37-dj30-wt211
|
||||
- py37-dj30-wt212
|
||||
- py37-dj30-wt213
|
||||
- py38-dj30-wt211
|
||||
- py38-dj30-wt212
|
||||
- py38-dj30-wt213
|
||||
include:
|
||||
- python-version: 3.6
|
||||
tox_env: py36-dj22-wt211
|
||||
- python-version: 3.6
|
||||
tox_env: py36-dj22-wt212
|
||||
- python-version: 3.6
|
||||
tox_env: py36-dj22-wt213
|
||||
- python-version: 3.7
|
||||
tox_env: py37-dj22-wt211
|
||||
- python-version: 3.7
|
||||
tox_env: py37-dj22-wt212
|
||||
- python-version: 3.7
|
||||
tox_env: py37-dj22-wt213
|
||||
- python-version: 3.8
|
||||
tox_env: py38-dj22-wt211
|
||||
- python-version: 3.8
|
||||
tox_env: py38-dj22-wt212
|
||||
- python-version: 3.8
|
||||
tox_env: py38-dj22-wt213
|
||||
- python-version: 3.7
|
||||
tox_env: py37-dj30-wt211
|
||||
- python-version: 3.7
|
||||
tox_env: py37-dj30-wt212
|
||||
- python-version: 3.7
|
||||
tox_env: py37-dj30-wt213
|
||||
- python-version: 3.8
|
||||
tox_env: py38-dj30-wt211
|
||||
- python-version: 3.8
|
||||
tox_env: py38-dj30-wt212
|
||||
- python-version: 3.8
|
||||
tox_env: py38-dj30-wt213
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install tox tox-gh-actions
|
||||
- name: Test with tox
|
||||
run: tox -e ${{ matrix.tox_env }} --index-url=https://pypi.python.org/simple/
|
||||
- name: Prepare artifacts
|
||||
run: mkdir -p .coverage-data && mv .coverage.* .coverage-data/
|
||||
- uses: actions/upload-artifact@master
|
||||
with:
|
||||
name: coverage-data
|
||||
path: .coverage-data/
|
3
.gitignore
vendored
@ -13,6 +13,7 @@
|
||||
.vscode/
|
||||
|
||||
build/
|
||||
ve/
|
||||
dist/
|
||||
htmlcov/
|
||||
docs/_build
|
||||
@ -23,3 +24,5 @@ tests/sandbox/assets
|
||||
node_modules
|
||||
|
||||
.DS_Store
|
||||
|
||||
.pytest_cache/
|
||||
|
31
.travis.yml
@ -1,31 +0,0 @@
|
||||
---
|
||||
sudo: false
|
||||
language: python
|
||||
|
||||
matrix:
|
||||
include:
|
||||
- python: 2.7
|
||||
env: lint
|
||||
|
||||
- python: 2.7
|
||||
env: TOXENV=py27-django111-wagtail113
|
||||
|
||||
install:
|
||||
- pip install tox codecov
|
||||
|
||||
script:
|
||||
- tox
|
||||
|
||||
after_success:
|
||||
- tox -e coverage-report
|
||||
- codecov
|
||||
|
||||
deploy:
|
||||
provider: pypi
|
||||
distributions: sdist bdist_wheel
|
||||
user: praekelt.org
|
||||
password:
|
||||
secure: IxPnc95OFNQsl7kFfLlLc38KfHh/W79VXnhEkdb2x1GZznOsG167QlhpAnyXIJysCQxgMMwLMuPOOdk1WIxOpGNM1/M80hNzPAfxMYWPuwposDdwoIc8SyREPJt16BXWAY+rAH8SHxld9p1YdRbOEPSSglODe4dCmQWsCbKjV3aKv+gZxBvf6pMxUglp2fBIlCwrE77MyMYh9iW0AGzD6atC2xN9NgAm4f+2WFlKCUL/MztLhNWdvWEiibQav6Tts7p8tWrsBVzywDW2IOy3O0ihPgRdISZ7QrxhiJTjlHYPAy4eRGOnYSQXvp6Dy8ONE5a6Uv5g3Xw2UmQo85sSMUs2VxR0G7d+PQgL0A7ENBQ5i7eSAFHYs8EswiGilW2A7mM4qRXwg9URLelYSdkM+aNXvR+25dCsXakiO4NjCz/BPgNzxPeQLlBdxR5vHugeM/XYuhy6CHlZrR/uueaO0X8RyhJcqeDjBy58IrwYS3Mpj7QCfBpQ9PqsqXEWV9BKwKiBXM2+3hkhawFDWa0GW2PDbORKtSLy/ORfGLx5Y9qxQYKEGvFQA3iqkTjrLj+KeUziKtuvEAcvsdBIJVIxeoHwdl+qqxEm8A7YuRBnWVxWc3jE6wBXboeFP92gVe/ueoXmY22riK9Ja0pli3TyNga8by9WM8Qp4D2ZqkVXHwg=
|
||||
on:
|
||||
tags: true
|
||||
condition: $TOXENV = py27-django111-wagtail113
|
56
CHANGES
@ -1,3 +1,59 @@
|
||||
0.13.0
|
||||
=================
|
||||
- Merged Praekelt fork
|
||||
- Add custom javascript to segment forms
|
||||
- bugfix:exclude variant returns queryset when params is queryset
|
||||
- Added RulePanel, a subclass of InlinePanel, for Rules
|
||||
- Upgrade to Wagtail > 2.0, drop support for Wagtail < 2
|
||||
|
||||
0.12.0
|
||||
==================
|
||||
- Fix Django version classifier in setup.py
|
||||
|
||||
0.12.0
|
||||
==================
|
||||
- Merged forks of Torchbox and Praekelt
|
||||
- Wagtail 2 compatibility
|
||||
- Makefile adjustments for portability
|
||||
- Adds simple segment forcing for superusers
|
||||
- Fix excluding pages without variant
|
||||
- Fix bug on visiting a segment page in the admin
|
||||
- Use Wagtail's logic in the page count in the dash
|
||||
- Prevent corrected summary item from counting the root page
|
||||
- Delete variants of a page that is being deleted
|
||||
- Add end user and developer documentation
|
||||
- Add an option to show a personalised block to everyone
|
||||
- Add origin country rule (#190)
|
||||
- Return 404 if variant page is accessed directly (#188)
|
||||
- Do not generate sitemap entries for variants (#187)
|
||||
- Remove restrictive wagtail dependency version constraint (#192)
|
||||
|
||||
0.11.3
|
||||
==================
|
||||
- Bugfix: Handle errors when testing an invalid visit count rule
|
||||
|
||||
0.11.2
|
||||
==================
|
||||
- Bugfix: Stop populating static segments when the count is reached
|
||||
|
||||
0.11.1
|
||||
==================
|
||||
- Populate entirely static segments from registered Users not active Sessions
|
||||
|
||||
0.11.0
|
||||
==================
|
||||
- Bug Fix: Query rule should not be static
|
||||
- Enable retrieval of user data for static rules through csv download
|
||||
|
||||
0.10.9
|
||||
==================
|
||||
- Bug Fix: Display the number of users in a static segment on dashboard
|
||||
|
||||
0.10.8
|
||||
==================
|
||||
- Don't add users to exclude list for dynamic segments
|
||||
- Store segments a user is excluded from in the session
|
||||
|
||||
0.10.7
|
||||
==================
|
||||
- Bug Fix: Ensure static segment members are show the survey immediately
|
||||
|
@ -8,3 +8,13 @@ Contributors
|
||||
* Michael van Tellingen
|
||||
* Pim Vernooij
|
||||
* Tomasz Knapik
|
||||
* Kaitlyn Crawford
|
||||
* Todd Dembrey
|
||||
* Nathan Begbie
|
||||
* Rob Moorman
|
||||
* Tom Dyson
|
||||
* Bertrand Bordage
|
||||
* Alex Muller
|
||||
* Saeed Marzban
|
||||
* Milton Madanda
|
||||
* Mike Dingjan
|
||||
|
9
Makefile
@ -1,13 +1,13 @@
|
||||
.PHONY: all clean requirements develop test lint flake8 isort dist sandbox docs
|
||||
|
||||
all: clean requirements dist
|
||||
|
||||
default: develop
|
||||
|
||||
all: clean requirements dist
|
||||
|
||||
clean:
|
||||
find src -name '*.pyc' -delete
|
||||
find tests -name '*.pyc' -delete
|
||||
find . -name '*.egg-info' -delete
|
||||
find . -name '*.egg-info' |xargs rm -rf
|
||||
|
||||
requirements:
|
||||
pip install --upgrade -e .[docs,test]
|
||||
@ -38,7 +38,8 @@ isort:
|
||||
isort --recursive src tests
|
||||
|
||||
dist:
|
||||
./setup.py sdist bdist_wheel
|
||||
pip install wheel
|
||||
python ./setup.py sdist bdist_wheel
|
||||
|
||||
sandbox:
|
||||
pip install -r sandbox/requirements.txt
|
||||
|
36
README.rst
@ -3,17 +3,24 @@
|
||||
.. image:: https://readthedocs.org/projects/wagtail-personalisation/badge/?version=latest
|
||||
:target: http://wagtail-personalisation.readthedocs.io/en/latest/?badge=latest
|
||||
|
||||
.. image:: https://travis-ci.org/LabD/wagtail-personalisation.svg?branch=master
|
||||
:target: https://travis-ci.org/LabD/wagtail-personalisation
|
||||
.. image:: https://travis-ci.org/wagtail/wagtail-personalisation.svg?branch=master
|
||||
:target: https://travis-ci.org/wagtail/wagtail-personalisation
|
||||
|
||||
.. image:: http://codecov.io/github/LabD/wagtail-personalisation/coverage.svg?branch=master
|
||||
:target: http://codecov.io/github/LabD/wagtail-personalisation?branch=master
|
||||
.. image:: http://codecov.io/github/wagtail/wagtail-personalisation/coverage.svg?branch=master
|
||||
:target: http://codecov.io/github/wagtail/wagtail-personalisation?branch=master
|
||||
|
||||
.. image:: https://img.shields.io/pypi/v/wagtail-personalisation.svg
|
||||
:target: https://pypi.python.org/pypi/wagtail-personalisation/
|
||||
|
||||
.. end-no-pypi
|
||||
|
||||
.. image:: logo.png
|
||||
:height: 261
|
||||
:width: 300
|
||||
:scale: 50
|
||||
:alt: Wagxperience
|
||||
:align: center
|
||||
|
||||
Wagtail Personalisation
|
||||
=======================
|
||||
|
||||
@ -24,20 +31,17 @@ in the admin interface.
|
||||
|
||||
.. _Wagtail CMS: http://wagtail.io/
|
||||
|
||||
.. image:: logo.png
|
||||
:scale: 50 %
|
||||
:alt: Wagxperience
|
||||
:align: center
|
||||
|
||||
|
||||
.. image:: screenshot.png
|
||||
|
||||
|
||||
Instructions
|
||||
------------
|
||||
Wagtail Personalisation requires Wagtail 1.9 or 1.10 and Django 1.9, 1.10 or 1.11.
|
||||
Wagtail Personalisation requires Wagtail 2.0 or 2.1 and Django 1.11 or 2.0.
|
||||
|
||||
To install the package with pip::
|
||||
To install the package with pip:
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
pip install wagtail-personalisation
|
||||
|
||||
@ -64,6 +68,16 @@ been added in first, this is a prerequisite for this project.
|
||||
# ...
|
||||
]
|
||||
|
||||
|
||||
Documentation
|
||||
-------------
|
||||
|
||||
You can find more information about installing, extending and using this module
|
||||
on `Read the Docs`_.
|
||||
|
||||
.. _Read the Docs: http://wagtail-personalisation.readthedocs.io
|
||||
|
||||
|
||||
Sandbox
|
||||
-------
|
||||
|
||||
|
BIN
docs/_static/images/dual_streamfield.png
vendored
Normal file
After Width: | Height: | Size: 84 KiB |
BIN
docs/_static/images/edit_segment_rules.png
vendored
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
docs/_static/images/edit_segment_specifics.png
vendored
Normal file
After Width: | Height: | Size: 84 KiB |
BIN
docs/_static/images/editing_variant.png
vendored
Normal file
After Width: | Height: | Size: 83 KiB |
BIN
docs/_static/images/segment_dashboard_header.png
vendored
Normal file
After Width: | Height: | Size: 9.7 KiB |
BIN
docs/_static/images/segment_dashboard_view.png
vendored
Normal file
After Width: | Height: | Size: 91 KiB |
BIN
docs/_static/images/segment_list_view.png
vendored
Normal file
After Width: | Height: | Size: 63 KiB |
BIN
docs/_static/images/single_streamfield.png
vendored
Normal file
After Width: | Height: | Size: 73 KiB |
BIN
docs/_static/images/variants_button.png
vendored
Normal file
After Width: | Height: | Size: 89 KiB |
30
docs/conf.py
@ -17,10 +17,17 @@
|
||||
# add these directories to sys.path here. If the directory is relative to the
|
||||
# documentation root, use os.path.abspath to make it absolute, like shown here.
|
||||
#
|
||||
# import os
|
||||
# import sys
|
||||
# sys.path.insert(0, os.path.abspath('.'))
|
||||
import os
|
||||
import sys
|
||||
|
||||
on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
|
||||
|
||||
if not on_rtd: # only import and set the theme if we're building docs locally
|
||||
import sphinx_rtd_theme
|
||||
html_theme = 'sphinx_rtd_theme'
|
||||
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
|
||||
|
||||
sys.path.insert(0, os.path.abspath('.'))
|
||||
|
||||
# -- General configuration ------------------------------------------------
|
||||
|
||||
@ -47,7 +54,7 @@ master_doc = 'index'
|
||||
|
||||
# General information about the project.
|
||||
project = 'wagtail-personalisation'
|
||||
copyright = '2017, Lab Digital BV'
|
||||
copyright = '2019, Lab Digital BV'
|
||||
author = 'Lab Digital BV'
|
||||
|
||||
# The version info for the project you're documenting, acts as replacement for
|
||||
@ -55,17 +62,17 @@ author = 'Lab Digital BV'
|
||||
# built documents.
|
||||
#
|
||||
# The short X.Y version.
|
||||
version = '0.10.7'
|
||||
version = '0.15.1'
|
||||
|
||||
# The full version, including alpha/beta/rc tags.
|
||||
release = '0.10.7'
|
||||
release = '0.15.1'
|
||||
|
||||
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||
# for a list of supported languages.
|
||||
#
|
||||
# This is also used if you do content translation via gettext catalogs.
|
||||
# Usually you set "language" from the command line for these cases.
|
||||
language = None
|
||||
# language = None
|
||||
|
||||
# List of patterns, relative to source directory, that match files and
|
||||
# directories to ignore when looking for source files.
|
||||
@ -84,7 +91,7 @@ todo_include_todos = False
|
||||
# The theme to use for HTML and HTML Help pages. See the documentation for
|
||||
# a list of builtin themes.
|
||||
#
|
||||
html_theme = 'alabaster'
|
||||
# html_theme = 'alabaster'
|
||||
|
||||
# Theme options are theme-specific and customize the look and feel of a theme
|
||||
# further. For a list of options available for each theme, see the
|
||||
@ -92,14 +99,11 @@ html_theme = 'alabaster'
|
||||
#
|
||||
# html_theme_options = {}
|
||||
html_theme_options = {
|
||||
'github_user': 'LabD',
|
||||
'github_banner': True,
|
||||
'github_repo': 'wagtail-personalisation',
|
||||
'travis_button': True,
|
||||
'codecov_button': True,
|
||||
'analytics_id': 'UA-100203499-2',
|
||||
}
|
||||
|
||||
html_logo = 'logo.png'
|
||||
|
||||
# Add any paths that contain custom static files (such as style sheets) here,
|
||||
# relative to this directory. They are copied after the builtin static files,
|
||||
# so a file named "default.css" will overwrite the builtin "default.css".
|
||||
|
@ -1,6 +1,10 @@
|
||||
Included rules
|
||||
==============
|
||||
|
||||
Wagxperience comes with a base set of rules that allow you to start segmenting
|
||||
your visitors quickly.
|
||||
|
||||
|
||||
Time rule
|
||||
---------
|
||||
|
||||
@ -16,11 +20,12 @@ End time The end time of your time frame.
|
||||
|
||||
``wagtail_personalisation.rules.TimeRule``
|
||||
|
||||
|
||||
Day rule
|
||||
--------
|
||||
|
||||
The day rule allows you to segment visitors based on the day of their visit.
|
||||
Select one or multiple days on which you'd like your segment to be applied.
|
||||
Select one or multiple days on which you would like your segment to be applied.
|
||||
|
||||
================== ==========================================================
|
||||
Option Description
|
||||
@ -36,6 +41,7 @@ Sunday Matches when the visitors visits on a sunday.
|
||||
|
||||
``wagtail_personalisation.rules.DayRule``
|
||||
|
||||
|
||||
Referral rule
|
||||
-------------
|
||||
|
||||
@ -54,6 +60,7 @@ Regex string The regex string to match the referral header to.
|
||||
|
||||
``wagtail_personalisation.rules.ReferralRule``
|
||||
|
||||
|
||||
Visit count rule
|
||||
----------------
|
||||
|
||||
@ -72,6 +79,7 @@ Operator Whether to match for more than, less than or equal to the
|
||||
|
||||
``wagtail_personalisation.rules.VisitCountRule``
|
||||
|
||||
|
||||
Query rule
|
||||
----------
|
||||
|
||||
@ -92,6 +100,7 @@ Value The second part of the query ('ourbestoffer').
|
||||
|
||||
``wagtail_personalisation.rules.QueryRule``
|
||||
|
||||
|
||||
Device rule
|
||||
-----------
|
||||
|
||||
@ -108,6 +117,7 @@ Desktop Matches when the visitor uses a desktop.
|
||||
|
||||
``wagtail_personalisation.rules.DeviceRule``
|
||||
|
||||
|
||||
User is logged in rule
|
||||
----------------------
|
||||
|
||||
@ -121,3 +131,47 @@ Is logged in Whether the user is logged in or logged out.
|
||||
================== ==========================================================
|
||||
|
||||
``wagtail_personalisation.rules.UserIsLoggedInRule``
|
||||
|
||||
|
||||
Origin country rule
|
||||
-------------------
|
||||
|
||||
The origin country rule allows you to match visitors based on the origin
|
||||
country of their request. This rule requires to have set up a way to detect
|
||||
countries beforehand.
|
||||
|
||||
================== ==========================================================
|
||||
Option Description
|
||||
================== ==========================================================
|
||||
Country What country user's request comes from.
|
||||
================== ==========================================================
|
||||
|
||||
You must have one of the following configurations set up in order to
|
||||
make it work.
|
||||
|
||||
- Cloudflare IP Geolocation - ``cf-ipcountry`` HTTP header set with a value of
|
||||
the alpha-2 country format.
|
||||
- CloudFront Geo-Targeting - ``cloudfront-viewer-country`` header set with a
|
||||
value of the alpha-2 country format.
|
||||
- The last fallback is to use GeoIP2 module that is included with Django. This
|
||||
requires setting up an IP database beforehand, see the Django's
|
||||
`GeoIP2 instructions <https://docs.djangoproject.com/en/stable/ref/contrib/gis/geoip2/>`_
|
||||
for more information. It will use IP of the request, using HTTP header
|
||||
the ``x-forwarded-for`` HTTP header and ``REMOTE_ADDR`` server value as a
|
||||
fallback. If you want to use a custom logic when obtaining IP address, please
|
||||
set the ``WAGTAIL_PERSONALISATION_IP_FUNCTION`` setting to the function that takes a
|
||||
request as an argument, e.g.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# settings.py
|
||||
|
||||
WAGTAIL_PERSONALISATION_IP_FUNCTION = 'yourproject.utils.get_client_ip'
|
||||
|
||||
|
||||
# yourproject/utils.py
|
||||
|
||||
def get_client_ip(request):
|
||||
return request['HTTP_CF_CONNECTING_IP']
|
||||
|
||||
``wagtail_personalisation.rules.OriginCountryRule``
|
||||
|
91
docs/editor_guide/creating_personalised_content.rst
Normal file
@ -0,0 +1,91 @@
|
||||
Creating personalised content
|
||||
=============================
|
||||
|
||||
Once you've created a segment you can start serving personalised content to your
|
||||
visitors. To do this, you can choose one of three methods.
|
||||
|
||||
1. Create a page variant for a segment.
|
||||
2. Use StreamField blocks visible for a segment only.
|
||||
3. Use a template block visible for a segment only.
|
||||
|
||||
|
||||
Method 1: Create a page variant
|
||||
-------------------------------
|
||||
|
||||
**Why you would want to use this method**
|
||||
|
||||
* It has absolutely no restrictions, you can change anything you want.
|
||||
* That's pretty much it.
|
||||
|
||||
**Why you would want to use a different method**
|
||||
|
||||
* You are editing a page that changes often. You would probably rather not
|
||||
change the variation(s) every time the original page changes.
|
||||
|
||||
To create a variant of a page for a specific Segment (which you can change to
|
||||
your liking after creating it), simply go to the Explorer section and find the
|
||||
page you would like to personalize.
|
||||
|
||||
.. figure:: ../_static/images/variants_button.png
|
||||
:alt: The variants button that appears on personalisable pages.
|
||||
|
||||
When you hover over a page, you'll notice a "Variants" dropdown button appears.
|
||||
Click the button and select the segment you would like to create personalised
|
||||
content for.
|
||||
|
||||
Once you've selected the segment, a copy of the original page will be created
|
||||
with a title that includes the segment. Don't worry, your visitors won't be able
|
||||
to see this title. It's only there for your reference.
|
||||
|
||||
.. figure:: ../_static/images/editing_variant.png
|
||||
:alt: The newly created page allowing you to change anything you want.
|
||||
|
||||
You can change everything on this page you would like. Visitors that are appointed
|
||||
to your segment will automatically see the new variant you've created for them
|
||||
when attempting to visit the original page.
|
||||
|
||||
|
||||
Method 2: Use a StreamField block
|
||||
---------------------------------
|
||||
|
||||
Preparing a page and it's StreamField blocks for this method is described in the
|
||||
Usage guide for developers. Please refer to
|
||||
:ref:`implementing_streamfield_blocks` for more information.
|
||||
|
||||
**Why you would want to use this method**
|
||||
|
||||
* Allows you to create personalised content in the original page (without
|
||||
creating a variant).
|
||||
* Create multiple StreamField blocks for different segments inline.
|
||||
|
||||
**Why you would want to use a different method**
|
||||
|
||||
* You need someone tech savvy to change the back-end implementation.
|
||||
|
||||
To create personalised StreamField blocks, first select the page you wan't to
|
||||
create the content for. Note that the personalisable StreamField blocks must be
|
||||
activated on the page by your developer.
|
||||
|
||||
Scroll down to the block containing the StreamField and add a personalisable
|
||||
block. The first input field in the block is a dropdown allowing you to select
|
||||
the segment this StreamField block is ment for.
|
||||
|
||||
.. figure:: ../_static/images/single_streamfield.png
|
||||
:alt: Create a new StreamField block and select the segment.
|
||||
|
||||
If you want, you can even add multiple blocks and change the segment to show
|
||||
different content between segments!
|
||||
|
||||
.. figure:: ../_static/images/dual_streamfield.png
|
||||
:alt: You can even create multiple variations of the same block!
|
||||
|
||||
Once saved, the page will selectively show StreamField blocks based on the
|
||||
visitor's segment.
|
||||
|
||||
|
||||
Method 3: Use a template block
|
||||
------------------------------
|
||||
|
||||
Setting up content in this manner is described in the Usage guide for
|
||||
developers. Please refer to :ref:`implementing_template_blocks` for more
|
||||
information.
|
84
docs/editor_guide/creating_segments.rst
Normal file
@ -0,0 +1,84 @@
|
||||
Creating a segment
|
||||
==================
|
||||
|
||||
To create a segment, go to the "Segments dashboard" and click "Add segment".
|
||||
You can find the segments dashboard in the administration menu on the left of
|
||||
the page.
|
||||
|
||||
.. figure:: ../_static/images/segment_dashboard_header.png
|
||||
:alt: The segment dashboard header containing the "Add segment" button.
|
||||
|
||||
On this page you will be presented with two forms. One with specific information
|
||||
about your segment, the other allowing you to choose and configure your
|
||||
rules.
|
||||
|
||||
|
||||
Set segment specific options
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
.. figure:: ../_static/images/edit_segment_specifics.png
|
||||
:alt: The form that allows you to configure specific segment options.
|
||||
|
||||
1. Enter a name for your segment
|
||||
|
||||
Choose something meaningful like "Newsletter campaign visitors". This will
|
||||
ensure you'll have a general idea which visitors are in this segment in
|
||||
other parts of the administration interface.
|
||||
|
||||
2. Select the status of the segment *Optional*
|
||||
|
||||
You will generally keep this one **enabled**. If for some reason you want
|
||||
to disable the segment, you can change this to **disabled**.
|
||||
|
||||
3. Set the segment persistence. *Optional*
|
||||
|
||||
When persistence is **enabled**, your segment will stick to the visitor once
|
||||
applied, even if the rules no longer match the next visit.
|
||||
|
||||
4. Select whether to match any or all defined rules. *Optional*
|
||||
|
||||
**Match any** will result in a segment that is applied as soon as one of
|
||||
your rules matches the visitor. When **match all** is selected, all rules
|
||||
must match before the segment is applied.
|
||||
|
||||
5. The segment type *Required*
|
||||
|
||||
**Dynamic**: Users in this segment will change as more or less meet the
|
||||
rules specified in the segment.
|
||||
|
||||
**Static**: If the segment contains only static compatible rules the segment
|
||||
will contain the members that pass those rules when the segment is created.
|
||||
Mixed static segments or those containing entirely non static compatible
|
||||
rules will be populated using the count variable.
|
||||
|
||||
6. The segment count *Optional*
|
||||
|
||||
If this number is set for a static segment users will be added to the set
|
||||
until the number is reached. After this no more users will be added.
|
||||
|
||||
7. Randomisation percentage *Optional*
|
||||
|
||||
If this number is set each user matching the rules will have this percentage
|
||||
chance of being placed in the segment.
|
||||
|
||||
Defining rules
|
||||
^^^^^^^^^^^^^^
|
||||
|
||||
.. figure:: ../_static/images/edit_segment_rules.png
|
||||
:alt: The form that allows you to set the rules for a segment.
|
||||
|
||||
5. Choose the rules you want to use.
|
||||
|
||||
Wagxperience comes with a basic set of :doc:`../default_rules` that allow
|
||||
you to get started quickly. The rules you define will be evaluated once a
|
||||
visitor makes a request to your application.
|
||||
|
||||
The rules that come with Wagxperience are as follows:
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
../default_rules
|
||||
|
||||
Click "save" to store your segment. It will be enabled by default, unless
|
||||
otherwise defined.
|
13
docs/editor_guide/index.rst
Normal file
@ -0,0 +1,13 @@
|
||||
Editor Guide
|
||||
============
|
||||
|
||||
The editor guide is meant for content editors and marketers using Wagxperience
|
||||
to offer a personalised experience to their visitors.
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
introduction
|
||||
segments_dashboard
|
||||
creating_segments
|
||||
creating_personalised_content
|
22
docs/editor_guide/introduction.rst
Normal file
@ -0,0 +1,22 @@
|
||||
Introduction
|
||||
============
|
||||
|
||||
Wagxperience_ is an open source module developed by `Lab Digital`_ for the
|
||||
Wagtail_ content management system. It allows editors and marketeers to create
|
||||
personalised experiences by harnessing the power of segmentation and rules.
|
||||
|
||||
.. _Wagxperience: http://wagxperience.io
|
||||
.. _Wagtail: https://wagtail.io
|
||||
.. _Lab Digital: http://labdigital.nl
|
||||
|
||||
In this guide, we'll take you step by step through the process of offering your
|
||||
visitors a tailor made online experience. The subjects covered are:
|
||||
|
||||
* Using the segments dashboard
|
||||
* Defining a new segment
|
||||
* Setting up rules used to match visitors to a segment
|
||||
* Personalize a page by creating a variant
|
||||
* Using the StreamField to personalize content blocks
|
||||
* And even more helpful stuff...
|
||||
|
||||
So without further ado, let's get started!
|
84
docs/editor_guide/segments_dashboard.rst
Normal file
@ -0,0 +1,84 @@
|
||||
The segments dashboard
|
||||
======================
|
||||
|
||||
Wagxperience comes with two different views for it's segment dashboard. A "list
|
||||
view" and a "dashboard view". Where the dashboard view attempts to show all
|
||||
relevant information and statistics in a visually pleasing manner, the list view
|
||||
is more fitted for sites using large amounts of segments, as it may be
|
||||
considered more clear in these cases.
|
||||
|
||||
|
||||
Switching between views
|
||||
-----------------------
|
||||
|
||||
By default, Wagxperience's "dashboard view" is active on the segment dashboard.
|
||||
If you would like to switch between the dashboard view and list view, open the
|
||||
segment dashboard and click the "Switch view" button in the green header at the
|
||||
top of the page.
|
||||
|
||||
.. figure:: ../_static/images/segment_dashboard_header.png
|
||||
:alt: The header containing the "Switch view" button.
|
||||
|
||||
|
||||
Using the list view
|
||||
-------------------
|
||||
|
||||
Advantages of using the list view:
|
||||
|
||||
* Uses the familiar table view that is used on many other parts of the Wagtail
|
||||
administration interface.
|
||||
* Offers a better overview for large amounts of segments.
|
||||
* Allows for reordering based on fields, such as name or status.
|
||||
|
||||
.. figure:: ../_static/images/segment_list_view.png
|
||||
:alt: The segment list view.
|
||||
|
||||
|
||||
Definitions
|
||||
^^^^^^^^^^^
|
||||
|
||||
Name
|
||||
The name of your segment.
|
||||
|
||||
Persistent
|
||||
If this is disabled (default), whenever a visitor requests a page, the rules
|
||||
of this segment are reevaluated. This means that when the rules no longer
|
||||
match, the visitor is no longer a part of this segment. However, if
|
||||
persistence is enabled, this segment will "stick" with the visitor, even when
|
||||
the rules no longer apply.
|
||||
|
||||
Match any
|
||||
If this is disabled (default) all rules of this segment must match a visitor
|
||||
before the visitor is appointed to this segment. If this is enabled, only 1
|
||||
rule has to match before the visitor is appointed.
|
||||
|
||||
Status
|
||||
Indicates whether this segment is active (default) or inactive. If it has
|
||||
been set to 'inactive', visitors will not be appointed to this segment and no
|
||||
personalised content for this segment will be shown to visitors.
|
||||
|
||||
Page count
|
||||
The amount of pages that have variants using this segment.
|
||||
|
||||
Variant count
|
||||
The total amount of variants for this segment. Does not yet apply, as this
|
||||
will always match the amount of pages in the "Page count".
|
||||
|
||||
Statistics
|
||||
Shows the amount of visits of this segment and the days it has been
|
||||
enabled. If the segment is disabled and then re-enabled, these statistics
|
||||
will reset.
|
||||
|
||||
|
||||
Using the dashboard view
|
||||
------------------------
|
||||
|
||||
Advantages of using the dashboard view:
|
||||
|
||||
* Offers a more pleasing visual representation of segments.
|
||||
* Focused on giving insights about your segments at a glance.
|
||||
* Shows the actual rules of a segment.
|
||||
* Gives more wordy explanation about the information shown.
|
||||
|
||||
.. figure:: ../_static/images/segment_dashboard_view.png
|
||||
:alt: The segment dashboard view.
|
@ -1,32 +0,0 @@
|
||||
Getting started
|
||||
===============
|
||||
|
||||
|
||||
Installing Wagxperience
|
||||
-----------------------
|
||||
|
||||
Installing the module
|
||||
^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
The Wagxperience app runs in the Wagtail CMS. You can find out more here_.
|
||||
|
||||
.. _here: http://docs.wagtail.io/en/latest/getting_started/tutorial.html
|
||||
|
||||
1. Install the module::
|
||||
|
||||
pip install wagtail-personalisation
|
||||
|
||||
2. Add the module and ``wagtail.contrib.modeladmin`` to your ``INSTALLED_APPS``::
|
||||
|
||||
INSTALLED_APPS = [
|
||||
# ...
|
||||
'wagtail.contrib.modeladmin',
|
||||
'wagtail_personalisation',
|
||||
# ...
|
||||
]
|
||||
|
||||
3. Update your database::
|
||||
|
||||
python manage.py migrate
|
||||
|
||||
Continue reading: :doc:`implementation`
|
8
docs/getting_started/index.rst
Normal file
@ -0,0 +1,8 @@
|
||||
Getting Started
|
||||
===============
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 3
|
||||
|
||||
installation
|
||||
sandbox
|
37
docs/getting_started/installation.rst
Normal file
@ -0,0 +1,37 @@
|
||||
Installing Wagxperience
|
||||
=======================
|
||||
|
||||
Wagtail Personalisation requires Wagtail_ 2.0 or 2.1 and Django_ 1.11 or 2.0.
|
||||
|
||||
.. _Wagtail: https://github.com/wagtail/wagtail
|
||||
.. _Django: https://github.com/django/django
|
||||
|
||||
|
||||
To install the package with pip:
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
pip install wagtail-personalisation
|
||||
|
||||
Next, include the ``wagtail_personalisation``, ``wagtail.contrib.modeladmin``
|
||||
and ``wagtailfontawesome`` apps in your project's ``INSTALLED_APPS``:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
INSTALLED_APPS = [
|
||||
# ...
|
||||
'wagtail.contrib.modeladmin',
|
||||
'wagtail_personalisation',
|
||||
'wagtailfontawesome',
|
||||
# ...
|
||||
]
|
||||
|
||||
Make sure that ``django.contrib.sessions.middleware.SessionMiddleware`` has
|
||||
been added in first, this is a prerequisite for this project.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
# ...
|
||||
]
|
14
docs/getting_started/sandbox.rst
Normal file
@ -0,0 +1,14 @@
|
||||
Using the sandbox
|
||||
=================
|
||||
|
||||
To experiment with the package you can use the sandbox provided in
|
||||
the repository_. It includes a couple of segments with rules, a personalisable
|
||||
page with a variant and a personalisable StreamField block.
|
||||
|
||||
To install this you will need to create and activate a
|
||||
virtualenv and then run ``make sandbox``. This will start a fresh Wagtail
|
||||
install, with the personalisation module enabled, on http://localhost:8000
|
||||
and http://localhost:8000/cms/. The superuser credentials are
|
||||
``superuser@example.com`` with the password ``testing``.
|
||||
|
||||
.. _repository: https://github.com/LabD/wagtail-personalisation
|
@ -1,87 +0,0 @@
|
||||
Implementation
|
||||
===============
|
||||
|
||||
Extending a page to be personalisable
|
||||
-------------------------------------
|
||||
Wagxperience offers a ``PersonalisablePage`` base class to extend from.
|
||||
This is a standard ``Page`` class with personalisation options added.
|
||||
|
||||
Creating a new personalisable page
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
Import and extend the ``personalisation.models.PersonalisablePage`` class to create a personalisable page.
|
||||
|
||||
A very simple example for a personalisable homepage::
|
||||
|
||||
from wagtail_personalisation.models import PersonalisablePage
|
||||
|
||||
class HomePage(PersonalisablePage):
|
||||
subtitle = models.CharField(max_length=255)
|
||||
body = RichTextField(blank=True, default='')
|
||||
|
||||
content_panels = PersonalisablePage.content_panels + [
|
||||
FieldPanel('subtitle'),
|
||||
FieldPanel('body'),
|
||||
]
|
||||
|
||||
It's just as simple as extending a standard ``Page`` class.
|
||||
|
||||
Migrating an existing page to be personalisable
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
|
||||
Creating custom rules
|
||||
---------------------
|
||||
|
||||
Rules consist of two important elements, the model's fields and the ``test_user`` function.
|
||||
|
||||
A very simple example of a rule would look something like this::
|
||||
|
||||
from django.db import models
|
||||
from wagtail.wagtailadmin.edit_handlers import FieldPanel
|
||||
from personalisation import AbstractBaseRule
|
||||
|
||||
class MyNewRule(AbstractBaseRule):
|
||||
field = models.BooleanField(default=False)
|
||||
|
||||
panels = [
|
||||
FieldPanel('field'),
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(MyNewRule, self).__init__(*args, **kwargs)
|
||||
|
||||
def test_user(self, request):
|
||||
return self.field
|
||||
|
||||
As you can see, the only real requirement is the ``test_user`` function that will either return
|
||||
``True`` or ``False`` based on the model's fields and optionally the request object.
|
||||
|
||||
Below is the "Time rule" model included with the module, which offers more complex functionality::
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class TimeRule(AbstractBaseRule):
|
||||
"""Time rule to segment users based on a start and end time"""
|
||||
start_time = models.TimeField(_("Starting time"))
|
||||
end_time = models.TimeField(_("Ending time"))
|
||||
|
||||
panels = [
|
||||
FieldRowPanel([
|
||||
FieldPanel('start_time'),
|
||||
FieldPanel('end_time'),
|
||||
]),
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(TimeRule, self).__init__(*args, **kwargs)
|
||||
|
||||
def test_user(self, request=None):
|
||||
current_time = datetime.now().time()
|
||||
starting_time = self.start_time
|
||||
ending_time = self.end_time
|
||||
|
||||
return starting_time <= current_time <= ending_time
|
||||
|
||||
def __str__(self):
|
||||
return 'Time Rule'
|
||||
|
||||
Continue reading: :doc:`usage_guide`
|
@ -3,22 +3,49 @@
|
||||
You can adapt this file completely to your liking, but it should at least
|
||||
contain the root `toctree` directive.
|
||||
|
||||
Welcome to the Wagxperience documentation!
|
||||
==========================================
|
||||
Welcome to the Wagxperience documentation
|
||||
=========================================
|
||||
|
||||
.. image:: https://readthedocs.org/projects/wagtail-personalisation/badge/?version=latest
|
||||
:target: http://wagtail-personalisation.readthedocs.io/en/latest/?badge=latest
|
||||
|
||||
.. image:: https://travis-ci.org/wagtail/wagtail-personalisation.svg?branch=master
|
||||
:target: https://travis-ci.org/wagtail/wagtail-personalisation
|
||||
|
||||
.. image:: http://codecov.io/github/wagtail/wagtail-personalisation/coverage.svg?branch=master
|
||||
:target: http://codecov.io/github/wagtail/wagtail-personalisation?branch=master
|
||||
|
||||
.. image:: https://img.shields.io/pypi/v/wagtail-personalisation.svg
|
||||
:target: https://pypi.python.org/pypi/wagtail-personalisation/
|
||||
|
||||
|
||||
Wagxperience is a fully-featured personalisation module for Wagtail.
|
||||
It enables editors to create customised pages - or parts of pages - based on
|
||||
segments whose rules are configured directly in the admin interface.
|
||||
|
||||
|
||||
* **Get up and running**
|
||||
|
||||
* :doc:`getting_started/index`
|
||||
|
||||
|
||||
* **For developers**
|
||||
|
||||
* :doc:`usage_guide/index`
|
||||
|
||||
|
||||
* **For editors & marketeers**
|
||||
|
||||
* :doc:`editor_guide/index`
|
||||
|
||||
|
||||
Index
|
||||
-----
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:caption: Contents:
|
||||
|
||||
getting_started
|
||||
implementation
|
||||
usage_guide
|
||||
|
||||
getting_started/index
|
||||
usage_guide/index
|
||||
editor_guide/index
|
||||
default_rules
|
||||
|
||||
|
||||
Indices and tables
|
||||
==================
|
||||
|
||||
* :ref:`genindex`
|
||||
* :ref:`modindex`
|
||||
* :ref:`search`
|
||||
|
BIN
docs/logo.png
Normal file
After Width: | Height: | Size: 110 KiB |
@ -1,95 +0,0 @@
|
||||
Usage guide
|
||||
===========
|
||||
|
||||
Creating a segment
|
||||
------------------
|
||||
|
||||
As soon as the installation is completed and configured, the module will be
|
||||
visible in the Wagtail administrative area.
|
||||
|
||||
To create a segment, go to the "Segments" page and click on "Add a new segment".
|
||||
|
||||
On this page you will be presented with a form. Follow these steps to create a
|
||||
new segment:
|
||||
|
||||
1. Enter a name for your segment.
|
||||
|
||||
2. (Optional) Select whether to match any or all defined rules.
|
||||
|
||||
``match any`` will result in a segment that is applied as soon as one of
|
||||
your rules matches the visitor. When ``match all`` is selected, all rules
|
||||
must match before the segment is applied.
|
||||
|
||||
3. (Optional) Set the segment persistence.
|
||||
|
||||
When persistence is enabled, your segment will stick to the visitor once
|
||||
applied, even if the rules no longer match on the next visit.
|
||||
|
||||
4. Define your segment rules.
|
||||
|
||||
Wagxperience comes with a basic set of :doc:`default_rules` that allow
|
||||
you to get started quickly. The rules you define will be evaluated once a
|
||||
visitor makes a request to your application.
|
||||
|
||||
5. Save your segment.
|
||||
|
||||
Click "save" to store your segment. It will be enabled by default,
|
||||
unless otherwise defined.
|
||||
|
||||
|
||||
Creating personalized content
|
||||
-----------------------------
|
||||
|
||||
Once you've created a segment you can start serving these visitors with
|
||||
personalised content. To do this, you can go one of two directions.
|
||||
|
||||
1. Create a copy of a page for your segment.
|
||||
|
||||
2. Create StreamField blocks only visible to your segment.
|
||||
|
||||
3. Create a template block only visible to your segment.
|
||||
|
||||
|
||||
Method 1: Create a copy
|
||||
^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
To create a copy from a page for a specific Segment (which you can change to
|
||||
your liking after copying it) simply go to the Explorer section and find the
|
||||
page you'd wish to personalize.
|
||||
|
||||
You'll notice a new "Variants" dropdown button has appeared. Click the button
|
||||
and select the segment you'd like to create personalized content for.
|
||||
|
||||
Once you've selected the segment, a copy of the page will be created with a
|
||||
title that includes the segment. Don't worry, your visitors won't be able to
|
||||
see this title.
|
||||
|
||||
You can change everything on this page you'd like. Visitors that are included in
|
||||
your segment, will automatically see the new page you've created for them.
|
||||
|
||||
|
||||
Method 2: Create a StreamField block
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
|
||||
Method 3: Create a template block
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
You can add a template block that only shows its contents to users of a
|
||||
specific segment. This is done using the "segment" block.
|
||||
|
||||
When editing templates make sure to load the ``wagtail_personalisation_tags``
|
||||
tags library in the template::
|
||||
|
||||
{% load wagtail_personalisation_tags %}
|
||||
|
||||
After that you can add a template block with the name of the segment you want
|
||||
the content to show up for::
|
||||
|
||||
{% segment name="My Segment" %}
|
||||
<p>Only users within "My Segment" see this!</p>
|
||||
{% endsegment %}
|
||||
|
||||
The template block currently only supports one segment at a time. If you want
|
||||
to target multiple segments you will have to make multiple blocks with the
|
||||
same content.
|
17
docs/usage_guide/custom_rules.rst
Normal file
@ -0,0 +1,17 @@
|
||||
Creating custom rules
|
||||
=====================
|
||||
|
||||
Rules consist of two important elements, the model fields and the
|
||||
``test_user`` function. They should inherit the ``AbstractBaseRule`` class from
|
||||
``wagtail_personalisation.rules``.
|
||||
|
||||
A simple example of a rule could look something like this:
|
||||
|
||||
.. literalinclude:: ../../src/wagtail_personalisation/rules.py
|
||||
:pyobject: UserIsLoggedInRule
|
||||
|
||||
As you can see, the only real requirement is the ``test_user`` function that
|
||||
will either return ``True`` or ``False`` based on the model fields and
|
||||
optionally the request object.
|
||||
|
||||
That's it!
|
71
docs/usage_guide/implementation.rst
Normal file
@ -0,0 +1,71 @@
|
||||
Implementation
|
||||
==============
|
||||
|
||||
Extending a page to be personalisable
|
||||
-------------------------------------
|
||||
Wagxperience offers a ``PersonalisablePage`` base class to extend from.
|
||||
This is a standard ``Page`` class with personalisation options added.
|
||||
|
||||
|
||||
Creating a new personalisable page
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
Import and extend the ``personalisation.models.PersonalisablePage`` class to
|
||||
create a personalisable page.
|
||||
|
||||
A very simple example for a personalisable homepage:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from wagtail.wagtailcore.models import Page
|
||||
from wagtail_personalisation.models import PersonalisablePageMixin
|
||||
|
||||
class HomePage(PersonalisablePageMixin, Page):
|
||||
pass
|
||||
|
||||
All you need is the ``PersonalisablePageMixin`` mixin and a Wagtail ``Page``
|
||||
class of your liking.
|
||||
|
||||
|
||||
.. _implementing_streamfield_blocks:
|
||||
|
||||
Adding personalisable StreamField blocks
|
||||
----------------------------------------
|
||||
|
||||
Taking things a step further, you can also add personalisable StreamField blocks
|
||||
to your page models. Below is the full Homepage model used in the sandbox.
|
||||
|
||||
.. literalinclude:: ../../sandbox/sandbox/apps/home/models.py
|
||||
|
||||
|
||||
.. _implementing_template_blocks:
|
||||
|
||||
Using template blocks for personalisation
|
||||
-----------------------------------------
|
||||
|
||||
*Please note that using the personalisable template tag is not the recommended
|
||||
method for adding personalisation to your content, as it is largely decoupled
|
||||
from the administration interface. Use responsibly.*
|
||||
|
||||
You can add a template block that only shows its contents to users of a
|
||||
specific segment. This is done using the "segment" block.
|
||||
|
||||
When editing templates make sure to load the ``wagtail_personalisation_tags``
|
||||
tags library in the template:
|
||||
|
||||
.. code-block:: jinja
|
||||
|
||||
{% load wagtail_personalisation_tags %}
|
||||
|
||||
After that you can add a template block with the name of the segment you want
|
||||
the content to show up for:
|
||||
|
||||
.. code-block:: jinja
|
||||
|
||||
{% segment name="My Segment" %}
|
||||
<p>Only users within "My Segment" see this!</p>
|
||||
{% endsegment %}
|
||||
|
||||
The template block currently only supports one segment at a time. If you want
|
||||
to target multiple segments you will have to make multiple blocks with the
|
||||
same content.
|
8
docs/usage_guide/index.rst
Normal file
@ -0,0 +1,8 @@
|
||||
Usage Guide
|
||||
===========
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 3
|
||||
|
||||
implementation
|
||||
custom_rules
|
0
frontend/img/.gitkeep
Normal file
BIN
logo.png
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 103 KiB |
BIN
logo_bw.png
Normal file
After Width: | Height: | Size: 98 KiB |
@ -42,12 +42,12 @@
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/LabD/wagtail-personalisation.git"
|
||||
"url": "git+https://github.com/wagtail/wagtail-personalisation.git"
|
||||
},
|
||||
"author": "Lab Digital",
|
||||
"license": "ISC",
|
||||
"bugs": {
|
||||
"url": "https://github.com/LabD/wagtail-personalisation/issues"
|
||||
"url": "https://github.com/wagtail/wagtail-personalisation/issues"
|
||||
},
|
||||
"homepage": "https://github.com/LabD/wagtail-personalisation#readme"
|
||||
"homepage": "https://github.com/wagtail/wagtail-personalisation#readme"
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
Django>=1.11,<1.12
|
||||
wagtail>=1.10,<1.11
|
||||
django-debug-toolbar==1.8
|
||||
Django>=2.2,<2.3
|
||||
wagtail>=2.6,<2.7
|
||||
django-debug-toolbar==2.0
|
||||
-e .[docs,test]
|
||||
|
@ -3,7 +3,7 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations
|
||||
import wagtail.wagtailcore.fields
|
||||
import wagtail.core.fields
|
||||
import wagtail_personalisation
|
||||
|
||||
|
||||
@ -17,14 +17,14 @@ class Migration(migrations.Migration):
|
||||
migrations.AddField(
|
||||
model_name='homepage',
|
||||
name='intro',
|
||||
field=wagtail.wagtailcore.fields.RichTextField(
|
||||
field=wagtail.core.fields.RichTextField(
|
||||
default='<p>Thank you for trying <a href="http://wagxperience.io" target="_blank">Wagxperience</a>!</p>'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='homepage',
|
||||
name='body',
|
||||
field=wagtail.wagtailcore.fields.StreamField((('personalisable_paragraph', wagtail.wagtailcore.blocks.StructBlock((('segment', wagtail.wagtailcore.blocks.ChoiceBlock(choices=wagtail_personalisation.blocks.list_segment_choices, help_text='Only show this content block for users in this segment', label='Personalisation segment', required=False)), ('paragraph', wagtail.wagtailcore.blocks.RichTextBlock())), icon='pilcrow')),), default=''),
|
||||
field=wagtail.core.fields.StreamField((('personalisable_paragraph', wagtail.core.blocks.StructBlock((('segment', wagtail.core.blocks.ChoiceBlock(choices=wagtail_personalisation.blocks.list_segment_choices, help_text='Only show this content block for users in this segment', label='Personalisation segment', required=False)), ('paragraph', wagtail.core.blocks.RichTextBlock())), icon='pilcrow')),), default=''),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
|
@ -1,9 +1,9 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from wagtail.wagtailadmin.edit_handlers import RichTextFieldPanel, StreamFieldPanel
|
||||
from wagtail.wagtailcore import blocks
|
||||
from wagtail.wagtailcore.fields import RichTextField, StreamField
|
||||
from wagtail.wagtailcore.models import Page
|
||||
from wagtail.admin.edit_handlers import RichTextFieldPanel, StreamFieldPanel
|
||||
from wagtail.core import blocks
|
||||
from wagtail.core.fields import RichTextField, StreamField
|
||||
from wagtail.core.models import Page
|
||||
|
||||
from wagtail_personalisation.models import PersonalisablePageMixin
|
||||
from wagtail_personalisation.blocks import PersonalisedStructBlock
|
||||
|
@ -3,8 +3,8 @@ from __future__ import absolute_import, unicode_literals
|
||||
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
|
||||
from django.shortcuts import render
|
||||
|
||||
from wagtail.wagtailcore.models import Page
|
||||
from wagtail.wagtailsearch.models import Query
|
||||
from wagtail.core.models import Page
|
||||
from wagtail.search.models import Query
|
||||
|
||||
|
||||
def search(request):
|
||||
|
@ -0,0 +1,20 @@
|
||||
# Generated by Django 2.1.7 on 2019-03-15 12:54
|
||||
|
||||
from django.db import migrations
|
||||
import sandbox.apps.user.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('user', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelManagers(
|
||||
name='user',
|
||||
managers=[
|
||||
('objects', sandbox.apps.user.models.UserManager()),
|
||||
],
|
||||
),
|
||||
]
|
@ -1,14 +1,44 @@
|
||||
from django.contrib.auth.models import (
|
||||
AbstractBaseUser, PermissionsMixin, UserManager)
|
||||
AbstractBaseUser, PermissionsMixin, BaseUserManager)
|
||||
from django.core.mail import send_mail
|
||||
from django.db import connections, models
|
||||
from django.dispatch import receiver
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
|
||||
class UserManager(BaseUserManager):
|
||||
use_in_migrations = True
|
||||
|
||||
def _create_user(self, email, password, **extra_fields):
|
||||
"""
|
||||
Create and save a user with the given username, email, and password.
|
||||
"""
|
||||
if not email:
|
||||
raise ValueError('The given email address must be set')
|
||||
email = self.normalize_email(email)
|
||||
user = self.model(email=email, **extra_fields)
|
||||
user.set_password(password)
|
||||
user.save(using=self._db)
|
||||
return user
|
||||
|
||||
def create_user(self, email, password=None, **extra_fields):
|
||||
extra_fields.setdefault('is_staff', False)
|
||||
extra_fields.setdefault('is_superuser', False)
|
||||
return self._create_user(email, password, **extra_fields)
|
||||
|
||||
def create_superuser(self, email, password, **extra_fields):
|
||||
extra_fields.setdefault('is_staff', True)
|
||||
extra_fields.setdefault('is_superuser', True)
|
||||
|
||||
if extra_fields.get('is_staff') is not True:
|
||||
raise ValueError('Superuser must have is_staff=True.')
|
||||
if extra_fields.get('is_superuser') is not True:
|
||||
raise ValueError('Superuser must have is_superuser=True.')
|
||||
return self._create_user(email, password, **extra_fields)
|
||||
|
||||
|
||||
class User(AbstractBaseUser, PermissionsMixin):
|
||||
"""Cusomtized version of the default `AbstractUser` from Django.
|
||||
"""Customized version of the default `AbstractUser` from Django.
|
||||
|
||||
"""
|
||||
first_name = models.CharField(_('first name'), max_length=100, blank=True)
|
||||
|
@ -14,6 +14,7 @@ from __future__ import absolute_import, unicode_literals
|
||||
|
||||
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
||||
import os
|
||||
from importlib.util import find_spec
|
||||
|
||||
PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
BASE_DIR = os.path.dirname(PROJECT_DIR)
|
||||
@ -29,21 +30,30 @@ EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/
|
||||
|
||||
SITE_ID = 1
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'wagtail.wagtailforms',
|
||||
'wagtail.wagtailredirects',
|
||||
'wagtail.wagtailembeds',
|
||||
'wagtail.wagtailsites',
|
||||
'wagtail.wagtailusers',
|
||||
'wagtail.wagtailsnippets',
|
||||
'wagtail.wagtaildocs',
|
||||
'wagtail.wagtailimages',
|
||||
'wagtail.wagtailsearch',
|
||||
'wagtail.wagtailadmin',
|
||||
'wagtail.wagtailcore',
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.sites',
|
||||
'django.contrib.staticfiles',
|
||||
|
||||
'wagtail.contrib.forms',
|
||||
'wagtail.contrib.redirects',
|
||||
'wagtail.embeds',
|
||||
'wagtail.sites',
|
||||
'wagtail.users',
|
||||
'wagtail.snippets',
|
||||
'wagtail.documents',
|
||||
'wagtail.images',
|
||||
'wagtail.search',
|
||||
'wagtail.admin',
|
||||
'wagtail.core',
|
||||
'wagtail.contrib.modeladmin',
|
||||
|
||||
'wagtailfontawesome',
|
||||
@ -51,13 +61,6 @@ INSTALLED_APPS = [
|
||||
'taggit',
|
||||
'debug_toolbar',
|
||||
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
|
||||
'wagtail_personalisation',
|
||||
|
||||
'sandbox.apps.home',
|
||||
@ -68,19 +71,22 @@ INSTALLED_APPS = [
|
||||
|
||||
MIDDLEWARE = [
|
||||
'debug_toolbar.middleware.DebugToolbarMiddleware',
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.http.ConditionalGetMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
|
||||
'wagtail.wagtailcore.middleware.SiteMiddleware',
|
||||
'wagtail.wagtailredirects.middleware.RedirectMiddleware',
|
||||
'wagtail.contrib.redirects.middleware.RedirectMiddleware',
|
||||
]
|
||||
|
||||
if find_spec('wagtail.contrib.legacy'):
|
||||
MIDDLEWARE += ('wagtail.contrib.legacy.sitemiddleware.SiteMiddleware',)
|
||||
else:
|
||||
MIDDLEWARE += ('wagtail.core.middleware.SiteMiddleware', )
|
||||
|
||||
ROOT_URLCONF = 'sandbox.urls'
|
||||
|
||||
TEMPLATES = [
|
||||
|
@ -4,16 +4,16 @@ import debug_toolbar
|
||||
from django.conf import settings
|
||||
from django.conf.urls import include, url
|
||||
from django.contrib import admin
|
||||
from wagtail.wagtailadmin import urls as wagtailadmin_urls
|
||||
from wagtail.wagtailcore import urls as wagtail_urls
|
||||
from wagtail.wagtaildocs import urls as wagtaildocs_urls
|
||||
from wagtail.admin import urls as wagtailadmin_urls
|
||||
from wagtail.core import urls as wagtail_urls
|
||||
from wagtail.documents import urls as wagtaildocs_urls
|
||||
|
||||
from sandbox.apps.search import views as search_views
|
||||
|
||||
urlpatterns = [
|
||||
url(r'^admin/', include(admin.site.urls)),
|
||||
url(r'^django-admin/', admin.site.urls),
|
||||
|
||||
url(r'^cms/', include(wagtailadmin_urls)),
|
||||
url(r'^admin/', include(wagtailadmin_urls)),
|
||||
url(r'^documents/', include(wagtaildocs_urls)),
|
||||
|
||||
url(r'^search/$', search_views.search, name='search'),
|
||||
|
11
setup.cfg
@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 0.10.7
|
||||
current_version = 0.15.1
|
||||
commit = true
|
||||
tag = true
|
||||
tag_name = {new_version}
|
||||
@ -15,17 +15,16 @@ python_paths = .
|
||||
[flake8]
|
||||
ignore = E731
|
||||
max-line-length = 120
|
||||
exclude =
|
||||
exclude =
|
||||
src/**/migrations/*.py
|
||||
|
||||
[wheel]
|
||||
universal = 1
|
||||
|
||||
[coverage:run]
|
||||
omit =
|
||||
src/**/migrations/*.py
|
||||
[coverage]
|
||||
include = src/**/
|
||||
omit = src/**/migrations/*.py
|
||||
|
||||
[bumpversion:file:setup.py]
|
||||
|
||||
[bumpversion:file:docs/conf.py]
|
||||
|
||||
|
47
setup.py
@ -2,28 +2,31 @@ import re
|
||||
from setuptools import find_packages, setup
|
||||
|
||||
install_requires = [
|
||||
'wagtail>=1.10,<1.14',
|
||||
'user-agents>=1.0.1',
|
||||
'wagtailfontawesome>=1.0.6',
|
||||
'wagtail>=2.0',
|
||||
'user-agents>=1.1.0',
|
||||
'wagtailfontawesome>=1.1.3',
|
||||
'pycountry',
|
||||
]
|
||||
|
||||
tests_require = [
|
||||
'factory_boy==2.8.1',
|
||||
'flake8',
|
||||
'flake8-blind-except',
|
||||
'flake8-debugger',
|
||||
'flake8-imports',
|
||||
'flake8-isort',
|
||||
'flake8',
|
||||
'freezegun==0.3.8',
|
||||
'pytest-cov==2.4.0',
|
||||
'pytest-django==3.1.2',
|
||||
'pytest-sugar==0.7.1',
|
||||
'pytest-cov==2.5.1',
|
||||
'pytest-django==4.1.0',
|
||||
'pytest-pythonpath==0.7.2',
|
||||
'pytest-sugar==0.9.1',
|
||||
'pytest==6.1.2',
|
||||
'wagtail_factories==1.1.0',
|
||||
'pytest-mock==1.6.3',
|
||||
'pytest==3.1.0',
|
||||
'wagtail_factories==0.3.0',
|
||||
]
|
||||
|
||||
docs_require = [
|
||||
'sphinx>=1.4.0',
|
||||
'sphinx>=1.7.6',
|
||||
'sphinx_rtd_theme>=0.4.0',
|
||||
]
|
||||
|
||||
with open('README.rst') as fh:
|
||||
@ -31,12 +34,12 @@ with open('README.rst') as fh:
|
||||
'^.. start-no-pypi.*^.. end-no-pypi', '', fh.read(), flags=re.M | re.S)
|
||||
|
||||
setup(
|
||||
name='wagtail-personalisation-molo',
|
||||
version='0.10.7',
|
||||
description='A forked version of Wagtail add-on for showing personalized content',
|
||||
author='Praekelt.org',
|
||||
author_email='dev@praekeltfoundation.org',
|
||||
url='https://github.com/praekeltfoundation/wagtail-personalisation/',
|
||||
name='wagtail-personalisation',
|
||||
version='0.15.1',
|
||||
description='A Wagtail add-on for showing personalized content',
|
||||
author='Lab Digital BV and others',
|
||||
author_email='opensource@labdigital.nl',
|
||||
url='https://labdigital.nl/',
|
||||
install_requires=install_requires,
|
||||
tests_require=tests_require,
|
||||
extras_require={
|
||||
@ -49,21 +52,15 @@ setup(
|
||||
license='MIT',
|
||||
long_description=long_description,
|
||||
classifiers=[
|
||||
'Development Status :: 2 - Pre-Alpha',
|
||||
'Development Status :: 5 - Production/Stable',
|
||||
'Environment :: Web Environment',
|
||||
'Intended Audience :: Developers',
|
||||
'License :: OSI Approved :: BSD License',
|
||||
'Operating System :: OS Independent',
|
||||
'Programming Language :: Python :: 2',
|
||||
'Programming Language :: Python :: 2.7',
|
||||
'Programming Language :: Python :: 3',
|
||||
'Programming Language :: Python :: 3.4',
|
||||
'Programming Language :: Python :: 3.5',
|
||||
'Programming Language :: Python :: 3.6',
|
||||
'Framework :: Django',
|
||||
'Framework :: Django :: 1.9',
|
||||
'Framework :: Django :: 1.10',
|
||||
'Framework :: Django :: 1.11',
|
||||
'Framework :: Django :: 2.0',
|
||||
'Topic :: Internet :: WWW/HTTP :: Site Management',
|
||||
],
|
||||
)
|
||||
|
@ -1,5 +1,3 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import F
|
||||
from django.utils.module_loading import import_string
|
||||
@ -9,7 +7,7 @@ from wagtail_personalisation.rules import AbstractBaseRule
|
||||
from wagtail_personalisation.utils import create_segment_dictionary
|
||||
|
||||
|
||||
class BaseSegmentsAdapter(object):
|
||||
class BaseSegmentsAdapter:
|
||||
"""Base segments adapter."""
|
||||
|
||||
def __init__(self, request):
|
||||
@ -66,34 +64,48 @@ class SessionSegmentsAdapter(BaseSegmentsAdapter):
|
||||
self.request.session.setdefault('segments', [])
|
||||
self._segment_cache = None
|
||||
|
||||
def get_segments(self):
|
||||
"""Return the persistent segments stored in the request session.
|
||||
|
||||
:returns: The segments in the request session
|
||||
:rtype: list of wagtail_personalisation.models.Segment or empty list
|
||||
|
||||
"""
|
||||
if self._segment_cache is not None:
|
||||
return self._segment_cache
|
||||
|
||||
raw_segments = self.request.session['segments']
|
||||
segment_ids = [segment['id'] for segment in raw_segments]
|
||||
|
||||
def _segments(self, ids=None):
|
||||
if not ids:
|
||||
ids = []
|
||||
segments = (
|
||||
Segment.objects
|
||||
.enabled()
|
||||
.filter(persistent=True)
|
||||
.in_bulk(segment_ids))
|
||||
.filter(pk__in=ids)
|
||||
)
|
||||
return segments
|
||||
|
||||
retval = [segments[pk] for pk in segment_ids if pk in segments]
|
||||
self._segment_cache = retval
|
||||
return retval
|
||||
def get_segments(self, key="segments"):
|
||||
"""Return the persistent segments stored in the request session.
|
||||
|
||||
def set_segments(self, segments):
|
||||
:param key: The key under which the segments are stored
|
||||
:type key: String
|
||||
:returns: The segments in the request session
|
||||
:rtype: list of wagtail_personalisation.models.Segment or empty list
|
||||
|
||||
"""
|
||||
if key == "segments" and self._segment_cache is not None:
|
||||
return self._segment_cache
|
||||
|
||||
if key not in self.request.session:
|
||||
return []
|
||||
raw_segments = self.request.session[key]
|
||||
segment_ids = [segment['id'] for segment in raw_segments]
|
||||
|
||||
segments = self._segments(ids=segment_ids)
|
||||
|
||||
result = list(segments)
|
||||
if key == "segments":
|
||||
self._segment_cache = result
|
||||
return result
|
||||
|
||||
def set_segments(self, segments, key="segments"):
|
||||
"""Set the currently active segments
|
||||
|
||||
:param segments: The segments to set for the current request
|
||||
:type segments: list of wagtail_personalisation.models.Segment
|
||||
:param key: The key under which to store the segments. Optional
|
||||
:type key: String
|
||||
|
||||
"""
|
||||
cache_segments = []
|
||||
@ -108,8 +120,9 @@ class SessionSegmentsAdapter(BaseSegmentsAdapter):
|
||||
serialized_segments.append(serialized)
|
||||
segment_ids.add(segment.pk)
|
||||
|
||||
self.request.session['segments'] = serialized_segments
|
||||
self._segment_cache = cache_segments
|
||||
self.request.session[key] = serialized_segments
|
||||
if key == "segments":
|
||||
self._segment_cache = cache_segments
|
||||
|
||||
def get_segment_by_id(self, segment_id):
|
||||
"""Find and return a single segment from the request session.
|
||||
@ -120,9 +133,9 @@ class SessionSegmentsAdapter(BaseSegmentsAdapter):
|
||||
:rtype: wagtail_personalisation.models.Segment or None
|
||||
|
||||
"""
|
||||
for segment in self.get_segments():
|
||||
if segment.pk == segment_id:
|
||||
return segment
|
||||
segments = self._segments(ids=[segment_id])
|
||||
if segments.exists():
|
||||
return segments.get()
|
||||
|
||||
def add_page_visit(self, page):
|
||||
"""Mark the page as visited by the user"""
|
||||
@ -171,13 +184,20 @@ class SessionSegmentsAdapter(BaseSegmentsAdapter):
|
||||
rule_models = AbstractBaseRule.get_descendant_models()
|
||||
|
||||
current_segments = self.get_segments()
|
||||
excluded_segments = self.get_segments("excluded_segments")
|
||||
current_segments = list(
|
||||
set(current_segments) - set(excluded_segments)
|
||||
)
|
||||
|
||||
# Run tests on all remaining enabled segments to verify applicability.
|
||||
additional_segments = []
|
||||
for segment in enabled_segments:
|
||||
if segment.is_static and segment.static_users.filter(id=self.request.user.id).exists():
|
||||
additional_segments.append(segment)
|
||||
elif segment.excluded_users.filter(id=self.request.user.id).exists():
|
||||
elif any((
|
||||
segment.excluded_users.filter(id=self.request.user.id).exists(),
|
||||
segment in excluded_segments
|
||||
)):
|
||||
continue
|
||||
elif not segment.is_static or not segment.is_full:
|
||||
segment_rules = []
|
||||
@ -189,14 +209,17 @@ class SessionSegmentsAdapter(BaseSegmentsAdapter):
|
||||
|
||||
if result and segment.randomise_into_segment():
|
||||
if segment.is_static and not segment.is_full:
|
||||
if self.request.user.is_authenticated():
|
||||
if self.request.user.is_authenticated:
|
||||
segment.static_users.add(self.request.user)
|
||||
additional_segments.append(segment)
|
||||
elif result:
|
||||
if self.request.user.is_authenticated():
|
||||
if segment.is_static and self.request.user.is_authenticated:
|
||||
segment.excluded_users.add(self.request.user)
|
||||
else:
|
||||
excluded_segments += [segment]
|
||||
|
||||
self.set_segments(current_segments + additional_segments)
|
||||
self.set_segments(excluded_segments, "excluded_segments")
|
||||
self.update_visit_count()
|
||||
|
||||
|
||||
|
@ -1,5 +1,3 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from wagtail_personalisation import models, rules
|
||||
|
@ -1,5 +1,3 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from django.conf.urls import url
|
||||
|
||||
from wagtail_personalisation import views
|
||||
@ -13,4 +11,6 @@ urlpatterns = [
|
||||
views.copy_page_view, name='copy_page'),
|
||||
url(r'^segment/toggle_segment_view/$',
|
||||
views.toggle_segment_view, name='toggle_segment_view'),
|
||||
url(r'^segment/users/(?P<segment_id>[0-9]+)$',
|
||||
views.segment_user_data, name='segment_user_data'),
|
||||
]
|
||||
|
@ -1,13 +1,12 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from wagtail.wagtailcore import blocks
|
||||
from wagtail.core import blocks
|
||||
|
||||
from wagtail_personalisation.adapters import get_segment_adapter
|
||||
from wagtail_personalisation.models import Segment
|
||||
|
||||
|
||||
def list_segment_choices():
|
||||
yield -1, ("Show to everyone")
|
||||
for pk, name in Segment.objects.values_list('pk', 'name'):
|
||||
yield pk, name
|
||||
|
||||
@ -35,10 +34,19 @@ class PersonalisedStructBlock(blocks.StructBlock):
|
||||
adapter = get_segment_adapter(request)
|
||||
user_segments = adapter.get_segments()
|
||||
|
||||
if value['segment']:
|
||||
try:
|
||||
segment_id = int(value['segment'])
|
||||
except (ValueError, TypeError):
|
||||
return ''
|
||||
|
||||
if segment_id > 0:
|
||||
for segment in user_segments:
|
||||
if segment.id == int(value['segment']):
|
||||
if segment.id == segment_id:
|
||||
return super(PersonalisedStructBlock, self).render(
|
||||
value, context)
|
||||
|
||||
return ""
|
||||
if segment_id == -1:
|
||||
return super(PersonalisedStructBlock, self).render(
|
||||
value, context)
|
||||
|
||||
return ''
|
||||
|
@ -1,23 +1,19 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from datetime import datetime
|
||||
import functools
|
||||
from importlib import import_module
|
||||
from itertools import takewhile
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.contrib.sessions.models import Session
|
||||
from django.contrib.staticfiles.templatetags.staticfiles import static
|
||||
from django.templatetags.static import static
|
||||
from django.test.client import RequestFactory
|
||||
from django.utils.lru_cache import lru_cache
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from wagtail.wagtailadmin.forms import WagtailAdminModelForm
|
||||
from wagtail.admin.forms import WagtailAdminModelForm
|
||||
|
||||
SessionStore = import_module(settings.SESSION_ENGINE).SessionStore
|
||||
|
||||
|
||||
@lru_cache(maxsize=1000)
|
||||
@functools.lru_cache(maxsize=1000)
|
||||
def user_from_data(user_id):
|
||||
User = get_user_model()
|
||||
try:
|
||||
@ -87,7 +83,7 @@ class SegmentAdminForm(WagtailAdminModelForm):
|
||||
if not self.instance.is_static:
|
||||
self.instance.count = 0
|
||||
|
||||
if is_new:
|
||||
if is_new and self.instance.is_static and not self.instance.all_rules_static:
|
||||
rules = [
|
||||
form.instance for formset in self.formsets.values()
|
||||
for form in formset
|
||||
@ -108,23 +104,24 @@ class SegmentAdminForm(WagtailAdminModelForm):
|
||||
|
||||
users_to_add = []
|
||||
users_to_exclude = []
|
||||
sessions = Session.objects.iterator()
|
||||
take_session = takewhile(
|
||||
lambda x: instance.count == 0 or len(users_to_add) <= instance.count,
|
||||
sessions
|
||||
)
|
||||
for session in take_session:
|
||||
session_data = session.get_decoded()
|
||||
user = user_from_data(session_data.get('_auth_user_id'))
|
||||
if user.is_authenticated():
|
||||
request.user = user
|
||||
request.session = SessionStore(session_key=session.session_key)
|
||||
passes = adapter._test_rules(instance.get_rules(), request, instance.match_any)
|
||||
if passes and instance.randomise_into_segment():
|
||||
users_to_add.append(user)
|
||||
elif passes:
|
||||
users_to_exclude.append(user)
|
||||
|
||||
User = get_user_model()
|
||||
users = User.objects.filter(is_active=True, is_staff=False)
|
||||
|
||||
matched_count = 0
|
||||
for user in users.iterator():
|
||||
request.user = user
|
||||
passes = adapter._test_rules(instance.get_rules(), request, instance.match_any)
|
||||
if passes:
|
||||
matched_count += 1
|
||||
if instance.count == 0 or len(users_to_add) < instance.count:
|
||||
if instance.randomise_into_segment():
|
||||
users_to_add.append(user)
|
||||
else:
|
||||
users_to_exclude.append(user)
|
||||
|
||||
instance.matched_users_count = matched_count
|
||||
instance.matched_count_updated_at = datetime.now()
|
||||
instance.static_users.add(*users_to_add)
|
||||
instance.excluded_users.add(*users_to_exclude)
|
||||
|
||||
|
@ -0,0 +1,19 @@
|
||||
# Generated by Django 2.0.5 on 2018-05-26 14:25
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wagtail_personalisation', '0018_segment_excluded_users'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='personalisablepagemetadata',
|
||||
name='segment',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='page_metadata', to='wagtail_personalisation.Segment'),
|
||||
),
|
||||
]
|
@ -0,0 +1,50 @@
|
||||
# Generated by Django 2.0.5 on 2018-05-30 18:51
|
||||
|
||||
import django.db.models.deletion
|
||||
import modelcluster.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wagtail_personalisation', '0019_auto_20180526_1425'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='dayrule',
|
||||
name='segment',
|
||||
field=modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='wagtail_personalisation_dayrules', to='wagtail_personalisation.Segment'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='devicerule',
|
||||
name='segment',
|
||||
field=modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='wagtail_personalisation_devicerules', to='wagtail_personalisation.Segment'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='queryrule',
|
||||
name='segment',
|
||||
field=modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='wagtail_personalisation_queryrules', to='wagtail_personalisation.Segment'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='referralrule',
|
||||
name='segment',
|
||||
field=modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='wagtail_personalisation_referralrules', to='wagtail_personalisation.Segment'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='timerule',
|
||||
name='segment',
|
||||
field=modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='wagtail_personalisation_timerules', to='wagtail_personalisation.Segment'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='userisloggedinrule',
|
||||
name='segment',
|
||||
field=modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='wagtail_personalisation_userisloggedinrules', to='wagtail_personalisation.Segment'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='visitcountrule',
|
||||
name='segment',
|
||||
field=modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='wagtail_personalisation_visitcountrules', to='wagtail_personalisation.Segment'),
|
||||
),
|
||||
]
|
@ -0,0 +1,19 @@
|
||||
# Generated by Django 2.0.7 on 2018-07-04 15:26
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wagtail_personalisation', '0020_rules_delete_relatedqueryname'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='personalisablepagemetadata',
|
||||
name='segment',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='page_metadata', to='wagtail_personalisation.Segment'),
|
||||
),
|
||||
]
|
@ -0,0 +1,19 @@
|
||||
# Generated by Django 2.0.7 on 2018-07-05 13:25
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wagtail_personalisation', '0021_personalisablepagemetadata_segment_set_on_delete_protect'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='personalisablepagemetadata',
|
||||
name='canonical_page',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='personalisable_canonical_metadata', to='wagtailcore.Page'),
|
||||
),
|
||||
]
|
@ -0,0 +1,19 @@
|
||||
# Generated by Django 2.0.5 on 2018-07-19 09:57
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wagtail_personalisation', '0022_personalisablepagemetadata_canonical_protect'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='personalisablepagemetadata',
|
||||
name='variant',
|
||||
field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_personalisable_page_metadata', to='wagtailcore.Page'),
|
||||
),
|
||||
]
|
@ -1,19 +1,18 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
import random
|
||||
|
||||
import wagtail
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models, transaction
|
||||
from django.template.defaultfilters import slugify
|
||||
from django.utils.encoding import python_2_unicode_compatible
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from modelcluster.models import ClusterableModel
|
||||
from wagtail.wagtailadmin.edit_handlers import (
|
||||
from wagtail.admin.edit_handlers import (
|
||||
FieldPanel, FieldRowPanel, InlinePanel, MultiFieldPanel)
|
||||
from wagtail.wagtailcore.models import Page
|
||||
from wagtail.core.models import Page
|
||||
|
||||
from wagtail_personalisation.rules import AbstractBaseRule
|
||||
from wagtail_personalisation.utils import count_active_days
|
||||
@ -21,12 +20,19 @@ from wagtail_personalisation.utils import count_active_days
|
||||
from .forms import SegmentAdminForm
|
||||
|
||||
|
||||
class RulePanel(InlinePanel):
|
||||
def on_model_bound(self):
|
||||
self.relation_name = self.relation_name.replace('_related', 's')
|
||||
self.db_field = self.model._meta.get_field(self.relation_name)
|
||||
manager = getattr(self.model, self.relation_name)
|
||||
self.related = manager.rel
|
||||
|
||||
|
||||
class SegmentQuerySet(models.QuerySet):
|
||||
def enabled(self):
|
||||
return self.filter(status=self.model.STATUS_ENABLED)
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class Segment(ClusterableModel):
|
||||
"""The segment model."""
|
||||
STATUS_ENABLED = 'enabled'
|
||||
@ -121,7 +127,7 @@ class Segment(ClusterableModel):
|
||||
FieldPanel('randomisation_percent', classname='percent_field'),
|
||||
], heading="Segment"),
|
||||
MultiFieldPanel([
|
||||
InlinePanel(
|
||||
RulePanel(
|
||||
"{}_related".format(rule_model._meta.db_table),
|
||||
label='{}{}'.format(
|
||||
rule_model._meta.verbose_name,
|
||||
@ -163,15 +169,11 @@ class Segment(ClusterableModel):
|
||||
|
||||
def get_used_pages(self):
|
||||
"""Return the pages that have variants using this segment."""
|
||||
pages = list(PersonalisablePageMetadata.objects.filter(segment=self))
|
||||
|
||||
return pages
|
||||
return PersonalisablePageMetadata.objects.filter(segment=self)
|
||||
|
||||
def get_created_variants(self):
|
||||
"""Return the variants using this segment."""
|
||||
pages = Page.objects.filter(_personalisable_page_metadata__segment=self)
|
||||
|
||||
return pages
|
||||
return Page.objects.filter(_personalisable_page_metadata__segment=self)
|
||||
|
||||
def get_rules(self):
|
||||
"""Retrieve all rules in the segment."""
|
||||
@ -208,17 +210,21 @@ class PersonalisablePageMetadata(ClusterableModel):
|
||||
segments.
|
||||
|
||||
"""
|
||||
# Canonical pages should not ever be deleted if they have variants
|
||||
# because the variants will be orphaned.
|
||||
canonical_page = models.ForeignKey(
|
||||
Page, related_name='personalisable_canonical_metadata',
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True, null=True
|
||||
Page, models.PROTECT, related_name='personalisable_canonical_metadata',
|
||||
null=True
|
||||
)
|
||||
|
||||
# Delete metadata of the variant if its page gets deleted.
|
||||
variant = models.OneToOneField(
|
||||
Page, related_name='_personalisable_page_metadata')
|
||||
Page, models.CASCADE, related_name='_personalisable_page_metadata',
|
||||
null=True
|
||||
)
|
||||
|
||||
segment = models.ForeignKey(
|
||||
Segment, related_name='page_metadata', null=True, blank=True)
|
||||
segment = models.ForeignKey(Segment, models.PROTECT, null=True,
|
||||
related_name='page_metadata')
|
||||
|
||||
@cached_property
|
||||
def has_variants(self):
|
||||
@ -289,7 +295,7 @@ class PersonalisablePageMetadata(ClusterableModel):
|
||||
return Segment.objects.none()
|
||||
|
||||
|
||||
class PersonalisablePageMixin(object):
|
||||
class PersonalisablePageMixin:
|
||||
"""The personalisable page model. Allows creation of variants with linked
|
||||
segments.
|
||||
|
||||
@ -303,3 +309,15 @@ class PersonalisablePageMixin(object):
|
||||
metadata = PersonalisablePageMetadata.objects.create(
|
||||
canonical_page=self, variant=self)
|
||||
return metadata
|
||||
|
||||
def get_sitemap_urls(self, request=None):
|
||||
# Do not generate sitemap entries for variants.
|
||||
if not self.personalisation_metadata.is_canonical:
|
||||
return []
|
||||
if wagtail.VERSION >= (2, 2):
|
||||
# Since Wagtail 2.2 you can pass request to the get_sitemap_urls
|
||||
# method.
|
||||
return super(PersonalisablePageMixin, self).get_sitemap_urls(
|
||||
request=request
|
||||
)
|
||||
return super(PersonalisablePageMixin, self).get_sitemap_urls()
|
||||
|
@ -1,20 +1,45 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from importlib import import_module
|
||||
|
||||
import pycountry
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.contrib.sessions.models import Session
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db import models
|
||||
from django.template.defaultfilters import slugify
|
||||
from django.utils.encoding import force_text, python_2_unicode_compatible
|
||||
from django.test.client import RequestFactory
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from modelcluster.fields import ParentalKey
|
||||
from user_agents import parse
|
||||
from wagtail.wagtailadmin.edit_handlers import (
|
||||
from wagtail.admin.edit_handlers import (
|
||||
FieldPanel, FieldRowPanel, PageChooserPanel)
|
||||
|
||||
from wagtail_personalisation.utils import get_client_ip
|
||||
|
||||
SessionStore = import_module(settings.SESSION_ENGINE).SessionStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_geoip_module():
|
||||
try:
|
||||
from django.contrib.gis.geoip2 import GeoIP2
|
||||
return GeoIP2
|
||||
except ImportError:
|
||||
logger.exception(
|
||||
'GeoIP module is disabled. To use GeoIP for the origin\n'
|
||||
'country personaliastion rule please set it up as per '
|
||||
'documentation:\n'
|
||||
'https://docs.djangoproject.com/en/stable/ref/contrib/gis/'
|
||||
'geoip2/.\n'
|
||||
'Wagtail-personalisation also works with Cloudflare and\n'
|
||||
'CloudFront country detection, so you should not see this\n'
|
||||
'warning if you use one of those.')
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class AbstractBaseRule(models.Model):
|
||||
"""Base for creating rules to segment users with."""
|
||||
icon = 'fa-circle-o'
|
||||
@ -22,8 +47,7 @@ class AbstractBaseRule(models.Model):
|
||||
|
||||
segment = ParentalKey(
|
||||
'wagtail_personalisation.Segment',
|
||||
related_name="%(app_label)s_%(class)s_related",
|
||||
related_query_name="%(app_label)s_%(class)ss"
|
||||
related_name="%(app_label)s_%(class)ss",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@ -31,7 +55,7 @@ class AbstractBaseRule(models.Model):
|
||||
verbose_name = 'Abstract segmentation rule'
|
||||
|
||||
def __str__(self):
|
||||
return force_text(self._meta.verbose_name)
|
||||
return str(self._meta.verbose_name)
|
||||
|
||||
def test_user(self):
|
||||
"""Test if the user matches this rule."""
|
||||
@ -39,7 +63,7 @@ class AbstractBaseRule(models.Model):
|
||||
|
||||
def encoded_name(self):
|
||||
"""Return a string with a slug for the rule."""
|
||||
return slugify(force_text(self).lower())
|
||||
return slugify(str(self).lower())
|
||||
|
||||
def description(self):
|
||||
"""Return a description explaining the functionality of the rule.
|
||||
@ -85,7 +109,7 @@ class TimeRule(AbstractBaseRule):
|
||||
verbose_name = _('Time Rule')
|
||||
|
||||
def test_user(self, request=None):
|
||||
return self.start_time <= datetime.now().time() <= self.end_time
|
||||
return self.start_time <= timezone.now().time() <= self.end_time
|
||||
|
||||
def description(self):
|
||||
return {
|
||||
@ -129,7 +153,7 @@ class DayRule(AbstractBaseRule):
|
||||
|
||||
def test_user(self, request=None):
|
||||
return [self.mon, self.tue, self.wed, self.thu,
|
||||
self.fri, self.sat, self.sun][datetime.today().weekday()]
|
||||
self.fri, self.sat, self.sun][timezone.now().date().weekday()]
|
||||
|
||||
def description(self):
|
||||
days = (
|
||||
@ -220,18 +244,43 @@ class VisitCountRule(AbstractBaseRule):
|
||||
class Meta:
|
||||
verbose_name = _('Visit count Rule')
|
||||
|
||||
def _get_user_session(self, user):
|
||||
sessions = Session.objects.iterator()
|
||||
for session in sessions:
|
||||
session_data = session.get_decoded()
|
||||
if session_data.get('_auth_user_id') == str(user.id):
|
||||
return SessionStore(session_key=session.session_key)
|
||||
return SessionStore()
|
||||
|
||||
def test_user(self, request, user=None):
|
||||
if user:
|
||||
# This rule currently does not support testing a user directly
|
||||
# TODO: Make this test a user directly when the rule uses
|
||||
# historical data
|
||||
# Local import for cyclic import
|
||||
from wagtail_personalisation.adapters import (
|
||||
get_segment_adapter, SessionSegmentsAdapter, SEGMENT_ADAPTER_CLASS)
|
||||
|
||||
# Django formsets don't honour 'required' fields so check rule is valid
|
||||
try:
|
||||
self.counted_page
|
||||
except ObjectDoesNotExist:
|
||||
return False
|
||||
|
||||
if user:
|
||||
# Create a fake request so we can use the adapter
|
||||
request = RequestFactory().get('/')
|
||||
request.user = user
|
||||
|
||||
# If we're using the session adapter check for an active session
|
||||
if SEGMENT_ADAPTER_CLASS == SessionSegmentsAdapter:
|
||||
request.session = self._get_user_session(user)
|
||||
else:
|
||||
request.session = SessionStore()
|
||||
|
||||
elif not request:
|
||||
# Return false if we don't have a user or a request
|
||||
return False
|
||||
|
||||
operator = self.operator
|
||||
segment_count = self.count
|
||||
|
||||
# Local import for cyclic import
|
||||
from wagtail_personalisation.adapters import get_segment_adapter
|
||||
|
||||
adapter = get_segment_adapter(request)
|
||||
|
||||
visit_count = adapter.get_visit_count(self.counted_page)
|
||||
@ -257,6 +306,28 @@ class VisitCountRule(AbstractBaseRule):
|
||||
),
|
||||
}
|
||||
|
||||
def get_column_header(self):
|
||||
return "Visit count - %s" % self.counted_page
|
||||
|
||||
def get_user_info_string(self, user):
|
||||
# Local import for cyclic import
|
||||
from wagtail_personalisation.adapters import (
|
||||
get_segment_adapter, SessionSegmentsAdapter, SEGMENT_ADAPTER_CLASS)
|
||||
|
||||
# Create a fake request so we can use the adapter
|
||||
request = RequestFactory().get('/')
|
||||
request.user = user
|
||||
|
||||
# If we're using the session adapter check for an active session
|
||||
if SEGMENT_ADAPTER_CLASS == SessionSegmentsAdapter:
|
||||
request.session = self._get_user_session(user)
|
||||
else:
|
||||
request.session = SessionStore()
|
||||
|
||||
adapter = get_segment_adapter(request)
|
||||
visit_count = adapter.get_visit_count(self.counted_page)
|
||||
return str(visit_count)
|
||||
|
||||
|
||||
class QueryRule(AbstractBaseRule):
|
||||
"""Query rule to segment users based on matching queries.
|
||||
@ -266,7 +337,6 @@ class QueryRule(AbstractBaseRule):
|
||||
|
||||
"""
|
||||
icon = 'fa-link'
|
||||
static = True
|
||||
|
||||
parameter = models.SlugField(_("The query parameter to search for"),
|
||||
max_length=20)
|
||||
@ -281,13 +351,7 @@ class QueryRule(AbstractBaseRule):
|
||||
class Meta:
|
||||
verbose_name = _('Query Rule')
|
||||
|
||||
def test_user(self, request, user=None):
|
||||
if user:
|
||||
# This rule currently does not support testing a user directly
|
||||
# TODO: Make this test a user directly if/when the rule uses
|
||||
# historical data
|
||||
return False
|
||||
|
||||
def test_user(self, request):
|
||||
return request.GET.get(self.parameter, '') == self.value
|
||||
|
||||
def description(self):
|
||||
@ -355,10 +419,72 @@ class UserIsLoggedInRule(AbstractBaseRule):
|
||||
verbose_name = _('Logged in Rule')
|
||||
|
||||
def test_user(self, request=None):
|
||||
return request.user.is_authenticated() == self.is_logged_in
|
||||
return request.user.is_authenticated == self.is_logged_in
|
||||
|
||||
def description(self):
|
||||
return {
|
||||
'title': _('These visitors are'),
|
||||
'value': _('Logged in') if self.is_logged_in else _('Not logged in'),
|
||||
}
|
||||
|
||||
|
||||
COUNTRY_CHOICES = [(country.alpha_2.lower(), country.name)
|
||||
for country in pycountry.countries]
|
||||
|
||||
|
||||
class OriginCountryRule(AbstractBaseRule):
|
||||
"""
|
||||
Test user against the country or origin of their request.
|
||||
|
||||
Using this rule requires setting up GeoIP2 on Django or using
|
||||
CloudFlare or CloudFront geolocation detection.
|
||||
"""
|
||||
country = models.CharField(
|
||||
max_length=2, choices=COUNTRY_CHOICES,
|
||||
help_text=_("Select origin country of the request that this rule will "
|
||||
"match against. This rule will only work if you use "
|
||||
"Cloudflare or CloudFront IP geolocation or if GeoIP2 "
|
||||
"module is configured.")
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("origin country rule")
|
||||
|
||||
def get_cloudflare_country(self, request):
|
||||
"""
|
||||
Get country code that has been detected by Cloudflare.
|
||||
|
||||
Guide to the functionality:
|
||||
https://support.cloudflare.com/hc/en-us/articles/200168236-What-does-Cloudflare-IP-Geolocation-do-
|
||||
"""
|
||||
try:
|
||||
return request.META['HTTP_CF_IPCOUNTRY'].lower()
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def get_cloudfront_country(self, request):
|
||||
try:
|
||||
return request.META['HTTP_CLOUDFRONT_VIEWER_COUNTRY'].lower()
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def get_geoip_country(self, request):
|
||||
GeoIP2 = get_geoip_module()
|
||||
if GeoIP2 is None:
|
||||
return False
|
||||
return GeoIP2().country_code(get_client_ip(request)).lower()
|
||||
|
||||
def get_country(self, request):
|
||||
# Prioritise CloudFlare and CloudFront country detection over GeoIP.
|
||||
functions = (
|
||||
self.get_cloudflare_country,
|
||||
self.get_cloudfront_country,
|
||||
self.get_geoip_country,
|
||||
)
|
||||
for function in functions:
|
||||
result = function(request)
|
||||
if result:
|
||||
return result
|
||||
|
||||
def test_user(self, request=None):
|
||||
return (self.get_country(request) or '') == self.country.lower()
|
||||
|
0
src/wagtail_personalisation/static/img/.gitkeep
Normal file
@ -37,8 +37,6 @@
|
||||
/******/ 3: 0
|
||||
/******/ };
|
||||
/******/
|
||||
/******/ var resolvedPromise = new Promise(function(resolve) { resolve(); });
|
||||
/******/
|
||||
/******/ // The require function
|
||||
/******/ function __webpack_require__(moduleId) {
|
||||
/******/
|
||||
@ -66,20 +64,21 @@
|
||||
/******/ // This file contains only the entry chunk.
|
||||
/******/ // The chunk loading function for additional chunks
|
||||
/******/ __webpack_require__.e = function requireEnsure(chunkId) {
|
||||
/******/ if(installedChunks[chunkId] === 0) {
|
||||
/******/ return resolvedPromise;
|
||||
/******/ var installedChunkData = installedChunks[chunkId];
|
||||
/******/ if(installedChunkData === 0) {
|
||||
/******/ return new Promise(function(resolve) { resolve(); });
|
||||
/******/ }
|
||||
/******/
|
||||
/******/ // a Promise means "currently loading".
|
||||
/******/ if(installedChunks[chunkId]) {
|
||||
/******/ return installedChunks[chunkId][2];
|
||||
/******/ if(installedChunkData) {
|
||||
/******/ return installedChunkData[2];
|
||||
/******/ }
|
||||
/******/
|
||||
/******/ // setup Promise in chunk cache
|
||||
/******/ var promise = new Promise(function(resolve, reject) {
|
||||
/******/ installedChunks[chunkId] = [resolve, reject];
|
||||
/******/ installedChunkData = installedChunks[chunkId] = [resolve, reject];
|
||||
/******/ });
|
||||
/******/ installedChunks[chunkId][2] = promise;
|
||||
/******/ installedChunkData[2] = promise;
|
||||
/******/
|
||||
/******/ // start chunk loading
|
||||
/******/ var head = document.getElementsByTagName('head')[0];
|
||||
|
@ -24,6 +24,7 @@
|
||||
{% for segment in object_list %}
|
||||
<div class="block block--{{ segment.status }}" onclick="location.href = '{% url 'wagtail_personalisation_segment_modeladmin_edit' segment.pk %}'">
|
||||
<h2>{{ segment }}</h2>
|
||||
|
||||
<div class="inspect_container">
|
||||
<ul class="inspect segment_stats">
|
||||
<li class="stat_card">
|
||||
@ -38,11 +39,11 @@
|
||||
<li class="stat_card">
|
||||
{% trans "This segment is Static" %}
|
||||
<span class="icon icon-fa-user">
|
||||
{{ segment.sessions.count|localize }}
|
||||
{% if segment.sessions.count < segment.count %}
|
||||
{{ segment.static_users.count|localize }}
|
||||
{% if segment.static_users.count < segment.count %}
|
||||
/ {{ segment.count }} {% trans "member" %}{{ segment.count|pluralize }}
|
||||
{% else %}
|
||||
{% trans "member" %}{{ segment.sessions.count|pluralize }}
|
||||
{% trans "member" %}{{ segment.count|pluralize }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</li>
|
||||
@ -103,6 +104,9 @@
|
||||
<li><a href="{% url 'segment:toggle' segment.pk %}" title="{% trans "Disable this segment" %}">disable</a></li>
|
||||
{% endif %}
|
||||
<li><a href="{% url 'wagtail_personalisation_segment_modeladmin_edit' segment.pk %}" title="{% trans "Configure this segment" %}">configure this</a></li>
|
||||
{% if segment.is_static %}
|
||||
<li><a href="{% url 'segment:segment_user_data' segment.pk %}" title="{% trans "Download user info" %}">download users csv</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
@ -0,0 +1,49 @@
|
||||
{% extends "modeladmin/delete.html" %}
|
||||
|
||||
{% load i18n modeladmin_tags %}
|
||||
|
||||
{% block content_main %}
|
||||
<div class="nice-padding">
|
||||
{% if protected_error %}
|
||||
<h2>{% blocktrans with view.verbose_name|capfirst as model_name %}{{ model_name }} could not be deleted{% endblocktrans %}</h2>
|
||||
<p>{% blocktrans with instance as instance_name %}'{{ instance_name }}' is currently referenced by other objects, and cannot be deleted without jeopardising data integrity. To delete it successfully, first remove references from the following objects, then try again:{% endblocktrans %}</p>
|
||||
<ul>
|
||||
{% for obj in linked_objects %}<li><b>{{ obj|get_content_type_for_obj|title }}:</b> {{ obj }}</li>{% endfor %}
|
||||
</ul>
|
||||
<p><a href="{{ view.index_url }}" class="button">{% trans 'Go back to listing' %}</a></p>
|
||||
{% elif cannot_delete_page_variants_error %}
|
||||
<h2>{% blocktrans %}Cannot delete all the page variants{% endblocktrans %}</h2>
|
||||
<p>{% blocktrans %}You need to have permissions to delete the page variants associated with this segment.{% endblocktrans %}
|
||||
{% else %}
|
||||
{% with page_variants=view.get_affected_page_objects %}
|
||||
{% if page_variants %}
|
||||
<p>
|
||||
{% blocktrans %}Deleting the segment will also mean deleting all the page variants associated with it. Do you want to continue?{% endblocktrans %}
|
||||
</p>
|
||||
<p>
|
||||
{% blocktrans %}The page objects that <strong>will be deleted</strong> are:{% endblocktrans %}
|
||||
</p>
|
||||
<ul>
|
||||
{% for variant in page_variants %}
|
||||
<li>
|
||||
<a href="{% url 'wagtailadmin_explore' variant.pk %}">
|
||||
{{ variant }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% trans 'Yes, delete the segment and associated page variants' as submit_button_value %}
|
||||
{% else %}
|
||||
<p>
|
||||
{% blocktrans %}Do you want to continue deleting this segment?{% endblocktrans %}
|
||||
</p>
|
||||
{% trans 'Yes, delete the segment' as submit_button_value %}
|
||||
{% endif %}
|
||||
<form action="{{ view.delete_url }}" method="POST">
|
||||
{% csrf_token %}
|
||||
<input type="submit" value="{{ submit_button_value }}" class="button serious" />
|
||||
</form>
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
@ -0,0 +1,57 @@
|
||||
{% extends "wagtailadmin/base.html" %}
|
||||
|
||||
{% load i18n wagtailadmin_tags %}
|
||||
|
||||
{% block content %}
|
||||
{% trans "Delete" as del_str %}
|
||||
{% include "wagtailadmin/shared/header.html" with title=del_str subtitle=page.get_admin_display_title icon="doc-empty-inverse" %}
|
||||
|
||||
<div class="nice-padding">
|
||||
<p>
|
||||
{% trans 'Are you sure you want to delete this page?' %}
|
||||
{% if descendant_count %}
|
||||
{% blocktrans count counter=descendant_count %}
|
||||
This will also delete one more subpage.
|
||||
{% plural %}
|
||||
This will also delete {{ descendant_count }} more subpages.
|
||||
{% endblocktrans %}
|
||||
{% endif %}
|
||||
</p>
|
||||
{% if variants %}
|
||||
<p>
|
||||
{% blocktrans count counter=variants|length %}
|
||||
This page is personalisable. Deleting it will delete its variant:
|
||||
{% plural %}
|
||||
This page is personalisable. Deleting it will delete all of its variants:
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<ul>
|
||||
{% for variant in variants %}
|
||||
<li>
|
||||
<a href="{% url 'wagtailadmin_explore' variant.pk %}">
|
||||
{{ variant }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
<form action="{% url 'wagtailadmin_pages:delete' page.id %}" method="POST">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
{% if variants %}
|
||||
{% trans 'Yes, delete the page and its variants' as submit_button_value %}
|
||||
{% else %}
|
||||
{% trans 'Yes, delete it' as submit_button_value %}
|
||||
{% endif %}
|
||||
<input type="submit" value="{{ submit_button_value }}" class="button serious">
|
||||
<a href="{% if next %}{{ next }}{% else %}{% url 'wagtailadmin_explore' page.get_parent.id %}{% endif %}" class="button button-secondary">{% trans "No, don't delete it" %}</a>
|
||||
</form>
|
||||
|
||||
{% page_permissions page as page_perms %}
|
||||
{% if page_perms.can_unpublish %}
|
||||
{% url 'wagtailadmin_pages:unpublish' page.id as unpublish_url %}
|
||||
<p style="margin-top: 1em">{% blocktrans %}Alternatively you can <a href="{{ unpublish_url }}">unpublish the page</a>. This removes the page from public view and you can edit or publish it again later.{% endblocktrans %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
@ -1,7 +1,10 @@
|
||||
import time
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import F
|
||||
from django.template.base import FilterExpression, kwarg_re
|
||||
from django.utils import timezone
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
|
||||
def impersonate_other_page(page, other_page):
|
||||
@ -98,22 +101,34 @@ def parse_tag(token, parser):
|
||||
def exclude_variants(pages):
|
||||
"""Checks if page is not a variant
|
||||
|
||||
:param pages: List of pages to check
|
||||
:type pages: list
|
||||
:return: List of pages that aren't variants
|
||||
:rtype: list
|
||||
:param pages: Set of pages to check
|
||||
:type pages: QuerySet
|
||||
:return: Queryset of pages that aren't variants
|
||||
:rtype: QuerySet
|
||||
"""
|
||||
return [
|
||||
page for page in pages
|
||||
if (
|
||||
(
|
||||
hasattr(page, 'personalisation_metadata') is False
|
||||
) or
|
||||
(
|
||||
hasattr(page, 'personalisation_metadata') and page.personalisation_metadata is None
|
||||
) or
|
||||
(
|
||||
hasattr(page, 'personalisation_metadata') and page.personalisation_metadata.is_canonical
|
||||
)
|
||||
)
|
||||
]
|
||||
from wagtail_personalisation.models import PersonalisablePageMetadata
|
||||
excluded_variant_pages = PersonalisablePageMetadata.objects.exclude(
|
||||
canonical_page_id=F('variant_id')
|
||||
).values_list('variant_id')
|
||||
return pages.exclude(pk__in=excluded_variant_pages)
|
||||
|
||||
|
||||
def can_delete_pages(pages, user):
|
||||
for variant in pages:
|
||||
if not variant.permissions_for_user(user).can_delete():
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def get_client_ip(request):
|
||||
try:
|
||||
func = import_string(settings.WAGTAIL_PERSONALISATION_IP_FUNCTION)
|
||||
except AttributeError:
|
||||
pass
|
||||
else:
|
||||
return func(request)
|
||||
try:
|
||||
x_forwarded_for = request.META['HTTP_X_FORWARDED_FOR']
|
||||
return x_forwarded_for.split(',')[-1].strip()
|
||||
except KeyError:
|
||||
return request.META['REMOTE_ADDR']
|
||||
|
@ -1,15 +1,19 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
import csv
|
||||
|
||||
from django import forms
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.http import HttpResponseForbidden, HttpResponseRedirect
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db import transaction
|
||||
from django.http import (
|
||||
HttpResponse, HttpResponseForbidden, HttpResponseRedirect)
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from wagtail.contrib.modeladmin.options import ModelAdmin, modeladmin_register
|
||||
from wagtail.contrib.modeladmin.views import IndexView
|
||||
from wagtail.wagtailcore.models import Page
|
||||
from wagtail.contrib.modeladmin.views import DeleteView, IndexView
|
||||
from wagtail.core.models import Page
|
||||
|
||||
from wagtail_personalisation.models import Segment
|
||||
from wagtail_personalisation.utils import can_delete_pages
|
||||
|
||||
|
||||
class SegmentModelIndexView(IndexView):
|
||||
@ -32,19 +36,59 @@ class SegmentModelDashboardView(IndexView):
|
||||
]
|
||||
|
||||
|
||||
class SegmentModelDeleteView(DeleteView):
|
||||
def get_affected_page_objects(self):
|
||||
return Page.objects.filter(pk__in=(
|
||||
self.instance.get_used_pages().values_list('variant_id', flat=True)
|
||||
))
|
||||
|
||||
def get_template_names(self):
|
||||
return [
|
||||
'modeladmin/wagtail_personalisation/segment/delete.html',
|
||||
'modeladmin/delete.html',
|
||||
]
|
||||
|
||||
def delete_instance(self):
|
||||
page_variants = self.get_affected_page_objects()
|
||||
if not can_delete_pages(page_variants, self.request.user):
|
||||
raise PermissionDenied(
|
||||
'User has no permission to delete variant page objects.'
|
||||
)
|
||||
# Deleting page objects triggers deletion of the personalisation
|
||||
# metadata too because of models.CASCADE.
|
||||
with transaction.atomic():
|
||||
for variant in page_variants.iterator():
|
||||
# Delete each one separately so signals are called.
|
||||
variant.delete()
|
||||
super().delete_instance()
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
if not can_delete_pages(self.get_affected_page_objects(),
|
||||
self.request.user):
|
||||
context = self.get_context_data(
|
||||
cannot_delete_page_variants_error=True,
|
||||
)
|
||||
return self.render_to_response(context)
|
||||
return super().post(request, *args, **kwargs)
|
||||
|
||||
|
||||
@modeladmin_register
|
||||
class SegmentModelAdmin(ModelAdmin):
|
||||
"""The model admin for the Segments administration interface."""
|
||||
model = Segment
|
||||
index_view_class = SegmentModelIndexView
|
||||
dashboard_view_class = SegmentModelDashboardView
|
||||
delete_view_class = SegmentModelDeleteView
|
||||
menu_icon = 'fa-snowflake-o'
|
||||
add_to_settings_menu = False
|
||||
list_display = ('name', 'persistent', 'match_any', 'status',
|
||||
'page_count', 'variant_count', 'statistics')
|
||||
index_view_extra_js = ['js/commons.js', 'js/index.js']
|
||||
index_view_extra_css = ['css/index.css']
|
||||
form_view_extra_js = ['js/commons.js', 'js/form.js']
|
||||
form_view_extra_js = ['js/commons.js', 'js/form.js',
|
||||
'js/segment_form_control.js',
|
||||
'wagtailadmin/js/page-chooser-modal.js',
|
||||
'wagtailadmin/js/page-chooser.js']
|
||||
form_view_extra_css = ['css/form.css']
|
||||
|
||||
def index_view(self, request):
|
||||
@ -139,3 +183,32 @@ def copy_page_view(request, page_id, segment_id):
|
||||
return HttpResponseRedirect(edit_url)
|
||||
|
||||
return HttpResponseForbidden()
|
||||
|
||||
|
||||
# CSV download views
|
||||
def segment_user_data(request, segment_id):
|
||||
if request.user.has_perm('wagtailadmin.access_admin'):
|
||||
segment = get_object_or_404(Segment, pk=segment_id)
|
||||
|
||||
response = HttpResponse(content_type='text/csv; charset=utf-8')
|
||||
response['Content-Disposition'] = \
|
||||
'attachment;filename=segment-%s-users.csv' % str(segment_id)
|
||||
|
||||
headers = ['Username']
|
||||
for rule in segment.get_rules():
|
||||
if rule.static:
|
||||
headers.append(rule.get_column_header())
|
||||
|
||||
writer = csv.writer(response)
|
||||
writer.writerow(headers)
|
||||
|
||||
for user in segment.static_users.all():
|
||||
row = [user.username]
|
||||
for rule in segment.get_rules():
|
||||
if rule.static:
|
||||
row.append(rule.get_user_info_string(user))
|
||||
writer.writerow(row)
|
||||
|
||||
return response
|
||||
|
||||
return HttpResponseForbidden()
|
||||
|
@ -1,41 +1,51 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf.urls import include, url
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.db import transaction
|
||||
from django.db.models import F
|
||||
from django.http import Http404
|
||||
from django.shortcuts import redirect, render
|
||||
from django.template.defaultfilters import pluralize
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from wagtail.wagtailadmin.site_summary import PagesSummaryItem, SummaryItem
|
||||
from wagtail.wagtailadmin.widgets import Button, ButtonWithDropdownFromHook
|
||||
from wagtail.wagtailcore import hooks
|
||||
from wagtail.wagtailcore.models import Page
|
||||
from wagtail.admin import messages
|
||||
from wagtail.admin.site_summary import PagesSummaryItem, SummaryItem
|
||||
|
||||
try:
|
||||
from wagtail.admin.views.pages.utils import get_valid_next_url_from_request
|
||||
except ModuleNotFoundError:
|
||||
from wagtail.admin.views.pages import get_valid_next_url_from_request # noqa
|
||||
|
||||
from wagtail.admin.widgets import Button, ButtonWithDropdownFromHook
|
||||
from wagtail.core import hooks
|
||||
from wagtail.core.models import Page
|
||||
|
||||
from wagtail_personalisation import admin_urls, models, utils
|
||||
from wagtail_personalisation.adapters import get_segment_adapter
|
||||
from wagtail_personalisation.models import PersonalisablePageMetadata
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@hooks.register('register_admin_urls')
|
||||
@hooks.register("register_admin_urls")
|
||||
def register_admin_urls():
|
||||
"""Adds the administration urls for the personalisation apps."""
|
||||
return [
|
||||
url(r'^personalisation/', include(
|
||||
admin_urls,
|
||||
app_name='wagtail_personalisation',
|
||||
namespace='wagtail_personalisation')),
|
||||
url(
|
||||
r"^personalisation/",
|
||||
include(admin_urls, namespace="wagtail_personalisation"),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@hooks.register('before_serve_page')
|
||||
@hooks.register("before_serve_page")
|
||||
def set_visit_count(page, request, serve_args, serve_kwargs):
|
||||
"""Tests the provided rules to see if the request still belongs
|
||||
to a segment.
|
||||
|
||||
:param page: The page being served
|
||||
:type page: wagtail.wagtailcore.models.Page
|
||||
:type page: wagtail.core.models.Page
|
||||
:param request: The http request
|
||||
:type request: django.http.HttpRequest
|
||||
|
||||
@ -44,12 +54,12 @@ def set_visit_count(page, request, serve_args, serve_kwargs):
|
||||
adapter.add_page_visit(page)
|
||||
|
||||
|
||||
@hooks.register('before_serve_page')
|
||||
@hooks.register("before_serve_page")
|
||||
def segment_user(page, request, serve_args, serve_kwargs):
|
||||
"""Apply a segment to a visitor before serving the page.
|
||||
|
||||
:param page: The page being served
|
||||
:type page: wagtail.wagtailcore.models.Page
|
||||
:type page: wagtail.core.models.Page
|
||||
:param request: The http request
|
||||
:type request: django.http.HttpRequest
|
||||
|
||||
@ -57,18 +67,42 @@ def segment_user(page, request, serve_args, serve_kwargs):
|
||||
adapter = get_segment_adapter(request)
|
||||
adapter.refresh()
|
||||
|
||||
forced_segment = request.GET.get("segment", None)
|
||||
if request.user.is_superuser and forced_segment is not None:
|
||||
segment = models.Segment.objects.filter(pk=forced_segment).first()
|
||||
if segment:
|
||||
adapter.set_segments([segment])
|
||||
|
||||
@hooks.register('before_serve_page')
|
||||
|
||||
class UserbarSegmentedLinkItem:
|
||||
def __init__(self, segment):
|
||||
self.segment = segment
|
||||
|
||||
def render(self, request):
|
||||
return f"""<div class="wagtail-userbar__item">
|
||||
<a href="{request.path}?segment={self.segment.pk}"
|
||||
class="wagtail-action">
|
||||
Show as segment: {self.segment.name}</a></div>"""
|
||||
|
||||
|
||||
@hooks.register("construct_wagtail_userbar")
|
||||
def add_segment_link_items(request, items):
|
||||
for item in models.Segment.objects.enabled():
|
||||
items.append(UserbarSegmentedLinkItem(item))
|
||||
return items
|
||||
|
||||
|
||||
@hooks.register("before_serve_page")
|
||||
def serve_variant(page, request, serve_args, serve_kwargs):
|
||||
"""Apply a segment to a visitor before serving the page.
|
||||
|
||||
:param page: The page being served
|
||||
:type page: wagtail.wagtailcore.models.Page
|
||||
:type page: wagtail.core.models.Page
|
||||
:param request: The http request
|
||||
:type request: django.http.HttpRequest
|
||||
:returns: A variant if one is available for the visitor's segment,
|
||||
otherwise the original page
|
||||
:rtype: wagtail.wagtailcore.models.Page
|
||||
:rtype: wagtail.core.models.Page
|
||||
|
||||
"""
|
||||
user_segments = []
|
||||
@ -78,9 +112,13 @@ def serve_variant(page, request, serve_args, serve_kwargs):
|
||||
adapter = get_segment_adapter(request)
|
||||
user_segments = adapter.get_segments()
|
||||
|
||||
if user_segments:
|
||||
metadata = page.personalisation_metadata
|
||||
metadata = page.personalisation_metadata
|
||||
|
||||
# If page is not canonical, don't serve it.
|
||||
if not metadata.is_canonical:
|
||||
raise Http404
|
||||
|
||||
if user_segments:
|
||||
# TODO: This is never more then one page? (fix query count)
|
||||
metadata = metadata.metadata_for_segments(user_segments)
|
||||
if metadata:
|
||||
@ -88,13 +126,13 @@ def serve_variant(page, request, serve_args, serve_kwargs):
|
||||
return variant.serve(request, *serve_args, **serve_kwargs)
|
||||
|
||||
|
||||
@hooks.register('construct_explorer_page_queryset')
|
||||
@hooks.register("construct_explorer_page_queryset")
|
||||
def dont_show_variant(parent_page, pages, request):
|
||||
return utils.exclude_variants(pages)
|
||||
|
||||
|
||||
@hooks.register('register_page_listing_buttons')
|
||||
def page_listing_variant_buttons(page, page_perms, is_parent=False):
|
||||
@hooks.register("register_page_listing_buttons")
|
||||
def page_listing_variant_buttons(page, page_perms, is_parent=False, *args):
|
||||
"""Adds page listing buttons to personalisable pages. Shows variants for
|
||||
the page (if any) and a 'Create a new variant' button.
|
||||
|
||||
@ -105,17 +143,18 @@ def page_listing_variant_buttons(page, page_perms, is_parent=False):
|
||||
metadata = page.personalisation_metadata
|
||||
if metadata.is_canonical:
|
||||
yield ButtonWithDropdownFromHook(
|
||||
_('Variants'),
|
||||
hook_name='register_page_listing_variant_buttons',
|
||||
_("Variants"),
|
||||
hook_name="register_page_listing_variant_buttons",
|
||||
page=page,
|
||||
page_perms=page_perms,
|
||||
is_parent=is_parent,
|
||||
attrs={'target': '_blank', 'title': _('Create or edit a variant')},
|
||||
priority=100)
|
||||
attrs={"target": "_blank", "title": _("Create or edit a variant")},
|
||||
priority=100,
|
||||
)
|
||||
|
||||
|
||||
@hooks.register('register_page_listing_variant_buttons')
|
||||
def page_listing_more_buttons(page, page_perms, is_parent=False):
|
||||
@hooks.register("register_page_listing_variant_buttons")
|
||||
def page_listing_more_buttons(page, page_perms, is_parent=False, *args):
|
||||
"""Adds a 'more' button to personalisable pages allowing users to quickly
|
||||
create a new variant for the selected segment.
|
||||
|
||||
@ -126,42 +165,60 @@ def page_listing_more_buttons(page, page_perms, is_parent=False):
|
||||
metadata = page.personalisation_metadata
|
||||
|
||||
for vm in metadata.variants_metadata:
|
||||
yield Button('%s variant' % (vm.segment.name),
|
||||
reverse('wagtailadmin_pages:edit', args=[vm.variant_id]),
|
||||
attrs={"title": _('Edit this variant')},
|
||||
classes=("icon", "icon-fa-pencil"),
|
||||
priority=0)
|
||||
yield Button(
|
||||
"%s variant" % (vm.segment.name),
|
||||
reverse("wagtailadmin_pages:edit", args=[vm.variant_id]),
|
||||
attrs={"title": _("Edit this variant")},
|
||||
classes=("icon", "icon-fa-pencil"),
|
||||
priority=0,
|
||||
)
|
||||
|
||||
for segment in metadata.get_unused_segments():
|
||||
yield Button('%s variant' % (segment.name),
|
||||
reverse('segment:copy_page', args=[page.pk, segment.pk]),
|
||||
attrs={"title": _('Create this variant')},
|
||||
classes=("icon", "icon-fa-plus"),
|
||||
priority=100)
|
||||
yield Button(
|
||||
"%s variant" % (segment.name),
|
||||
reverse("segment:copy_page", args=[page.pk, segment.pk]),
|
||||
attrs={"title": _("Create this variant")},
|
||||
classes=("icon", "icon-fa-plus"),
|
||||
priority=100,
|
||||
)
|
||||
|
||||
yield Button(_('Create a new segment'),
|
||||
reverse('wagtail_personalisation_segment_modeladmin_create'),
|
||||
attrs={"title": _('Create a new segment')},
|
||||
classes=("icon", "icon-fa-snowflake-o"),
|
||||
priority=200)
|
||||
yield Button(
|
||||
_("Create a new segment"),
|
||||
reverse("wagtail_personalisation_segment_modeladmin_create"),
|
||||
attrs={"title": _("Create a new segment")},
|
||||
classes=("icon", "icon-fa-snowflake-o"),
|
||||
priority=200,
|
||||
)
|
||||
|
||||
|
||||
class CorrectedPagesSummaryPanel(PagesSummaryItem):
|
||||
class CorrectedPagesSummaryItem(PagesSummaryItem):
|
||||
def get_context(self):
|
||||
context = super(CorrectedPagesSummaryPanel, self).get_context()
|
||||
# Perform the same check as Wagtail to get the correct count.
|
||||
# Only correct the count when a root page is available to the user.
|
||||
# The `PagesSummaryItem` will return a page count of 0 otherwise.
|
||||
# https://github.com/wagtail/wagtail/blob/5c9ff23e229acabad406c42c4e13cbaea32e6c15/wagtail/admin/site_summary.py#L38
|
||||
context = super().get_context()
|
||||
root_page = context.get("root_page", None)
|
||||
if root_page:
|
||||
pages = utils.exclude_variants(
|
||||
Page.objects.descendant_of(root_page, inclusive=True)
|
||||
)
|
||||
page_count = pages.count()
|
||||
|
||||
pages = utils.exclude_variants(Page.objects.all().specific())
|
||||
if root_page.is_root():
|
||||
page_count -= 1
|
||||
|
||||
context["total_pages"] = page_count
|
||||
|
||||
context['total_pages'] = len(pages) - 1
|
||||
return context
|
||||
|
||||
|
||||
@hooks.register('construct_homepage_summary_items')
|
||||
@hooks.register("construct_homepage_summary_items")
|
||||
def add_corrected_pages_summary_panel(request, items):
|
||||
"""Replaces the Pages summary panel to hide variants."""
|
||||
for index, item in enumerate(items):
|
||||
if item.__class__ is PagesSummaryItem:
|
||||
items[index] = CorrectedPagesSummaryPanel(request)
|
||||
items[index] = CorrectedPagesSummaryItem(request)
|
||||
|
||||
|
||||
class SegmentSummaryPanel(SummaryItem):
|
||||
@ -169,16 +226,21 @@ class SegmentSummaryPanel(SummaryItem):
|
||||
site and allowing quick access to the Segment dashboard.
|
||||
|
||||
"""
|
||||
|
||||
order = 2000
|
||||
|
||||
def render(self):
|
||||
segment_count = models.Segment.objects.count()
|
||||
target_url = reverse('wagtail_personalisation_segment_modeladmin_index')
|
||||
target_url = reverse("wagtail_personalisation_segment_modeladmin_index")
|
||||
title = _("Segments")
|
||||
return mark_safe("""
|
||||
return mark_safe(
|
||||
"""
|
||||
<li class="icon icon-fa-snowflake-o">
|
||||
<a href="{}"><span>{}</span>{}</a>
|
||||
</li>""".format(target_url, segment_count, title))
|
||||
</li>""".format(
|
||||
target_url, segment_count, title
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class PersonalisedPagesSummaryPanel(PagesSummaryItem):
|
||||
@ -186,12 +248,17 @@ class PersonalisedPagesSummaryPanel(PagesSummaryItem):
|
||||
|
||||
def render(self):
|
||||
page_count = models.PersonalisablePageMetadata.objects.filter(
|
||||
segment__isnull=True).count()
|
||||
segment__isnull=True
|
||||
).count()
|
||||
title = _("Personalised Page")
|
||||
return mark_safe("""
|
||||
return mark_safe(
|
||||
"""
|
||||
<li class="icon icon-fa-file-o">
|
||||
<span>{}</span>{}{}
|
||||
</li>""".format(page_count, title, pluralize(page_count)))
|
||||
</li>""".format(
|
||||
page_count, title, pluralize(page_count)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class VariantPagesSummaryPanel(PagesSummaryItem):
|
||||
@ -199,15 +266,20 @@ class VariantPagesSummaryPanel(PagesSummaryItem):
|
||||
|
||||
def render(self):
|
||||
page_count = models.PersonalisablePageMetadata.objects.filter(
|
||||
segment__isnull=False).count()
|
||||
segment__isnull=False
|
||||
).count()
|
||||
title = _("Variant")
|
||||
return mark_safe("""
|
||||
return mark_safe(
|
||||
"""
|
||||
<li class="icon icon-fa-files-o">
|
||||
<span>{}</span>{}{}
|
||||
</li>""".format(page_count, title, pluralize(page_count)))
|
||||
</li>""".format(
|
||||
page_count, title, pluralize(page_count)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@hooks.register('construct_homepage_summary_items')
|
||||
@hooks.register("construct_homepage_summary_items")
|
||||
def add_personalisation_summary_panels(request, items):
|
||||
"""Adds a summary panel to the Wagtail dashboard showing the total amount
|
||||
of segments on the site and allowing quick access to the Segment
|
||||
@ -217,3 +289,63 @@ def add_personalisation_summary_panels(request, items):
|
||||
items.append(SegmentSummaryPanel(request))
|
||||
items.append(PersonalisedPagesSummaryPanel(request))
|
||||
items.append(VariantPagesSummaryPanel(request))
|
||||
|
||||
|
||||
@hooks.register("before_delete_page")
|
||||
def delete_related_variants(request, page):
|
||||
if (
|
||||
not isinstance(page, models.PersonalisablePageMixin)
|
||||
or not page.personalisation_metadata.is_canonical
|
||||
):
|
||||
return
|
||||
# Get a list of related personalisation metadata for all the related
|
||||
# variants.
|
||||
variants_metadata = page.personalisation_metadata.variants_metadata.select_related(
|
||||
"variant"
|
||||
)
|
||||
next_url = get_valid_next_url_from_request(request)
|
||||
|
||||
if request.method == "POST":
|
||||
parent_id = page.get_parent().id
|
||||
with transaction.atomic():
|
||||
# To ensure variants are deleted for all descendants, start with
|
||||
# the deepest ones, and explicitly delete variants and metadata
|
||||
# for all of them, including the page itself. Otherwise protected
|
||||
# foreign key constraints are violated. Only consider canonical
|
||||
# pages.
|
||||
for metadata in PersonalisablePageMetadata.objects.filter(
|
||||
canonical_page__in=page.get_descendants(inclusive=True),
|
||||
variant=F("canonical_page"),
|
||||
).order_by("-canonical_page__depth"):
|
||||
for variant_metadata in metadata.variants_metadata.select_related(
|
||||
"variant"
|
||||
):
|
||||
# Call delete() on objects to trigger any signals or hooks.
|
||||
variant_metadata.variant.delete()
|
||||
metadata.delete()
|
||||
metadata.canonical_page.delete()
|
||||
|
||||
msg = _("Page '{0}' and its variants deleted.")
|
||||
messages.success(request, msg.format(page.get_admin_display_title()))
|
||||
|
||||
for fn in hooks.get_hooks("after_delete_page"):
|
||||
result = fn(request, page)
|
||||
if hasattr(result, "status_code"):
|
||||
return result
|
||||
|
||||
if next_url:
|
||||
return redirect(next_url)
|
||||
return redirect("wagtailadmin_explore", parent_id)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"wagtailadmin/pages/wagtail_personalisation/confirm_delete.html",
|
||||
{
|
||||
"page": page,
|
||||
"descendant_count": page.get_descendant_count(),
|
||||
"next": next_url,
|
||||
"variants": Page.objects.filter(
|
||||
pk__in=variants_metadata.values_list("variant_id")
|
||||
),
|
||||
},
|
||||
)
|
||||
|
@ -1,5 +1,3 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import pytest
|
||||
|
||||
pytest_plugins = [
|
||||
@ -7,9 +5,14 @@ pytest_plugins = [
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def enable_db_access(db):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def django_db_setup(django_db_setup, django_db_blocker):
|
||||
from wagtail.wagtailcore.models import Page, Site
|
||||
from wagtail.core.models import Page, Site
|
||||
|
||||
with django_db_blocker.unblock():
|
||||
# Remove some initial data that is brought by the tests.site module
|
||||
|
@ -5,9 +5,21 @@ from django.utils.text import slugify
|
||||
from wagtail_factories.factories import PageFactory
|
||||
|
||||
from tests.site.pages import models
|
||||
from wagtail_personalisation.models import PersonalisablePageMetadata
|
||||
|
||||
try:
|
||||
from wagtail.core.models import Locale
|
||||
|
||||
class LocaleFactory(factory.DjangoModelFactory):
|
||||
language_code = "en"
|
||||
|
||||
class Meta:
|
||||
model = Locale
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
class ContentPageFactory(PageFactory):
|
||||
parent = None
|
||||
title = 'Test page'
|
||||
slug = factory.LazyAttribute(lambda obj: slugify(obj.title))
|
||||
|
||||
@ -21,3 +33,9 @@ class RegularPageFactory(PageFactory):
|
||||
|
||||
class Meta:
|
||||
model = models.RegularPage
|
||||
|
||||
|
||||
class PersonalisablePageMetadataFactory(factory.DjangoModelFactory):
|
||||
|
||||
class Meta:
|
||||
model = PersonalisablePageMetadata
|
||||
|
@ -46,3 +46,8 @@ class VisitCountRuleFactory(factory.DjangoModelFactory):
|
||||
|
||||
class Meta:
|
||||
model = rules.VisitCountRule
|
||||
|
||||
|
||||
class OriginCountryRuleFactory(factory.DjangoModelFactory):
|
||||
class Meta:
|
||||
model = rules.OriginCountryRule
|
||||
|
@ -1,5 +1,5 @@
|
||||
import factory
|
||||
from wagtail.wagtailcore.models import Site
|
||||
from wagtail.core.models import Site
|
||||
|
||||
from tests.factories.page import ContentPageFactory
|
||||
|
||||
|
@ -23,7 +23,7 @@ def site():
|
||||
return site
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@pytest.fixture()
|
||||
def segmented_page(site):
|
||||
page = ContentPageFactory(parent=site.root_page, slug='personalised')
|
||||
segment = SegmentFactory()
|
||||
@ -46,6 +46,6 @@ class RequestFactory(BaseRequestFactory):
|
||||
return request
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@pytest.fixture()
|
||||
def user(django_user_model):
|
||||
return django_user_model.objects.create(username='user')
|
||||
|
@ -1,9 +1,5 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import os
|
||||
|
||||
import django
|
||||
from pkg_resources import parse_version as V
|
||||
from importlib.util import find_spec
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
@ -56,37 +52,32 @@ TEMPLATES = [
|
||||
]
|
||||
|
||||
|
||||
def get_middleware_settings():
|
||||
return (
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
MIDDLEWARE = (
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
)
|
||||
|
||||
'wagtail.wagtailcore.middleware.SiteMiddleware',
|
||||
)
|
||||
|
||||
|
||||
# Django 1.10 started to use "MIDDLEWARE" instead of "MIDDLEWARE_CLASSES".
|
||||
if V(django.get_version()) < V('1.10'):
|
||||
MIDDLEWARE_CLASSES = get_middleware_settings()
|
||||
if find_spec('wagtail.contrib.legacy'):
|
||||
MIDDLEWARE += ('wagtail.contrib.legacy.sitemiddleware.SiteMiddleware',)
|
||||
else:
|
||||
MIDDLEWARE = get_middleware_settings()
|
||||
MIDDLEWARE += ('wagtail.core.middleware.SiteMiddleware', )
|
||||
|
||||
|
||||
INSTALLED_APPS = (
|
||||
'wagtail_personalisation',
|
||||
|
||||
'wagtail.contrib.modeladmin',
|
||||
'wagtail.wagtailsearch',
|
||||
'wagtail.wagtailsites',
|
||||
'wagtail.wagtailusers',
|
||||
'wagtail.wagtailimages',
|
||||
'wagtail.wagtaildocs',
|
||||
'wagtail.wagtailadmin',
|
||||
'wagtail.wagtailcore',
|
||||
'wagtail.search',
|
||||
'wagtail.sites',
|
||||
'wagtail.users',
|
||||
'wagtail.images',
|
||||
'wagtail.documents',
|
||||
'wagtail.admin',
|
||||
'wagtail.core',
|
||||
|
||||
'taggit',
|
||||
|
||||
|
@ -3,7 +3,7 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
import wagtail.wagtailcore.fields
|
||||
import wagtail.core.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
import wagtail_personalisation.models
|
||||
@ -23,7 +23,7 @@ class Migration(migrations.Migration):
|
||||
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')), # noqa: E501
|
||||
('subtitle', models.CharField(blank=True, default='', max_length=255)),
|
||||
('body', wagtail.wagtailcore.fields.RichTextField(blank=True, default='')),
|
||||
('body', wagtail.core.fields.RichTextField(blank=True, default='')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
|
@ -1,9 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.1 on 2017-06-02 04:26
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
import wagtail.wagtailcore.fields
|
||||
import wagtail.core.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@ -20,7 +19,7 @@ class Migration(migrations.Migration):
|
||||
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')), # noqa: E501
|
||||
('subtitle', models.CharField(blank=True, default='', max_length=255)),
|
||||
('body', wagtail.wagtailcore.fields.RichTextField(blank=True, default='')),
|
||||
('body', wagtail.core.fields.RichTextField(blank=True, default='')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
|
@ -1,9 +1,7 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from django.db import models
|
||||
from wagtail.wagtailadmin.edit_handlers import FieldPanel
|
||||
from wagtail.wagtailcore.fields import RichTextField
|
||||
from wagtail.wagtailcore.models import Page
|
||||
from wagtail.admin.edit_handlers import FieldPanel
|
||||
from wagtail.core.fields import RichTextField
|
||||
from wagtail.core.models import Page
|
||||
|
||||
from wagtail_personalisation.models import PersonalisablePageMixin
|
||||
|
||||
|
@ -2,12 +2,12 @@ from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from django.conf.urls import include, url
|
||||
from django.contrib import admin
|
||||
from wagtail.wagtailadmin import urls as wagtailadmin_urls
|
||||
from wagtail.wagtailcore import urls as wagtail_urls
|
||||
from wagtail.wagtaildocs import urls as wagtaildocs_urls
|
||||
from wagtail.admin import urls as wagtailadmin_urls
|
||||
from wagtail.core import urls as wagtail_urls
|
||||
from wagtail.documents import urls as wagtaildocs_urls
|
||||
|
||||
urlpatterns = [
|
||||
url(r'^django-admin/', include(admin.site.urls)),
|
||||
url(r'^django-admin/', admin.site.urls),
|
||||
|
||||
url(r'^admin/', include(wagtailadmin_urls)),
|
||||
url(r'^documents/', include(wagtaildocs_urls)),
|
||||
|
@ -20,6 +20,23 @@ def test_get_segments(rf):
|
||||
assert segments == [segment_1, segment_2]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_segments_session(rf):
|
||||
request = rf.get('/')
|
||||
|
||||
adapter = adapters.SessionSegmentsAdapter(request)
|
||||
|
||||
segment_1 = SegmentFactory(name='segment-1', persistent=True)
|
||||
segment_2 = SegmentFactory(name='segment-2', persistent=True)
|
||||
|
||||
adapter.set_segments([segment_1, segment_2])
|
||||
assert len(request.session['segments']) == 2
|
||||
|
||||
adapter._segment_cache = None
|
||||
segments = adapter.get_segments()
|
||||
assert segments == [segment_1, segment_2]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_segment_by_id(rf):
|
||||
request = rf.get('/')
|
||||
|
@ -4,7 +4,7 @@ import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.factories.rule import ReferralRuleFactory, QueryRuleFactory
|
||||
from tests.factories.rule import QueryRuleFactory, ReferralRuleFactory
|
||||
from tests.factories.segment import SegmentFactory
|
||||
from wagtail_personalisation.models import Segment
|
||||
from wagtail_personalisation.rules import TimeRule
|
||||
@ -45,4 +45,3 @@ def test_query_rule_create():
|
||||
|
||||
assert query_rule.parameter == 'query'
|
||||
assert query_rule.value == 'value'
|
||||
assert query_rule.static
|
||||
|
@ -1,10 +1,12 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import datetime
|
||||
|
||||
import pytest
|
||||
from django.db.models import ProtectedError
|
||||
|
||||
from tests.factories.page import ContentPageFactory
|
||||
from tests.factories.segment import SegmentFactory
|
||||
from tests.site.pages import models
|
||||
from wagtail_personalisation.models import PersonalisablePageMetadata, Segment
|
||||
from wagtail_personalisation.rules import TimeRule
|
||||
|
||||
|
||||
@ -25,3 +27,57 @@ def test_metadata_page_has_variants(segmented_page):
|
||||
canonical = segmented_page.personalisation_metadata.canonical_page
|
||||
assert canonical.personalisation_metadata.is_canonical
|
||||
assert canonical.personalisation_metadata.has_variants
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_content_page_model():
|
||||
page = ContentPageFactory()
|
||||
qs = models.ContentPage.objects.all()
|
||||
assert page in qs
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_variant_can_be_deleted_without_error(segmented_page):
|
||||
segmented_page.delete()
|
||||
# Make sure the metadata gets deleted because of models.CASCADE.
|
||||
with pytest.raises(PersonalisablePageMetadata.DoesNotExist):
|
||||
segmented_page._personalisable_page_metadata.refresh_from_db()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_canonical_page_deletion_is_protected(segmented_page):
|
||||
# When deleting canonical page without deleting variants, it should return
|
||||
# an error. All variants should be deleted beforehand.
|
||||
with pytest.raises(ProtectedError):
|
||||
segmented_page.personalisation_metadata.canonical_page.delete()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_page_protection_when_deleting_segment(segmented_page):
|
||||
segment = segmented_page.personalisation_metadata.segment
|
||||
assert len(segment.get_used_pages())
|
||||
with pytest.raises(ProtectedError):
|
||||
segment.delete()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_sitemap_generation_for_canonical_pages_is_enabled(segmented_page):
|
||||
canonical = segmented_page.personalisation_metadata.canonical_page
|
||||
assert canonical.personalisation_metadata.is_canonical
|
||||
assert canonical.get_sitemap_urls()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_sitemap_generation_for_variants_is_disabled(segmented_page):
|
||||
assert not segmented_page.personalisation_metadata.is_canonical
|
||||
assert not segmented_page.get_sitemap_urls()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_segment_edit_view(site, client, django_user_model):
|
||||
test_segment = SegmentFactory()
|
||||
try:
|
||||
new_panel = test_segment.panels[1].children[0].bind_to(model=Segment)
|
||||
except AttributeError:
|
||||
new_panel = test_segment.panels[1].children[0].bind_to_model(Segment)
|
||||
assert new_panel.related.name == "wagtail_personalisation_timerules"
|
||||
|
202
tests/unit/test_rules_country_origin.py
Normal file
@ -0,0 +1,202 @@
|
||||
from importlib.util import find_spec
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.factories.rule import OriginCountryRuleFactory
|
||||
from tests.factories.segment import SegmentFactory
|
||||
from wagtail_personalisation.rules import get_geoip_module
|
||||
|
||||
skip_if_geoip2_installed = pytest.mark.skipif(
|
||||
find_spec('geoip2'), reason='requires GeoIP2 to be not installed'
|
||||
)
|
||||
|
||||
skip_if_geoip2_not_installed = pytest.mark.skipif(
|
||||
not find_spec('geoip2'), reason='requires GeoIP2 to be installed.'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_cloudflare_country_with_header(rf):
|
||||
segment = SegmentFactory(name='Test segment')
|
||||
rule = OriginCountryRuleFactory(segment=segment, country='GB')
|
||||
request = rf.get('/', HTTP_CF_IPCOUNTRY='PL')
|
||||
assert rule.get_cloudflare_country(request) == 'pl'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_cloudflare_country_with_no_header(rf):
|
||||
segment = SegmentFactory(name='Test segment')
|
||||
rule = OriginCountryRuleFactory(segment=segment, country='GB')
|
||||
request = rf.get('/')
|
||||
assert 'HTTP_CF_IPCOUNTRY' not in request.META
|
||||
assert rule.get_cloudflare_country(request) is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_cloudfront_country_with_header(rf):
|
||||
segment = SegmentFactory(name='Test segment')
|
||||
rule = OriginCountryRuleFactory(segment=segment, country='GB')
|
||||
request = rf.get('/', HTTP_CLOUDFRONT_VIEWER_COUNTRY='BY')
|
||||
assert rule.get_cloudfront_country(request) == 'by'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_cloudfront_country_with_no_header(rf):
|
||||
segment = SegmentFactory(name='Test segment')
|
||||
rule = OriginCountryRuleFactory(segment=segment, country='GB')
|
||||
request = rf.get('/')
|
||||
assert 'HTTP_CLOUDFRONT_VIEWER_COUNTRY' not in request.META
|
||||
assert rule.get_cloudfront_country(request) is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_geoip_country_with_remote_addr(rf):
|
||||
segment = SegmentFactory(name='Test segment')
|
||||
rule = OriginCountryRuleFactory(segment=segment, country='GB')
|
||||
request = rf.get('/', REMOTE_ADDR='173.254.89.34')
|
||||
geoip_mock = MagicMock()
|
||||
with patch('wagtail_personalisation.rules.get_geoip_module',
|
||||
return_value=geoip_mock) as geoip_import_mock:
|
||||
rule.get_geoip_country(request)
|
||||
geoip_import_mock.assert_called_once()
|
||||
geoip_mock.assert_called_once()
|
||||
assert geoip_mock.mock_calls[1] == call().country_code('173.254.89.34')
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_country_calls_all_methods(rf):
|
||||
segment = SegmentFactory(name='Test segment')
|
||||
rule = OriginCountryRuleFactory(segment=segment, country='GB')
|
||||
request = rf.get('/')
|
||||
|
||||
@patch.object(rule, 'get_geoip_country', return_value='')
|
||||
@patch.object(rule, 'get_cloudflare_country', return_value='')
|
||||
@patch.object(rule, 'get_cloudfront_country', return_value='')
|
||||
def test_mock(cloudfront_mock, cloudflare_mock, geoip_mock):
|
||||
country = rule.get_country(request)
|
||||
cloudflare_mock.assert_called_once_with(request)
|
||||
cloudfront_mock.assert_called_once_with(request)
|
||||
geoip_mock.assert_called_once_with(request)
|
||||
assert country is None
|
||||
|
||||
test_mock()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_country_does_not_use_all_detection_methods_unnecessarily(rf):
|
||||
segment = SegmentFactory(name='Test segment')
|
||||
rule = OriginCountryRuleFactory(segment=segment, country='GB')
|
||||
request = rf.get('/')
|
||||
|
||||
@patch.object(rule, 'get_geoip_country', return_value='')
|
||||
@patch.object(rule, 'get_cloudflare_country', return_value='t1')
|
||||
@patch.object(rule, 'get_cloudfront_country', return_value='')
|
||||
def test_mock(cloudfront_mock, cloudflare_mock, geoip_mock):
|
||||
country = rule.get_country(request)
|
||||
cloudflare_mock.assert_called_once_with(request)
|
||||
cloudfront_mock.assert_not_called()
|
||||
geoip_mock.assert_not_called()
|
||||
assert country == 't1'
|
||||
|
||||
test_mock()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_test_user_calls_get_country(rf):
|
||||
segment = SegmentFactory(name='Test segment')
|
||||
rule = OriginCountryRuleFactory(segment=segment, country='GB')
|
||||
request = rf.get('/')
|
||||
with patch.object(rule, 'get_country') as get_country_mock:
|
||||
rule.test_user(request)
|
||||
get_country_mock.assert_called_once_with(request)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_test_user_returns_true_if_cloudflare_country_match(rf):
|
||||
segment = SegmentFactory(name='Test segment')
|
||||
rule = OriginCountryRuleFactory(segment=segment, country='GB')
|
||||
request = rf.get('/', HTTP_CF_IPCOUNTRY='GB')
|
||||
assert rule.test_user(request) is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_test_user_returns_false_if_cloudflare_country_doesnt_match(rf):
|
||||
segment = SegmentFactory(name='Test segment')
|
||||
rule = OriginCountryRuleFactory(segment=segment, country='GB')
|
||||
request = rf.get('/', HTTP_CF_IPCOUNTRY='NL')
|
||||
assert not rule.test_user(request)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_test_user_returns_false_if_cloudfront_country_doesnt_match(rf):
|
||||
segment = SegmentFactory(name='Test segment')
|
||||
rule = OriginCountryRuleFactory(segment=segment, country='GB')
|
||||
request = rf.get('/', HTTP_CLOUDFRONT_VIEWER_COUNTRY='NL')
|
||||
assert rule.test_user(request) is False
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_test_user_returns_true_if_cloudfront_country_matches(rf):
|
||||
segment = SegmentFactory(name='Test segment')
|
||||
rule = OriginCountryRuleFactory(segment=segment, country='se')
|
||||
request = rf.get('/', HTTP_CLOUDFRONT_VIEWER_COUNTRY='SE')
|
||||
assert rule.test_user(request) is True
|
||||
|
||||
|
||||
@skip_if_geoip2_not_installed
|
||||
@pytest.mark.django_db
|
||||
def test_test_user_geoip_module_matches(rf):
|
||||
segment = SegmentFactory(name='Test segment')
|
||||
rule = OriginCountryRuleFactory(segment=segment, country='se')
|
||||
request = rf.get('/', REMOTE_ADDR='123.120.0.2')
|
||||
GeoIP2Mock = MagicMock()
|
||||
GeoIP2Mock().configure_mock(**{'country_code.return_value': 'SE'})
|
||||
GeoIP2Mock.reset_mock()
|
||||
with patch('wagtail_personalisation.rules.get_geoip_module',
|
||||
return_value=GeoIP2Mock):
|
||||
assert rule.test_user(request) is True
|
||||
assert GeoIP2Mock.mock_calls == [
|
||||
call(),
|
||||
call().country_code('123.120.0.2'),
|
||||
]
|
||||
|
||||
|
||||
@skip_if_geoip2_not_installed
|
||||
@pytest.mark.django_db
|
||||
def test_test_user_geoip_module_does_not_match(rf):
|
||||
segment = SegmentFactory(name='Test segment')
|
||||
rule = OriginCountryRuleFactory(segment=segment, country='nl')
|
||||
request = rf.get('/', REMOTE_ADDR='123.120.0.2')
|
||||
GeoIP2Mock = MagicMock()
|
||||
GeoIP2Mock().configure_mock(**{'country_code.return_value': 'SE'})
|
||||
GeoIP2Mock.reset_mock()
|
||||
with patch('wagtail_personalisation.rules.get_geoip_module',
|
||||
return_value=GeoIP2Mock):
|
||||
assert rule.test_user(request) is False
|
||||
assert GeoIP2Mock.mock_calls == [
|
||||
call(),
|
||||
call().country_code('123.120.0.2')
|
||||
]
|
||||
|
||||
|
||||
@skip_if_geoip2_installed
|
||||
@pytest.mark.django_db
|
||||
def test_test_user_does_not_use_geoip_module_if_disabled(rf):
|
||||
segment = SegmentFactory(name='Test segment')
|
||||
rule = OriginCountryRuleFactory(segment=segment, country='se')
|
||||
request = rf.get('/', REMOTE_ADDR='123.120.0.2')
|
||||
assert rule.test_user(request) is False
|
||||
|
||||
|
||||
@skip_if_geoip2_installed
|
||||
def test_get_geoip_module_disabled():
|
||||
with pytest.raises(ImportError):
|
||||
from django.contrib.gis.geoip2 import GeoIP2 # noqa
|
||||
assert get_geoip_module() is None
|
||||
|
||||
|
||||
@skip_if_geoip2_not_installed
|
||||
def test_get_geoip_module_enabled():
|
||||
from django.contrib.gis.geoip2 import GeoIP2
|
||||
assert get_geoip_module() is GeoIP2
|
@ -1,5 +1,9 @@
|
||||
import pytest
|
||||
|
||||
from tests.factories.rule import VisitCountRuleFactory
|
||||
from tests.factories.segment import SegmentFactory
|
||||
from wagtail_personalisation.rules import VisitCountRule
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_visit_count(site, client):
|
||||
@ -20,3 +24,56 @@ def test_visit_count(site, client):
|
||||
visit_count = client.session['visit_count']
|
||||
assert visit_count[0]['count'] == 2
|
||||
assert visit_count[1]['count'] == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_call_test_user_on_invalid_rule_fails(site, user, mocker):
|
||||
rule = VisitCountRule()
|
||||
assert not (rule.test_user(None, user))
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_visit_count_call_test_user_with_user(site, client, user):
|
||||
segment = SegmentFactory(name='VisitCount')
|
||||
rule = VisitCountRuleFactory(counted_page=site.root_page, segment=segment)
|
||||
|
||||
session = client.session
|
||||
session['visit_count'] = [{'path': '/', 'count': 2}]
|
||||
session.save()
|
||||
client.force_login(user)
|
||||
|
||||
assert rule.test_user(None, user)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_visit_count_call_test_user_with_user_or_request_fails(site, client, user):
|
||||
segment = SegmentFactory(name='VisitCount')
|
||||
rule = VisitCountRuleFactory(counted_page=site.root_page, segment=segment)
|
||||
|
||||
session = client.session
|
||||
session['visit_count'] = [{'path': '/', 'count': 2}]
|
||||
session.save()
|
||||
client.force_login(user)
|
||||
|
||||
assert not rule.test_user(None)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_column_header(site):
|
||||
segment = SegmentFactory(name='VisitCount')
|
||||
rule = VisitCountRuleFactory(counted_page=site.root_page, segment=segment)
|
||||
|
||||
assert rule.get_column_header() == 'Visit count - Test page'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_user_info_string_returns_count(site, client, user):
|
||||
segment = SegmentFactory(name='VisitCount')
|
||||
rule = VisitCountRuleFactory(counted_page=site.root_page, segment=segment)
|
||||
|
||||
session = client.session
|
||||
session['visit_count'] = [{'path': '/', 'count': 2}]
|
||||
session.save()
|
||||
client.force_login(user)
|
||||
|
||||
assert rule.get_user_info_string(user) == '2'
|
||||
|
@ -8,8 +8,7 @@ from django.forms.models import model_to_dict
|
||||
from tests.factories.segment import SegmentFactory
|
||||
from wagtail_personalisation.forms import SegmentAdminForm
|
||||
from wagtail_personalisation.models import Segment
|
||||
from wagtail_personalisation.rules import (AbstractBaseRule, TimeRule,
|
||||
VisitCountRule)
|
||||
from wagtail_personalisation.rules import TimeRule, VisitCountRule
|
||||
|
||||
|
||||
def form_with_data(segment, *rules):
|
||||
@ -36,22 +35,32 @@ def form_with_data(segment, *rules):
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_session_added_to_static_segment_at_creation(site, client, user):
|
||||
session = client.session
|
||||
session.save()
|
||||
client.force_login(user)
|
||||
client.get(site.root_page.url)
|
||||
|
||||
def test_user_added_to_static_segment_at_creation(site, user, mocker):
|
||||
segment = SegmentFactory.build(type=Segment.TYPE_STATIC)
|
||||
rule = VisitCountRule(counted_page=site.root_page)
|
||||
form = form_with_data(segment, rule)
|
||||
mocker.patch('wagtail_personalisation.rules.VisitCountRule.test_user', return_value=True)
|
||||
instance = form.save()
|
||||
|
||||
assert user in instance.static_users.all()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_anonymous_user_not_added_to_static_segment_at_creation(site, client):
|
||||
def test_user_not_added_to_full_static_segment_at_creation(site, django_user_model, mocker):
|
||||
django_user_model.objects.create(username='first')
|
||||
django_user_model.objects.create(username='second')
|
||||
segment = SegmentFactory.build(type=Segment.TYPE_STATIC, count=1)
|
||||
rule = VisitCountRule(counted_page=site.root_page)
|
||||
form = form_with_data(segment, rule)
|
||||
mocker.patch('wagtail_personalisation.rules.VisitCountRule.test_user',
|
||||
side_effect=[True, True])
|
||||
instance = form.save()
|
||||
|
||||
assert len(instance.static_users.all()) == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_anonymous_user_not_added_to_static_segment_at_creation(site, client, mocker):
|
||||
session = client.session
|
||||
session.save()
|
||||
client.get(site.root_page.url)
|
||||
@ -59,43 +68,32 @@ def test_anonymous_user_not_added_to_static_segment_at_creation(site, client):
|
||||
segment = SegmentFactory.build(type=Segment.TYPE_STATIC)
|
||||
rule = VisitCountRule(counted_page=site.root_page)
|
||||
form = form_with_data(segment, rule)
|
||||
mock_test_rule = mocker.patch('wagtail_personalisation.adapters.SessionSegmentsAdapter._test_rules')
|
||||
instance = form.save()
|
||||
|
||||
assert not instance.static_users.all()
|
||||
assert mock_test_rule.call_count == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_match_any_correct_populates(site, client, django_user_model):
|
||||
def test_match_any_correct_populates(site, django_user_model, mocker):
|
||||
user = django_user_model.objects.create(username='first')
|
||||
session = client.session
|
||||
client.force_login(user)
|
||||
client.get(site.root_page.url)
|
||||
|
||||
other_user = django_user_model.objects.create(username='second')
|
||||
client.cookies.clear()
|
||||
second_session = client.session
|
||||
other_page = site.root_page.get_last_child()
|
||||
client.force_login(other_user)
|
||||
client.get(other_page.url)
|
||||
|
||||
segment = SegmentFactory.build(type=Segment.TYPE_STATIC, match_any=True)
|
||||
rule_1 = VisitCountRule(counted_page=site.root_page)
|
||||
rule_2 = VisitCountRule(counted_page=other_page)
|
||||
form = form_with_data(segment, rule_1, rule_2)
|
||||
mocker.patch('wagtail_personalisation.rules.VisitCountRule.test_user', side_effect=[True, False, True, False])
|
||||
instance = form.save()
|
||||
|
||||
assert session.session_key != second_session.session_key
|
||||
assert user in instance.static_users.all()
|
||||
assert other_user in instance.static_users.all()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_mixed_static_dynamic_session_doesnt_generate_at_creation(site, client, user):
|
||||
session = client.session
|
||||
session.save()
|
||||
client.force_login(user)
|
||||
client.get(site.root_page.url)
|
||||
|
||||
def test_mixed_static_dynamic_session_doesnt_generate_at_creation(site, mocker):
|
||||
segment = SegmentFactory.build(type=Segment.TYPE_STATIC, count=1)
|
||||
static_rule = VisitCountRule(counted_page=site.root_page)
|
||||
non_static_rule = TimeRule(
|
||||
@ -103,9 +101,12 @@ def test_mixed_static_dynamic_session_doesnt_generate_at_creation(site, client,
|
||||
end_time=datetime.time(23, 59, 59),
|
||||
)
|
||||
form = form_with_data(segment, static_rule, non_static_rule)
|
||||
|
||||
mock_test_rule = mocker.patch('wagtail_personalisation.adapters.SessionSegmentsAdapter._test_rules')
|
||||
instance = form.save()
|
||||
|
||||
assert not instance.static_users.all()
|
||||
assert mock_test_rule.call_count == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@ -181,12 +182,7 @@ def test_session_not_added_to_static_segment_after_full(site, client, django_use
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_sessions_not_added_to_static_segment_if_rule_not_static(client, site, user):
|
||||
session = client.session
|
||||
session.save()
|
||||
client.force_login(user)
|
||||
client.get(site.root_page.url)
|
||||
|
||||
def test_sessions_not_added_to_static_segment_if_rule_not_static(mocker):
|
||||
segment = SegmentFactory.build(type=Segment.TYPE_STATIC, count=1)
|
||||
rule = TimeRule(
|
||||
start_time=datetime.time(0, 0, 0),
|
||||
@ -194,26 +190,27 @@ def test_sessions_not_added_to_static_segment_if_rule_not_static(client, site, u
|
||||
segment=segment,
|
||||
)
|
||||
form = form_with_data(segment, rule)
|
||||
mock_test_rule = mocker.patch('wagtail_personalisation.adapters.SessionSegmentsAdapter._test_rules')
|
||||
instance = form.save()
|
||||
|
||||
assert not instance.static_users.all()
|
||||
assert mock_test_rule.call_count == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_does_not_calculate_the_segment_again(site, client, mocker, user):
|
||||
session = client.session
|
||||
session.save()
|
||||
client.force_login(user)
|
||||
client.get(site.root_page.url)
|
||||
|
||||
segment = SegmentFactory.build(type=Segment.TYPE_STATIC, count=2)
|
||||
rule = VisitCountRule(counted_page=site.root_page, segment=segment)
|
||||
form = form_with_data(segment, rule)
|
||||
mocker.patch('wagtail_personalisation.rules.VisitCountRule.test_user', return_value=True)
|
||||
instance = form.save()
|
||||
|
||||
assert user in instance.static_users.all()
|
||||
|
||||
mock_test_rule = mocker.patch('wagtail_personalisation.adapters.SessionSegmentsAdapter._test_rules')
|
||||
session = client.session
|
||||
session.save()
|
||||
client.force_login(user)
|
||||
client.get(site.root_page.url)
|
||||
assert mock_test_rule.call_count == 0
|
||||
|
||||
@ -332,6 +329,7 @@ def test_offered_dynamic_segment_if_random_is_below_percentage(site, client, moc
|
||||
session.save()
|
||||
client.get(site.root_page.url)
|
||||
|
||||
assert len(client.session['excluded_segments']) == 0
|
||||
assert instance.id == client.session['segments'][0]['id']
|
||||
|
||||
|
||||
@ -341,7 +339,7 @@ def test_not_offered_dynamic_segment_if_random_is_above_percentage(site, client,
|
||||
randomisation_percent=40)
|
||||
rule = VisitCountRule(counted_page=site.root_page)
|
||||
form = form_with_data(segment, rule)
|
||||
form.save()
|
||||
instance = form.save()
|
||||
|
||||
mocker.patch('random.randint', return_value=41)
|
||||
session = client.session
|
||||
@ -349,6 +347,7 @@ def test_not_offered_dynamic_segment_if_random_is_above_percentage(site, client,
|
||||
client.get(site.root_page.url)
|
||||
|
||||
assert len(client.session['segments']) == 0
|
||||
assert instance.id == client.session['excluded_segments'][0]['id']
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@ -388,16 +387,12 @@ def test_always_in_segment_if_percentage_is_100(site, client, mocker, user):
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_not_added_to_static_segment_at_creation_if_random_above_percent(site, client, mocker, user):
|
||||
session = client.session
|
||||
session.save()
|
||||
client.force_login(user)
|
||||
client.get(site.root_page.url)
|
||||
|
||||
def test_not_added_to_static_segment_at_creation_if_random_above_percent(site, mocker, user):
|
||||
mocker.patch('random.randint', return_value=41)
|
||||
segment = SegmentFactory.build(type=Segment.TYPE_STATIC, randomisation_percent=40)
|
||||
rule = VisitCountRule(counted_page=site.root_page)
|
||||
form = form_with_data(segment, rule)
|
||||
mocker.patch('wagtail_personalisation.rules.VisitCountRule.test_user', return_value=True)
|
||||
instance = form.save()
|
||||
|
||||
assert user not in instance.static_users.all()
|
||||
@ -405,16 +400,12 @@ def test_not_added_to_static_segment_at_creation_if_random_above_percent(site, c
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_added_to_static_segment_at_creation_if_random_below_percent(site, client, mocker, user):
|
||||
session = client.session
|
||||
session.save()
|
||||
client.force_login(user)
|
||||
client.get(site.root_page.url)
|
||||
|
||||
def test_added_to_static_segment_at_creation_if_random_below_percent(site, mocker, user):
|
||||
mocker.patch('random.randint', return_value=39)
|
||||
segment = SegmentFactory.build(type=Segment.TYPE_STATIC, randomisation_percent=40)
|
||||
rule = VisitCountRule(counted_page=site.root_page)
|
||||
form = form_with_data(segment, rule)
|
||||
mocker.patch('wagtail_personalisation.rules.VisitCountRule.test_user', return_value=True)
|
||||
instance = form.save()
|
||||
|
||||
assert user in instance.static_users.all()
|
||||
@ -446,7 +437,31 @@ def test_rules_check_skipped_if_user_in_excluded(site, client, mocker, user):
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_matched_user_count_added_to_segment_at_creation(site, client, mocker, django_user_model):
|
||||
def test_rules_check_skipped_if_dynamic_segment_in_excluded(site, client, mocker, user):
|
||||
segment = SegmentFactory.build(type=Segment.TYPE_DYNAMIC,
|
||||
randomisation_percent=100)
|
||||
rule = VisitCountRule(counted_page=site.root_page)
|
||||
form = form_with_data(segment, rule)
|
||||
instance = form.save()
|
||||
instance.persistent = True
|
||||
instance.save()
|
||||
|
||||
session = client.session
|
||||
session['excluded_segments'] = [{'id': instance.pk}]
|
||||
session.save()
|
||||
|
||||
mock_test_rule = mocker.patch(
|
||||
'wagtail_personalisation.adapters.SessionSegmentsAdapter._test_rules')
|
||||
|
||||
client.force_login(user)
|
||||
client.get(site.root_page.url)
|
||||
|
||||
assert mock_test_rule.call_count == 0
|
||||
assert len(client.session['segments']) == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_matched_user_count_added_to_segment_at_creation(site, mocker, django_user_model):
|
||||
django_user_model.objects.create(username='first')
|
||||
django_user_model.objects.create(username='second')
|
||||
|
||||
@ -454,6 +469,7 @@ def test_matched_user_count_added_to_segment_at_creation(site, client, mocker, d
|
||||
rule = VisitCountRule()
|
||||
|
||||
form = form_with_data(segment, rule)
|
||||
form.instance.type = Segment.TYPE_STATIC
|
||||
mock_test_user = mocker.patch('wagtail_personalisation.rules.VisitCountRule.test_user', return_value=True)
|
||||
instance = form.save()
|
||||
|
||||
@ -462,152 +478,97 @@ def test_matched_user_count_added_to_segment_at_creation(site, client, mocker, d
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_count_users_matching_static_rules(site, client, django_user_model):
|
||||
class TestStaticRule(AbstractBaseRule):
|
||||
static = True
|
||||
|
||||
class Meta:
|
||||
app_label = 'wagtail_personalisation'
|
||||
|
||||
def test_user(self, request, user):
|
||||
return True
|
||||
|
||||
def test_count_users_matching_static_rules(site, client, mocker, django_user_model):
|
||||
django_user_model.objects.create(username='first')
|
||||
django_user_model.objects.create(username='second')
|
||||
|
||||
segment = SegmentFactory.build(type=Segment.TYPE_STATIC)
|
||||
rule = TestStaticRule()
|
||||
rule = VisitCountRule(counted_page=site.root_page)
|
||||
form = form_with_data(segment, rule)
|
||||
mocker.patch('wagtail_personalisation.rules.VisitCountRule.test_user', return_value=True)
|
||||
|
||||
assert form.count_matching_users([rule], True) is 2
|
||||
assert form.count_matching_users([rule], True) == 2
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_count_matching_users_excludes_staff(site, client, django_user_model):
|
||||
class TestStaticRule(AbstractBaseRule):
|
||||
static = True
|
||||
|
||||
class Meta:
|
||||
app_label = 'wagtail_personalisation'
|
||||
|
||||
def test_user(self, request, user):
|
||||
return True
|
||||
|
||||
def test_count_matching_users_excludes_staff(site, client, mocker, django_user_model):
|
||||
django_user_model.objects.create(username='first')
|
||||
django_user_model.objects.create(username='second', is_staff=True)
|
||||
|
||||
segment = SegmentFactory.build(type=Segment.TYPE_STATIC)
|
||||
rule = TestStaticRule()
|
||||
rule = VisitCountRule(counted_page=site.root_page)
|
||||
form = form_with_data(segment, rule)
|
||||
mock_test_user = mocker.patch('wagtail_personalisation.rules.VisitCountRule.test_user', return_value=True)
|
||||
|
||||
assert form.count_matching_users([rule], True) is 1
|
||||
assert form.count_matching_users([rule], True) == 1
|
||||
assert mock_test_user.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_count_matching_users_excludes_inactive(site, client, django_user_model):
|
||||
class TestStaticRule(AbstractBaseRule):
|
||||
static = True
|
||||
|
||||
class Meta:
|
||||
app_label = 'wagtail_personalisation'
|
||||
|
||||
def test_user(self, request, user):
|
||||
return True
|
||||
|
||||
def test_count_matching_users_excludes_inactive(site, client, mocker, django_user_model):
|
||||
django_user_model.objects.create(username='first')
|
||||
django_user_model.objects.create(username='second', is_active=False)
|
||||
|
||||
segment = SegmentFactory.build(type=Segment.TYPE_STATIC)
|
||||
rule = TestStaticRule()
|
||||
rule = VisitCountRule(counted_page=site.root_page)
|
||||
form = form_with_data(segment, rule)
|
||||
mock_test_user = mocker.patch('wagtail_personalisation.rules.VisitCountRule.test_user', return_value=True)
|
||||
|
||||
assert form.count_matching_users([rule], True) is 1
|
||||
assert form.count_matching_users([rule], True) == 1
|
||||
assert mock_test_user.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_count_matching_users_only_counts_static_rules(site, client, django_user_model):
|
||||
class TestStaticRule(AbstractBaseRule):
|
||||
class Meta:
|
||||
app_label = 'wagtail_personalisation'
|
||||
|
||||
def test_user(self, request, user):
|
||||
return True
|
||||
|
||||
def test_count_matching_users_only_counts_static_rules(site, client, mocker, django_user_model):
|
||||
django_user_model.objects.create(username='first')
|
||||
django_user_model.objects.create(username='second')
|
||||
|
||||
segment = SegmentFactory.build(type=Segment.TYPE_STATIC)
|
||||
rule = TestStaticRule()
|
||||
rule = TimeRule(
|
||||
start_time=datetime.time(0, 0, 0),
|
||||
end_time=datetime.time(23, 59, 59),
|
||||
segment=segment,
|
||||
)
|
||||
form = form_with_data(segment, rule)
|
||||
mock_test_user = mocker.patch('wagtail_personalisation.rules.TimeRule.test_user')
|
||||
|
||||
assert form.count_matching_users([rule], True) is 0
|
||||
assert form.count_matching_users([rule], True) == 0
|
||||
assert mock_test_user.call_count == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_count_matching_users_handles_match_any(site, client, django_user_model):
|
||||
class TestStaticRuleFirst(AbstractBaseRule):
|
||||
static = True
|
||||
|
||||
class Meta:
|
||||
app_label = 'wagtail_personalisation'
|
||||
|
||||
def test_user(self, request, user):
|
||||
if user.username == 'first':
|
||||
return True
|
||||
return False
|
||||
|
||||
class TestStaticRuleSecond(AbstractBaseRule):
|
||||
static = True
|
||||
|
||||
class Meta:
|
||||
app_label = 'wagtail_personalisation'
|
||||
|
||||
def test_user(self, request, user):
|
||||
if user.username == 'second':
|
||||
return True
|
||||
return False
|
||||
|
||||
def test_count_matching_users_handles_match_any(site, client, mocker, django_user_model):
|
||||
django_user_model.objects.create(username='first')
|
||||
django_user_model.objects.create(username='second')
|
||||
|
||||
segment = SegmentFactory.build(type=Segment.TYPE_STATIC)
|
||||
first_rule = TestStaticRuleFirst()
|
||||
second_rule = TestStaticRuleSecond()
|
||||
first_rule = VisitCountRule(counted_page=site.root_page)
|
||||
other_page = site.root_page.get_last_child()
|
||||
second_rule = VisitCountRule(counted_page=other_page)
|
||||
form = form_with_data(segment, first_rule, second_rule)
|
||||
|
||||
assert form.count_matching_users([first_rule, second_rule], True) is 2
|
||||
mock_test_user = mocker.patch(
|
||||
'wagtail_personalisation.rules.VisitCountRule.test_user',
|
||||
side_effect=[True, False, True, False])
|
||||
|
||||
assert form.count_matching_users([first_rule, second_rule], True) == 2
|
||||
mock_test_user.call_count == 4
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_count_matching_users_handles_match_all(site, client, django_user_model):
|
||||
class TestStaticRuleFirst(AbstractBaseRule):
|
||||
static = True
|
||||
|
||||
class Meta:
|
||||
app_label = 'wagtail_personalisation'
|
||||
|
||||
def test_user(self, request, user):
|
||||
if user.username == 'first':
|
||||
return True
|
||||
return False
|
||||
|
||||
class TestStaticRuleContainsS(AbstractBaseRule):
|
||||
static = True
|
||||
|
||||
class Meta:
|
||||
app_label = 'wagtail_personalisation'
|
||||
|
||||
def test_user(self, request, user):
|
||||
if 's' in user.username:
|
||||
return True
|
||||
return False
|
||||
|
||||
def test_count_matching_users_handles_match_all(site, client, mocker, django_user_model):
|
||||
django_user_model.objects.create(username='first')
|
||||
django_user_model.objects.create(username='second')
|
||||
|
||||
segment = SegmentFactory.build(type=Segment.TYPE_STATIC)
|
||||
first_rule = TestStaticRuleFirst()
|
||||
s_rule = TestStaticRuleContainsS()
|
||||
form = form_with_data(segment, first_rule, s_rule)
|
||||
first_rule = VisitCountRule(counted_page=site.root_page)
|
||||
other_page = site.root_page.get_last_child()
|
||||
second_rule = VisitCountRule(counted_page=other_page)
|
||||
form = form_with_data(segment, first_rule, second_rule)
|
||||
|
||||
assert form.count_matching_users([first_rule, s_rule], False) is 1
|
||||
mock_test_user = mocker.patch(
|
||||
'wagtail_personalisation.rules.VisitCountRule.test_user',
|
||||
side_effect=[True, True, False, True])
|
||||
|
||||
assert form.count_matching_users([first_rule, second_rule], False) == 1
|
||||
mock_test_user.call_count == 4
|
||||
|
@ -1,59 +1,129 @@
|
||||
import pytest
|
||||
from django.test import override_settings
|
||||
from wagtail.core.models import Page as WagtailPage
|
||||
|
||||
from tests.factories.page import (ContentPageFactory, PersonalisablePageMetadataFactory)
|
||||
from wagtail_personalisation.utils import (
|
||||
exclude_variants, impersonate_other_page)
|
||||
can_delete_pages, exclude_variants, get_client_ip, impersonate_other_page)
|
||||
|
||||
|
||||
class Page(object):
|
||||
def __init__(self, path, depth, url_path, title):
|
||||
self.path = path
|
||||
self.depth = depth
|
||||
self.url_path = url_path
|
||||
self.title = title
|
||||
locale_factory = False
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.__dict__ == other.__dict__
|
||||
try:
|
||||
from tests.factories.page import LocaleFactory
|
||||
locale_factory = True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
def test_impersonate_other_page():
|
||||
page = Page(path="/", depth=0, url_path="/", title="Hoi")
|
||||
other_page = Page(path="/other", depth=1, url_path="/other", title="Doei")
|
||||
|
||||
impersonate_other_page(page, other_page)
|
||||
|
||||
assert page == other_page
|
||||
@pytest.fixture
|
||||
def rootpage():
|
||||
return ContentPageFactory(parent=None, path='/', depth=0, title='root')
|
||||
|
||||
|
||||
class Metadata(object):
|
||||
def __init__(self, is_canonical=True):
|
||||
self.is_canonical = is_canonical
|
||||
@pytest.fixture
|
||||
def page(rootpage):
|
||||
return ContentPageFactory(parent=rootpage, path='/hi', title='Hi')
|
||||
|
||||
|
||||
class PersonalisationMetadataPage(object):
|
||||
def __init__(self):
|
||||
self.personalisation_metadata = Metadata()
|
||||
@pytest.fixture
|
||||
def otherpage(rootpage):
|
||||
return ContentPageFactory(parent=rootpage, path='/bye', title='Bye')
|
||||
|
||||
|
||||
def test_exclude_variants_includes_pages_with_no_metadata_property():
|
||||
page = PersonalisationMetadataPage()
|
||||
del page.personalisation_metadata
|
||||
result = exclude_variants([page])
|
||||
assert result == [page]
|
||||
@pytest.mark.django_db
|
||||
def test_impersonate_other_page(page, otherpage):
|
||||
impersonate_other_page(page, otherpage)
|
||||
assert page.title == otherpage.title == 'Bye'
|
||||
assert page.path == otherpage.path
|
||||
|
||||
|
||||
def test_exclude_variants_includes_pages_with_metadata_none():
|
||||
page = PersonalisationMetadataPage()
|
||||
page.personalisation_metadata = None
|
||||
result = exclude_variants([page])
|
||||
assert result == [page]
|
||||
@pytest.mark.django_db
|
||||
def test_can_delete_pages_with_superuser(rf, user, segmented_page):
|
||||
user.is_superuser = True
|
||||
assert can_delete_pages([segmented_page], user)
|
||||
|
||||
|
||||
def test_exclude_variants_includes_pages_with_metadata_canonical():
|
||||
page = PersonalisationMetadataPage()
|
||||
result = exclude_variants([page])
|
||||
assert result == [page]
|
||||
@pytest.mark.django_db
|
||||
def test_cannot_delete_pages_with_standard_user(user, segmented_page):
|
||||
assert not can_delete_pages([segmented_page], user)
|
||||
|
||||
|
||||
def test_exclude_variants_excludes_pages_with_metadata_not_canonical():
|
||||
page = PersonalisationMetadataPage()
|
||||
page.personalisation_metadata.is_canonical = False
|
||||
result = exclude_variants([page])
|
||||
assert result == []
|
||||
def test_get_client_ip_with_remote_addr(rf):
|
||||
request = rf.get('/', REMOTE_ADDR='173.231.235.87')
|
||||
assert get_client_ip(request) == '173.231.235.87'
|
||||
|
||||
|
||||
def test_get_client_ip_with_x_forwarded_for(rf):
|
||||
request = rf.get('/', HTTP_X_FORWARDED_FOR='173.231.235.87',
|
||||
REMOTE_ADDR='10.0.23.24')
|
||||
assert get_client_ip(request) == '173.231.235.87'
|
||||
|
||||
|
||||
@override_settings(
|
||||
WAGTAIL_PERSONALISATION_IP_FUNCTION='some.non.existent.path'
|
||||
)
|
||||
def test_get_client_ip_custom_get_client_ip_function_does_not_exist(rf):
|
||||
with pytest.raises(ImportError):
|
||||
get_client_ip(rf.get('/'))
|
||||
|
||||
|
||||
@override_settings(
|
||||
WAGTAIL_PERSONALISATION_IP_FUNCTION='tests.utils.get_custom_ip'
|
||||
)
|
||||
def test_get_client_ip_custom_get_client_ip_used(rf):
|
||||
assert get_client_ip(rf.get('/')) == '123.123.123.123'
|
||||
|
||||
|
||||
def test_exclude_variants_with_pages_querysets():
|
||||
'''
|
||||
Test that excludes variant works for querysets
|
||||
'''
|
||||
for i in range(5):
|
||||
page = ContentPageFactory(path="/" + str(i), depth=0, url_path="/", title="Hoi " + str(i))
|
||||
page.save()
|
||||
pages = WagtailPage.objects.all().specific().order_by('id')
|
||||
|
||||
result = exclude_variants(pages)
|
||||
assert type(result) == type(pages)
|
||||
assert set(result.values_list('pk', flat=True)) == set(pages.values_list('pk', flat=True))
|
||||
|
||||
|
||||
def test_exclude_variants_with_pages_querysets_not_canonical():
|
||||
'''
|
||||
Test that excludes variant works for querysets with
|
||||
personalisation_metadata canonical False
|
||||
'''
|
||||
for i in range(5):
|
||||
page = ContentPageFactory(path="/" + str(i), depth=0, url_path="/", title="Hoi " + str(i))
|
||||
page.save()
|
||||
pages = WagtailPage.objects.all().specific().order_by('id')
|
||||
# add variants
|
||||
for page in pages:
|
||||
variant = ContentPageFactory(title='variant %d' % page.pk)
|
||||
page.personalisation_metadata = PersonalisablePageMetadataFactory(canonical_page=page, variant=variant)
|
||||
page.save()
|
||||
|
||||
pages = WagtailPage.objects.all().specific()
|
||||
result = exclude_variants(pages)
|
||||
assert type(result) == type(pages)
|
||||
assert result.count() < pages.count()
|
||||
|
||||
|
||||
def test_exclude_variants_with_pages_querysets_meta_none():
|
||||
'''
|
||||
Test that excludes variant works for querysets with meta as none
|
||||
'''
|
||||
for i in range(5):
|
||||
page = ContentPageFactory(path="/" + str(i), depth=0, url_path="/", title="Hoi " + str(i))
|
||||
page.save()
|
||||
pages = WagtailPage.objects.all().specific().order_by('id')
|
||||
# add variants
|
||||
for page in pages:
|
||||
page.personalisation_metadata = PersonalisablePageMetadataFactory(canonical_page=page, variant=page)
|
||||
page.save()
|
||||
|
||||
pages = WagtailPage.objects.all().specific()
|
||||
result = exclude_variants(pages)
|
||||
assert type(result) == type(pages)
|
||||
assert set(result.values_list('pk', flat=True)) == set(pages.values_list('pk', flat=True))
|
||||
|
110
tests/unit/test_views.py
Normal file
@ -0,0 +1,110 @@
|
||||
import pytest
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.urls import reverse
|
||||
from wagtail.core.models import Page
|
||||
|
||||
from wagtail_personalisation.models import Segment
|
||||
from wagtail_personalisation.rules import VisitCountRule
|
||||
from wagtail_personalisation.views import (
|
||||
SegmentModelAdmin, SegmentModelDeleteView)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_segment_user_data_view_requires_admin_access(site, client, django_user_model):
|
||||
user = django_user_model.objects.create(username='first')
|
||||
|
||||
segment = Segment(type=Segment.TYPE_STATIC, count=1)
|
||||
segment.save()
|
||||
|
||||
client.force_login(user)
|
||||
url = reverse('segment:segment_user_data', args=(segment.id,))
|
||||
response = client.get(url)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == '/admin/login/?next=%s' % url
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_segment_user_data_view(site, client, mocker, django_user_model):
|
||||
user1 = django_user_model.objects.create(username='first')
|
||||
user2 = django_user_model.objects.create(username='second')
|
||||
admin_user = django_user_model.objects.create(
|
||||
username='admin', is_superuser=True)
|
||||
|
||||
segment = Segment(type=Segment.TYPE_STATIC, count=1)
|
||||
segment.save()
|
||||
segment.static_users.add(user1)
|
||||
segment.static_users.add(user2)
|
||||
|
||||
rule1 = VisitCountRule(counted_page=site.root_page, segment=segment)
|
||||
rule2 = VisitCountRule(counted_page=site.root_page.get_last_child(),
|
||||
segment=segment)
|
||||
rule1.save()
|
||||
rule2.save()
|
||||
|
||||
mocker.patch('wagtail_personalisation.rules.VisitCountRule.get_user_info_string',
|
||||
side_effect=[3, 9, 0, 1])
|
||||
|
||||
client.force_login(admin_user)
|
||||
response = client.get(
|
||||
reverse('segment:segment_user_data', args=(segment.id,)))
|
||||
|
||||
assert response.status_code == 200
|
||||
data_lines = response.content.decode().split("\n")
|
||||
|
||||
assert data_lines[0] == 'Username,Visit count - Test page,Visit count - Regular page\r'
|
||||
assert data_lines[1] == 'first,3,9\r'
|
||||
assert data_lines[2] == 'second,0,1\r'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_segment_delete_view_delete_instance(rf, segmented_page, user):
|
||||
user.is_superuser = True
|
||||
user.save()
|
||||
segment = segmented_page.personalisation_metadata.segment
|
||||
canonical_page = segmented_page.personalisation_metadata.canonical_page
|
||||
variants_metadata = segment.get_used_pages()
|
||||
page_variants = Page.objects.filter(pk__in=(
|
||||
variants_metadata.values_list('variant_id', flat=True)
|
||||
))
|
||||
|
||||
# Make sure all canonical page, variants and variants metadata exist
|
||||
assert canonical_page
|
||||
assert page_variants
|
||||
assert variants_metadata
|
||||
|
||||
# Delete the segment via the method on the view.
|
||||
request = rf.get('/'.format(segment.pk))
|
||||
request.user = user
|
||||
view = SegmentModelDeleteView(
|
||||
instance_pk=str(segment.pk),
|
||||
model_admin=SegmentModelAdmin()
|
||||
)
|
||||
view.request = request
|
||||
view.delete_instance()
|
||||
|
||||
# Segment has been deleted.
|
||||
with pytest.raises(segment.DoesNotExist):
|
||||
segment.refresh_from_db()
|
||||
|
||||
# Canonical page stayed intact.
|
||||
canonical_page.refresh_from_db()
|
||||
|
||||
# Variant pages and their metadata have been deleted.
|
||||
assert not page_variants.all()
|
||||
assert not variants_metadata.all()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_segment_delete_view_raises_permission_denied(rf, segmented_page, user):
|
||||
segment = segmented_page.personalisation_metadata.segment
|
||||
request = rf.get('/'.format(segment.pk))
|
||||
request.user = user
|
||||
view = SegmentModelDeleteView(
|
||||
instance_pk=str(segment.pk),
|
||||
model_admin=SegmentModelAdmin()
|
||||
)
|
||||
view.request = request
|
||||
message = 'User have no permission to delete variant page objects.'
|
||||
with pytest.raises(PermissionDenied):
|
||||
view.delete_instance()
|
@ -1,5 +1,8 @@
|
||||
import pytest
|
||||
from django.http import Http404
|
||||
from wagtail.core.models import Page
|
||||
|
||||
from tests.factories.page import ContentPageFactory
|
||||
from tests.factories.segment import SegmentFactory
|
||||
from wagtail_personalisation import adapters, wagtail_hooks
|
||||
|
||||
@ -15,6 +18,15 @@ def test_serve_variant_no_variant(site, rf):
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_variant_accessed_directly_returns_404(segmented_page, rf):
|
||||
request = rf.get('/')
|
||||
args = tuple()
|
||||
kwargs = {}
|
||||
with pytest.raises(Http404):
|
||||
wagtail_hooks.serve_variant(segmented_page, request, args, kwargs)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_serve_variant_with_variant_no_segment(site, rf, segmented_page):
|
||||
request = rf.get('/')
|
||||
@ -60,3 +72,72 @@ def test_page_listing_more_buttons(site, rf, segmented_page):
|
||||
result = wagtail_hooks.page_listing_more_buttons(page, [])
|
||||
items = list(result)
|
||||
assert len(items) == 3
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_custom_delete_page_view_does_not_trigger_for_variants(
|
||||
rf,
|
||||
segmented_page
|
||||
):
|
||||
assert (
|
||||
wagtail_hooks.delete_related_variants(rf.get('/'), segmented_page)
|
||||
) is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_custom_delete_page_view_triggers_for_canonical_pages(
|
||||
rf,
|
||||
segmented_page
|
||||
):
|
||||
assert (
|
||||
wagtail_hooks.delete_related_variants(
|
||||
rf.get('/'),
|
||||
segmented_page.personalisation_metadata.canonical_page
|
||||
)
|
||||
) is not None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_custom_delete_page_view_deletes_variants(rf, segmented_page, user):
|
||||
post_request = rf.post('/')
|
||||
user.is_superuser = True
|
||||
rf.user = user
|
||||
canonical_page = segmented_page.personalisation_metadata.canonical_page
|
||||
canonical_page_variant = canonical_page.personalisation_metadata
|
||||
assert canonical_page_variant
|
||||
|
||||
variants = Page.objects.filter(pk__in=(
|
||||
canonical_page.personalisation_metadata.variants_metadata.values_list('variant_id', flat=True)
|
||||
))
|
||||
variants_metadata = canonical_page.personalisation_metadata.variants_metadata
|
||||
# Make sure there are variants that exist in the database.
|
||||
assert len(variants.all())
|
||||
assert len(variants_metadata.all())
|
||||
wagtail_hooks.delete_related_variants(
|
||||
post_request, segmented_page.personalisation_metadata.canonical_page
|
||||
)
|
||||
with pytest.raises(canonical_page.DoesNotExist):
|
||||
canonical_page.refresh_from_db()
|
||||
with pytest.raises(canonical_page_variant.DoesNotExist):
|
||||
canonical_page_variant.refresh_from_db()
|
||||
# Make sure all the variant pages have been deleted.
|
||||
assert not len(variants.all())
|
||||
assert not len(variants_metadata.all())
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_custom_delete_page_view_deletes_variants_of_child_pages(rf, segmented_page, user):
|
||||
"""
|
||||
Regression test for deleting pages that have children with variants
|
||||
"""
|
||||
post_request = rf.post('/')
|
||||
user.is_superuser = True
|
||||
rf.user = user
|
||||
canonical_page = segmented_page.personalisation_metadata.canonical_page
|
||||
# Create a child with a variant
|
||||
child_page = ContentPageFactory(parent=canonical_page, slug='personalised-child')
|
||||
child_page.personalisation_metadata.copy_for_segment(segmented_page.personalisation_metadata.segment)
|
||||
# A ProtectedError would be raised if the bug persists
|
||||
wagtail_hooks.delete_related_variants(
|
||||
post_request, canonical_page
|
||||
)
|
||||
|
@ -5,3 +5,7 @@ def render_template(value, **context):
|
||||
template = engines['django'].from_string(value)
|
||||
request = context.pop('request', None)
|
||||
return template.render(context, request)
|
||||
|
||||
|
||||
def get_custom_ip(request):
|
||||
return '123.123.123.123'
|
||||
|
43
tox.ini
@ -1,26 +1,51 @@
|
||||
[tox]
|
||||
envlist = py{27}-django{111}-wagtail{113},lint
|
||||
envlist =
|
||||
flake8
|
||||
py{36,37,38}-dj{22}-wt{211,212,213}
|
||||
py{37,38}-dj{30,31}-wt{211,212,213}
|
||||
|
||||
[gh-actions]
|
||||
python =
|
||||
3.6: py36
|
||||
3.7: py37
|
||||
3.8: py38
|
||||
|
||||
[testenv]
|
||||
commands = coverage run --parallel -m pytest {posargs}
|
||||
basepython =
|
||||
py36: python3.6
|
||||
py37: python3.7
|
||||
py38: python3.8
|
||||
commands = coverage run --parallel -m pytest -rs {posargs}
|
||||
extras = test
|
||||
deps =
|
||||
django111: django>=1.11,<1.12
|
||||
wagtail19: wagtail>=1.13,<1.14
|
||||
dj22: Django>=2.2.8,<2.3
|
||||
dj30: Django>=3.0,<3.1
|
||||
dj31: Django>=3.1,<3.2
|
||||
wt211: wagtail>=2.11,<2.12
|
||||
wt212: wagtail>=2.12,<2.13
|
||||
wt213: wagtail>=2.13,<2.14
|
||||
geoip2: geoip2
|
||||
|
||||
[testenv:coverage-report]
|
||||
basepython = python2.7
|
||||
basepython = python3.6
|
||||
deps = coverage
|
||||
pip_pre = true
|
||||
skip_install = true
|
||||
commands =
|
||||
coverage combine
|
||||
coverage report
|
||||
|
||||
coverage report --include="src/**/" --omit="src/**/migrations/*.py"
|
||||
|
||||
[testenv:lint]
|
||||
basepython = python2.7
|
||||
basepython = python3.6
|
||||
deps = flake8==3.5.0
|
||||
commands =
|
||||
flake8 src tests setup.py
|
||||
isort -q --recursive --diff src/ tests/
|
||||
|
||||
[testenv:format]
|
||||
basepython = python3.8
|
||||
deps =
|
||||
isort
|
||||
black
|
||||
skip_install = true
|
||||
commands =
|
||||
black --check setup.py src/wagtail_personalisation/ tests/
|
||||
|