Compare commits
300 Commits
feature/74
...
feature/se
Author | SHA1 | Date | |
---|---|---|---|
acd273c06c | |||
4b3af020fd | |||
05afea8d68 | |||
c31415b484 | |||
4a596d62f2 | |||
3c1c0c3306 | |||
937c06cf32 | |||
d7fac2607b | |||
be672f6fde | |||
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 | |||
869237360d | |||
33277a0b20 | |||
2cd643fb2d | |||
0f18024ebc | |||
521222f748 | |||
56a8e106d8 | |||
3162191a16 | |||
8c7e99313b | |||
824e42174f | |||
d114bb2570 | |||
7bba1e57cc | |||
3017f32b6b | |||
6b1a7cf1f2 | |||
1525b7946c | |||
7bf1bc3f19 | |||
4c60bcbe6b | |||
ad4f75d471 | |||
086168954d | |||
881090f2f9 | |||
d073c7d268 | |||
7200b5b4c4 | |||
6f97c76958 | |||
ecb4f928fb | |||
29aa91477e | |||
5c3acc6661 | |||
602919d2d4 | |||
ae97118c3f | |||
51774b939e | |||
908f85e295 | |||
99f9700ed0 | |||
7fa8ee1a46 | |||
5ad70d68f6 | |||
06bfe77901 | |||
d5e89d374b | |||
5b39e82f80 | |||
fbcebb43a4 | |||
ef271587ec | |||
786a8801b1 | |||
caf73aa43c | |||
4021d2c915 | |||
33f96af4a3 | |||
6299feb497 | |||
7ced6db126 | |||
c6ce67c9c9 | |||
3df3fc0b16 | |||
a00929846e | |||
49fba11049 | |||
e3488e87ad | |||
808aa6d202 | |||
efb060cc6e | |||
414afa5269 | |||
b3f0ac2d58 | |||
4f9c18d2cf | |||
a4a283e4f3 | |||
30318549e2 | |||
f19de241b0 | |||
95ecd8d200 | |||
6436b85b1d | |||
06471248d3 | |||
e3df03f559 | |||
0a42ce3eeb | |||
e5068894c3 | |||
fdc2b97194 | |||
a8d3aeab68 | |||
c76d6d1617 | |||
a8c4b66d6e | |||
f3fbee99a2 | |||
4918c99b5f | |||
330c3bd377 | |||
9c9a9d3acd | |||
51e9aa9724 | |||
a5705fd53c | |||
9d1f3074c0 | |||
3bfd5b8e8f | |||
232609fb4e | |||
35fd4836b0 | |||
b786b0a4d2 | |||
23b1456438 | |||
1f4a4536ab | |||
b8bf27fb99 | |||
d07e06b4f0 | |||
71d7faba1f | |||
743d3f668e | |||
bc0b69fde5 | |||
7cf22d05f6 | |||
9e0fc8e6fd | |||
a116b14d57 | |||
44cc95617e | |||
c6ff2801c5 | |||
0d2834a55f | |||
ff236a095d | |||
ef20580334 | |||
cf41be4b76 | |||
f339879907 | |||
aa2a239aec | |||
8c96fffd4e | |||
675d219f1f | |||
0d9e4aab0c | |||
ac9f32c570 | |||
bc91d64770 | |||
821ee5863e | |||
1a2777835c | |||
d160bb5217 | |||
b021164309 | |||
6f6d6e3a06 | |||
fb7ed4936d | |||
80e33a467e | |||
9c88ec1582 | |||
785d1486e4 | |||
b553295fc2 | |||
0da7f111e3 | |||
fe6a26e1fd | |||
94c947a435 | |||
ef08403ba3 | |||
48935218db | |||
4c315f067a | |||
6e56d8cf4d | |||
281086a159 | |||
2e74741033 | |||
537dfb12a6 | |||
1e885adf83 | |||
12853c61e1 | |||
d4ee67b778 | |||
c6e4d9cca8 | |||
38aff16044 | |||
aafc8c4ed5 | |||
f21c423b1c | |||
7e24485aaf | |||
12ae3fa173 | |||
961a58505a | |||
cb03a36ba2 | |||
9605773a74 | |||
46d86d852d | |||
0776d2300a | |||
38620d916f | |||
3ee0645267 | |||
eda00d624e | |||
0e24ae17ac | |||
39c31dc81a | |||
702fa233a9 | |||
7405c34252 | |||
6f96f2f172 | |||
559d3c5356 | |||
5aa754dd80 | |||
282baa4787 | |||
35c22cb6af | |||
6c5ab9c6ae | |||
d92fe13d37 | |||
dfb364b7fc | |||
15a0276041 | |||
c0c3ce19fe | |||
e0fffb70b7 | |||
7f2882ce0e | |||
a629bfc862 | |||
e3ceecfa7d | |||
0f79cf1d15 | |||
29001fac8e | |||
dda0bc720e | |||
5beef1b27c | |||
8465e6dcbb | |||
cf8101156c | |||
7076973fc8 | |||
c2735807b4 | |||
2651eb0e3c | |||
18838b2e8c | |||
763a67e2d4 | |||
d35a7fee57 | |||
c100dc603c | |||
d4421eebcb | |||
fea3bc8b8e | |||
38a18f80a4 | |||
85613db363 | |||
5fbfb82480 | |||
f88722c827 | |||
46ad32236c | |||
e6fac5f7fb | |||
82f2dd460e | |||
4f2dc3a304 | |||
8905f471ee | |||
6587d0fd56 | |||
4e221b6666 | |||
99d2e4a347 | |||
18eea8a9b1 | |||
a4cf8120b4 | |||
09fbb5d795 | |||
d79765efee | |||
0aa07261ad |
2
.gitignore
vendored
@ -23,3 +23,5 @@ tests/sandbox/assets
|
||||
node_modules
|
||||
|
||||
.DS_Store
|
||||
|
||||
.pytest_cache/
|
||||
|
14
.travis.yml
@ -4,16 +4,12 @@ language: python
|
||||
|
||||
matrix:
|
||||
include:
|
||||
- python: 2.7
|
||||
env: TOXENV=py27-django111-wagtail110
|
||||
- python: 3.5
|
||||
env: TOXENV=py35-django111-wagtail110
|
||||
- python: 3.6
|
||||
env: TOXENV=py36-django111-wagtail110
|
||||
|
||||
allow_failures:
|
||||
- python: 3.5
|
||||
env: TOXENV=lint
|
||||
env: lint
|
||||
- python: 3.6
|
||||
env: TOXENV=py36-django20-wagtail20
|
||||
- python: 3.6
|
||||
env: TOXENV=py36-django20-wagtail21
|
||||
|
||||
install:
|
||||
- pip install tox codecov
|
||||
|
9
.tx/config
Normal file
@ -0,0 +1,9 @@
|
||||
[main]
|
||||
host = https://www.transifex.com
|
||||
|
||||
[wagtail_personalisation]
|
||||
file_filter = src/wagtail_personalisation/locale/<lang>/LC_MESSAGES/django.po
|
||||
source_file = src/wagtail_personalisation/locale/en/LC_MESSAGES/django.po
|
||||
source_lang = en
|
||||
type = PO
|
||||
|
66
CHANGES
@ -1,3 +1,63 @@
|
||||
0.1 (TBD)
|
||||
====================
|
||||
- Initial release
|
||||
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
|
||||
- Records users excluded by randomisation on the segment
|
||||
- Don't re-check excluded users
|
||||
|
||||
0.10.6
|
||||
==================
|
||||
- Accepts and stores randomisation percentage for segment
|
||||
- Adds users to segment based on random number relative to percentage
|
||||
|
||||
0.10.5
|
||||
==================
|
||||
- Count how many users match a segments rules before saving the segment
|
||||
- Stores count on the segment and displays in the dashboard
|
||||
- Enables testing users against rules if there isn't an active request
|
||||
|
||||
0.10.0
|
||||
==================
|
||||
- Adds static and dynamic segments
|
||||
|
||||
0.9.1 (tbd)
|
||||
==================
|
||||
|
||||
- Fixes import for reverse resolver for older Django versions (<1.10)
|
||||
- Bases migrations off of older wagtail dependencies
|
||||
- Adds more dashboard panels and fixes exclude variants function
|
||||
|
||||
|
||||
0.9.0 (2017-06-02)
|
||||
==================
|
||||
|
||||
Initial release of wagtail-personalisation. This Wagtail module provides basic
|
||||
personalisation based on pre-defined rules in the backend.
|
||||
|
||||
This module was developed by Boris Besemer (@blurrah) and Jasper Berghoef
|
||||
(@jberghoef) for Lab Digital (http://labdigital.nl)
|
||||
|
@ -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
|
||||
|
@ -1,3 +1,6 @@
|
||||
include README.rst
|
||||
|
||||
recursive-include src
|
||||
recursive-include src *
|
||||
|
||||
recursive-exclude src __pycache__
|
||||
recursive-exclude src *.py[co]
|
||||
|
16
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,10 +38,18 @@ 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
|
||||
sandbox/manage.py migrate
|
||||
sandbox/manage.py loaddata sandbox/exampledata/users.json
|
||||
sandbox/manage.py loaddata sandbox/exampledata/personalisation.json
|
||||
sandbox/manage.py runserver
|
||||
|
||||
release:
|
||||
pip install twine wheel
|
||||
rm -rf dist/*
|
||||
python setup.py sdist bdist_wheel
|
||||
twine upload -s dist/*
|
||||
|
61
README.rst
@ -1,45 +1,52 @@
|
||||
.. start-no-pypi
|
||||
|
||||
.. image:: https://readthedocs.org/projects/wagtail-personalisation/badge/?version=latest
|
||||
:target: https://readthedocs.org/projects/wagtail-personalisation/
|
||||
: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
|
||||
=======================
|
||||
|
||||
Wagtail Personalisation is a fully-featured personalisation module for `Wagtail CMS`_. It enables editors to create customised pages - or parts of pages - based on segments whose rules are configured directly in the admin interface.
|
||||
Wagtail Personalisation is a fully-featured personalisation module for
|
||||
`Wagtail CMS`_. It enables editors to create customised pages
|
||||
- or parts of pages - based on segments whose rules are configured directly
|
||||
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.10 and Django 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
|
||||
|
||||
Next, include the ``wagtail_personalisation`` and
|
||||
``wagtail.contrib.modeladmin`` app in your project's ``INSTALLED_APPS``:
|
||||
Next, include the ``wagtail_personalisation``, ``wagtail.contrib.modeladmin``
|
||||
and ``wagtailfontawesome`` apps in your project's ``INSTALLED_APPS``:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
@ -47,18 +54,36 @@ Next, include the ``wagtail_personalisation`` and
|
||||
# ...
|
||||
'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',
|
||||
# ...
|
||||
]
|
||||
|
||||
|
||||
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
|
||||
-------
|
||||
|
||||
To experiment with the package you can use the sandbox provided in
|
||||
this repository. 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. The
|
||||
superuser credentials are ``superuser@example.com`` with the password
|
||||
``testing``.
|
||||
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``.
|
||||
|
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 |
34
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 = '2018, Lab Digital BV'
|
||||
author = 'Lab Digital BV'
|
||||
|
||||
# The version info for the project you're documenting, acts as replacement for
|
||||
@ -55,16 +62,17 @@ author = 'Lab Digital BV'
|
||||
# built documents.
|
||||
#
|
||||
# The short X.Y version.
|
||||
version = ''
|
||||
version = '0.12.0'
|
||||
|
||||
# The full version, including alpha/beta/rc tags.
|
||||
release = ''
|
||||
release = '0.12.0'
|
||||
|
||||
# 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.
|
||||
@ -83,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
|
||||
@ -91,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".
|
||||
@ -160,6 +165,3 @@ texinfo_documents = [
|
||||
author, 'wagtail-personalisation', 'One line description of project.',
|
||||
'Miscellaneous'),
|
||||
]
|
||||
|
||||
|
||||
|
||||
|
@ -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
|
||||
----------------------
|
||||
|
||||
|
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
|
Before Width: | Height: | Size: 794 B |
Before Width: | Height: | Size: 2.3 KiB |
Before Width: | Height: | Size: 1.6 KiB |
Before Width: | Height: | Size: 1.3 KiB |
Before Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 2.1 KiB |
Before Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 1.4 KiB |
@ -86,11 +86,16 @@
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.block_container .block .inspect_container .inspect li {
|
||||
.stat_card {
|
||||
display: inline-block;
|
||||
margin-bottom: 5px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.block_container .block span.icon::before {
|
||||
margin-right: 0.3em;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.block_container .block .inspect_container .inspect li span {
|
||||
@ -101,42 +106,6 @@
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.block_container .block .inspect_container .inspect li span::before {
|
||||
display: inline-block;
|
||||
content: "";
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 5px;
|
||||
background-size: contain;
|
||||
background-image: url("../img/ruler_icon.png");
|
||||
}
|
||||
|
||||
.block_container .block .inspect_container .segment_stats .visit_stat span::before {
|
||||
background-image: url("../img/rocket_icon.png");
|
||||
}
|
||||
.block_container .block .inspect_container .segment_stats .days_stat span::before {
|
||||
background-image: url("../img/calendar_icon.png");
|
||||
}
|
||||
|
||||
.block_container .block .inspect_container .segment_rules .persistent_state span::before {
|
||||
background-image: url("../img/persistent_icon.png");
|
||||
}
|
||||
.block_container .block .inspect_container .segment_rules .persistent_state.fleeting span::before {
|
||||
transform: rotate(45deg) translateY(-2px);
|
||||
}
|
||||
.block_container .block .inspect_container .segment_rules .time-rule span::before {
|
||||
background-image: url("../img/time_icon.png");
|
||||
}
|
||||
.block_container .block .inspect_container .segment_rules .visit-count-rule span::before {
|
||||
background-image: url("../img/visit_count_icon.png");
|
||||
}
|
||||
.block_container .block .inspect_container .segment_rules .logged-in-rule span::before {
|
||||
background-image: url("../img/key_icon.png");
|
||||
}
|
||||
.block_container .block .inspect_container .segment_rules .day-rule span::before {
|
||||
background-image: url("../img/calendar_icon.png");
|
||||
}
|
||||
|
||||
.block_container .block .inspect_container .inspect li pre {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
@ -150,26 +119,6 @@
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.block_container .block .inspect_container .inspect li pre::before {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
content: "";
|
||||
left: -21px;
|
||||
top: 6px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 5px;
|
||||
background-size: contain;
|
||||
background-image: url("../img/ruler_icon.png");
|
||||
}
|
||||
|
||||
.block_container .block .inspect_container .segment_rules .referral-rule pre::before {
|
||||
background-image: url("../img/referral_icon.png");
|
||||
}
|
||||
.block_container .block .inspect_container .segment_rules .query-rule pre::before {
|
||||
background-image: url("../img/referral_icon.png");
|
||||
}
|
||||
|
||||
.block_container .block.suggestion .suggestive_text {
|
||||
display: block;
|
||||
position: absolute;
|
||||
|
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 |
22
manage.py
@ -1,22 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
import os
|
||||
import sys
|
||||
|
||||
if __name__ == "__main__":
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.sandbox.settings")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError:
|
||||
# The above import may fail for some other reason. Ensure that the
|
||||
# issue is really that Django is missing to avoid masking other
|
||||
# exceptions on Python 2.
|
||||
try:
|
||||
import django
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
)
|
||||
raise
|
||||
execute_from_command_line(sys.argv)
|
@ -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"
|
||||
}
|
||||
|
148
sandbox/exampledata/personalisation.json
Normal file
@ -0,0 +1,148 @@
|
||||
[{
|
||||
"model": "wagtail_personalisation.timerule",
|
||||
"pk": 1,
|
||||
"fields": {
|
||||
"segment": 2,
|
||||
"start_time": "06:00:00",
|
||||
"end_time": "11:00:00"
|
||||
}
|
||||
}, {
|
||||
"model": "wagtail_personalisation.visitcountrule",
|
||||
"pk": 1,
|
||||
"fields": {
|
||||
"segment": 1,
|
||||
"operator": "more_than",
|
||||
"count": 3,
|
||||
"counted_page": 3
|
||||
}
|
||||
}, {
|
||||
"model": "wagtail_personalisation.segment",
|
||||
"pk": 1,
|
||||
"fields": {
|
||||
"name": "Returning Rook",
|
||||
"create_date": "2017-06-02T05:38:02.304Z",
|
||||
"edit_date": "2017-06-02T10:58:39.399Z",
|
||||
"enable_date": "2017-06-02T10:58:39.389Z",
|
||||
"disable_date": "2017-06-02T10:34:51.722Z",
|
||||
"visit_count": 0,
|
||||
"enabled": true,
|
||||
"persistent": false,
|
||||
"match_any": false
|
||||
}
|
||||
}, {
|
||||
"model": "wagtail_personalisation.segment",
|
||||
"pk": 2,
|
||||
"fields": {
|
||||
"name": "Early Birds",
|
||||
"create_date": "2017-06-02T05:38:14.749Z",
|
||||
"edit_date": "2017-06-02T10:57:44.504Z",
|
||||
"enable_date": "2017-06-02T10:57:44.497Z",
|
||||
"disable_date": "2017-06-02T10:57:39.984Z",
|
||||
"visit_count": 1,
|
||||
"enabled": true,
|
||||
"persistent": false,
|
||||
"match_any": false
|
||||
}
|
||||
}, {
|
||||
"model": "wagtail_personalisation.personalisablepagemetadata",
|
||||
"pk": 1,
|
||||
"fields": {
|
||||
"canonical_page": 3,
|
||||
"variant": 3,
|
||||
"segment": null
|
||||
}
|
||||
}, {
|
||||
"model": "wagtail_personalisation.personalisablepagemetadata",
|
||||
"pk": 2,
|
||||
"fields": {
|
||||
"canonical_page": 3,
|
||||
"variant": 4,
|
||||
"segment": 1
|
||||
}
|
||||
}, {
|
||||
"model": "home.homepage",
|
||||
"pk": 3,
|
||||
"fields": {
|
||||
"intro": "<p>Thank you for trying <a href=\"http://wagxperience.io\">Wagxperience</a>!</p>",
|
||||
"body": "[{\"type\": \"personalisable_paragraph\", \"value\": {\"segment\": \"2\", \"paragraph\": \"<p>You are an early bird!</p>\"}}]"
|
||||
}
|
||||
}, {
|
||||
"model": "home.homepage",
|
||||
"pk": 4,
|
||||
"fields": {
|
||||
"intro": "<p>Thank you for trying <a href=\"http://wagxperience.io\">Wagxperience</a>!</p><p>You've visited the homepage more than 3 times!</p>",
|
||||
"body": "[]"
|
||||
}
|
||||
}, {
|
||||
"model": "wagtailcore.page",
|
||||
"pk": 1,
|
||||
"fields": {
|
||||
"path": "0001",
|
||||
"depth": 1,
|
||||
"numchild": 1,
|
||||
"title": "Root",
|
||||
"slug": "root",
|
||||
"content_type": 1,
|
||||
"live": true,
|
||||
"has_unpublished_changes": false,
|
||||
"url_path": "/",
|
||||
"owner": null,
|
||||
"seo_title": "",
|
||||
"show_in_menus": false,
|
||||
"search_description": "",
|
||||
"go_live_at": null,
|
||||
"expire_at": null,
|
||||
"expired": false,
|
||||
"locked": false,
|
||||
"first_published_at": null,
|
||||
"latest_revision_created_at": null
|
||||
}
|
||||
}, {
|
||||
"model": "wagtailcore.page",
|
||||
"pk": 3,
|
||||
"fields": {
|
||||
"path": "00010001",
|
||||
"depth": 2,
|
||||
"numchild": 0,
|
||||
"title": "Home",
|
||||
"slug": "home",
|
||||
"content_type": 2,
|
||||
"live": true,
|
||||
"has_unpublished_changes": false,
|
||||
"url_path": "/home/",
|
||||
"owner": null,
|
||||
"seo_title": "",
|
||||
"show_in_menus": false,
|
||||
"search_description": "",
|
||||
"go_live_at": null,
|
||||
"expire_at": null,
|
||||
"expired": false,
|
||||
"locked": false,
|
||||
"first_published_at": "2017-06-02T10:35:34.706Z",
|
||||
"latest_revision_created_at": "2017-06-02T10:35:34.565Z"
|
||||
}
|
||||
}, {
|
||||
"model": "wagtailcore.page",
|
||||
"pk": 4,
|
||||
"fields": {
|
||||
"path": "00010002",
|
||||
"depth": 2,
|
||||
"numchild": 0,
|
||||
"title": "Home (Returning Rook)",
|
||||
"slug": "home-returning-rook",
|
||||
"content_type": 2,
|
||||
"live": true,
|
||||
"has_unpublished_changes": false,
|
||||
"url_path": "/home-returning-rook/",
|
||||
"owner": null,
|
||||
"seo_title": "",
|
||||
"show_in_menus": false,
|
||||
"search_description": "",
|
||||
"go_live_at": null,
|
||||
"expire_at": null,
|
||||
"expired": false,
|
||||
"locked": false,
|
||||
"first_published_at": "2017-06-02T05:38:53.568Z",
|
||||
"latest_revision_created_at": "2017-06-02T05:38:53.390Z"
|
||||
}
|
||||
}]
|
@ -1,4 +1,4 @@
|
||||
Django>=1.11,<1.12
|
||||
wagtail>=1.10,<1.11
|
||||
django-debug-toolbar==1.8
|
||||
Django>=2.0,<2.1
|
||||
wagtail>=2.1,<2.2
|
||||
django-debug-toolbar==1.9.1
|
||||
-e .[docs,test]
|
||||
|
@ -1,9 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.1 on 2017-05-31 10:20
|
||||
# Generated by Django 1.11.1 on 2017-05-31 16:59
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import modelcluster.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
@ -11,7 +12,8 @@ class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('wagtail_personalisation', '0009_auto_20170531_0428'),
|
||||
('wagtailcore', '0033_remove_golive_expiry_help_text'),
|
||||
('wagtail_personalisation', '0011_personalisablepagemetadata'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@ -19,9 +21,6 @@ class Migration(migrations.Migration):
|
||||
name='HomePage',
|
||||
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')),
|
||||
('is_segmented', models.BooleanField(default=False)),
|
||||
('canonical_page', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='variations', to='home.HomePage')),
|
||||
('segment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='segments', to='wagtail_personalisation.Segment')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
|
@ -0,0 +1,30 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.1 on 2017-05-31 19:36
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations
|
||||
import wagtail.core.fields
|
||||
import wagtail_personalisation
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('home', '0002_create_homepage'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='homepage',
|
||||
name='intro',
|
||||
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.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,23 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
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
|
||||
|
||||
|
||||
class HomePage(PersonalisablePageMixin, Page):
|
||||
pass
|
||||
intro = RichTextField()
|
||||
body = StreamField([
|
||||
('personalisable_paragraph', PersonalisedStructBlock([
|
||||
('paragraph', blocks.RichTextBlock()),
|
||||
], icon='pilcrow'))
|
||||
])
|
||||
|
||||
content_panels = Page.content_panels + [
|
||||
RichTextFieldPanel('intro'),
|
||||
StreamFieldPanel('body'),
|
||||
]
|
||||
|
@ -1,11 +1,18 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% load wagtailcore_tags %}
|
||||
|
||||
{% block body_class %}template-homepage{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Welcome to your new Wagtail site!</h1>
|
||||
<h1>Wagtail Personalisation</h1>
|
||||
<hr>
|
||||
<h2>{{ self.title }}</h2>
|
||||
|
||||
<p>You can access the admin interface <a href="{% url 'wagtailadmin_home' %}">here</a> (make sure you have run "./manage.py createsuperuser" in the console first).</p>
|
||||
{{ self.intro|richtext }}
|
||||
|
||||
{% for block in page.body %}
|
||||
<div>{% include_block block %}</div>
|
||||
{% endfor %}
|
||||
|
||||
<p>If you haven't already given the documentation a read, head over to <a href="http://docs.wagtail.io/">http://docs.wagtail.io</a> to start building on Wagtail</p>
|
||||
{% endblock %}
|
||||
|
@ -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):
|
||||
|
@ -29,34 +29,37 @@ 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',
|
||||
'wagtail.contrib.modeladmin',
|
||||
|
||||
'modelcluster',
|
||||
'taggit',
|
||||
'debug_toolbar',
|
||||
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'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',
|
||||
'modelcluster',
|
||||
'taggit',
|
||||
'debug_toolbar',
|
||||
|
||||
'wagtail_personalisation',
|
||||
|
||||
'sandbox.apps.home',
|
||||
@ -67,17 +70,17 @@ 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.core.middleware.SiteMiddleware',
|
||||
'wagtail.contrib.redirects.middleware.RedirectMiddleware',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'sandbox.urls'
|
||||
|
@ -4,14 +4,14 @@ 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'^admin/', admin.site.urls),
|
||||
|
||||
url(r'^cms/', include(wagtailadmin_urls)),
|
||||
url(r'^documents/', include(wagtaildocs_urls)),
|
||||
|
28
setup.cfg
@ -1,6 +1,16 @@
|
||||
[bumpversion]
|
||||
current_version = 0.12.0
|
||||
commit = true
|
||||
tag = true
|
||||
tag_name = {new_version}
|
||||
|
||||
[tool:pytest]
|
||||
DJANGO_SETTINGS_MODULE = tests.sandbox.settings
|
||||
norecursedirs = .tox .git
|
||||
DJANGO_SETTINGS_MODULE = tests.settings
|
||||
minversion = 3.0
|
||||
strict = true
|
||||
django_find_project = false
|
||||
testpaths = tests
|
||||
python_paths = .
|
||||
|
||||
[flake8]
|
||||
ignore = E731
|
||||
@ -8,6 +18,14 @@ max-line-length = 120
|
||||
exclude =
|
||||
src/**/migrations/*.py
|
||||
|
||||
[coverage:run]
|
||||
omit =
|
||||
src/**/migrations/*.py
|
||||
[wheel]
|
||||
universal = 1
|
||||
|
||||
[coverage]
|
||||
include = src/**/
|
||||
omit = src/**/migrations/*.py
|
||||
|
||||
[bumpversion:file:setup.py]
|
||||
|
||||
[bumpversion:file:docs/conf.py]
|
||||
|
||||
|
45
setup.py
@ -1,36 +1,44 @@
|
||||
import re
|
||||
from setuptools import find_packages, setup
|
||||
|
||||
|
||||
install_requires = [
|
||||
'wagtail>=1.10,<1.11',
|
||||
'user-agents>=1.0.1',
|
||||
'wagtail>=2.0,<2.2',
|
||||
'user-agents>=1.1.0',
|
||||
'wagtailfontawesome>=1.1.3',
|
||||
]
|
||||
|
||||
tests_require = [
|
||||
'factory_boy==2.8.1',
|
||||
'flake8',
|
||||
'flake8-blind-except',
|
||||
'flake8-debugger',
|
||||
'flake8-imports',
|
||||
'flake8',
|
||||
'freezegun==0.3.8',
|
||||
'pytest-cov==2.4.0',
|
||||
'pytest-cov==2.5.1',
|
||||
'pytest-django==3.1.2',
|
||||
'pytest-sugar==0.7.1',
|
||||
'pytest==3.1.0',
|
||||
'wagtail_factories==0.3.0',
|
||||
'pytest-pythonpath==0.7.2',
|
||||
'pytest-sugar==0.9.1',
|
||||
'pytest==3.4.2',
|
||||
'wagtail_factories==1.0.0',
|
||||
'pytest-mock==1.6.3',
|
||||
]
|
||||
|
||||
docs_require = [
|
||||
'sphinx>=1.4.0',
|
||||
'sphinx>=1.7.6',
|
||||
'sphinx_rtd_theme>=0.4.0',
|
||||
]
|
||||
|
||||
with open('README.rst') as fh:
|
||||
long_description = re.sub(
|
||||
'^.. start-no-pypi.*^.. end-no-pypi', '', fh.read(), flags=re.M | re.S)
|
||||
|
||||
setup(
|
||||
name='wagtail_personalisation',
|
||||
version='0.1.0',
|
||||
name='wagtail-personalisation',
|
||||
version='0.12.0',
|
||||
description='A Wagtail add-on for showing personalized content',
|
||||
author='Lab Digital BV',
|
||||
author='Lab Digital BV and others',
|
||||
author_email='opensource@labdigital.nl',
|
||||
url='http://labdigital.nl',
|
||||
url='https://labdigital.nl/',
|
||||
install_requires=install_requires,
|
||||
tests_require=tests_require,
|
||||
extras_require={
|
||||
@ -41,24 +49,17 @@ setup(
|
||||
package_dir={'': 'src'},
|
||||
include_package_data=True,
|
||||
license='MIT',
|
||||
long_description=open('README.rst').read(),
|
||||
long_description=long_description,
|
||||
classifiers=[
|
||||
'Development Status :: 2 - Pre-Alpha',
|
||||
'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.8',
|
||||
'Framework :: Django :: 1.9',
|
||||
'Framework :: Django :: 1.10',
|
||||
'Framework :: Django :: 1.11',
|
||||
'Framework :: Django :: 2',
|
||||
'Topic :: Internet :: WWW/HTTP :: Site Management',
|
||||
],
|
||||
)
|
||||
|
@ -9,7 +9,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):
|
||||
@ -23,28 +23,22 @@ class BaseSegmentsAdapter(object):
|
||||
|
||||
def setup(self):
|
||||
"""Prepare the adapter for segment storage."""
|
||||
return None
|
||||
|
||||
def get_segments(self):
|
||||
"""Return the segments stored in the adapter storage."""
|
||||
return None
|
||||
|
||||
def get_segment_by_id(self):
|
||||
"""Return a single segment stored in the adapter storage."""
|
||||
return None
|
||||
|
||||
def add(self):
|
||||
"""Add a new segment to the adapter storage."""
|
||||
return None
|
||||
|
||||
def refresh(self):
|
||||
"""Refresh the segments stored in the adapter storage."""
|
||||
return None
|
||||
|
||||
def _test_rules(self, rules, request, match_any=False):
|
||||
"""Tests the provided rules to see if the request still belongs
|
||||
to a segment.
|
||||
|
||||
:param rules: The rules to test for
|
||||
:type rules: list of wagtail_personalisation.rules
|
||||
:param request: The http request
|
||||
@ -53,20 +47,12 @@ class BaseSegmentsAdapter(object):
|
||||
:type match_any: bool
|
||||
:returns: A boolean indicating the segment matches the request
|
||||
:rtype: bool
|
||||
|
||||
"""
|
||||
if len(rules) > 0:
|
||||
for rule in rules:
|
||||
result = rule.test_user(request)
|
||||
if not rules:
|
||||
return False
|
||||
if match_any:
|
||||
if result is True:
|
||||
return result
|
||||
|
||||
elif result is False:
|
||||
return False
|
||||
if not match_any:
|
||||
return True
|
||||
return False
|
||||
return any(rule.test_user(request) for rule in rules)
|
||||
return all(rule.test_user(request) for rule in rules)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
@ -80,34 +66,48 @@ class SessionSegmentsAdapter(BaseSegmentsAdapter):
|
||||
self.request.session.setdefault('segments', [])
|
||||
self._segment_cache = None
|
||||
|
||||
def get_segments(self):
|
||||
def _segments(self, ids=None):
|
||||
if not ids:
|
||||
ids = []
|
||||
segments = (
|
||||
Segment.objects
|
||||
.enabled()
|
||||
.filter(persistent=True)
|
||||
.filter(pk__in=ids)
|
||||
)
|
||||
return segments
|
||||
|
||||
def get_segments(self, key="segments"):
|
||||
"""Return the persistent segments stored in the request session.
|
||||
|
||||
: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 self._segment_cache is not None:
|
||||
if key == "segments" and self._segment_cache is not None:
|
||||
return self._segment_cache
|
||||
|
||||
raw_segments = self.request.session['segments']
|
||||
if key not in self.request.session:
|
||||
return []
|
||||
raw_segments = self.request.session[key]
|
||||
segment_ids = [segment['id'] for segment in raw_segments]
|
||||
|
||||
segments = (
|
||||
Segment.objects
|
||||
.filter(status=Segment.STATUS_ENABLED)
|
||||
.filter(persistent=True)
|
||||
.in_bulk(segment_ids))
|
||||
segments = self._segments(ids=segment_ids)
|
||||
|
||||
retval = [segments[pk] for pk in segment_ids if pk in segments]
|
||||
self._segment_cache = retval
|
||||
return retval
|
||||
result = list(segments)
|
||||
if key == "segments":
|
||||
self._segment_cache = result
|
||||
return result
|
||||
|
||||
def set_segments(self, segments):
|
||||
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 = []
|
||||
@ -122,7 +122,8 @@ class SessionSegmentsAdapter(BaseSegmentsAdapter):
|
||||
serialized_segments.append(serialized)
|
||||
segment_ids.add(segment.pk)
|
||||
|
||||
self.request.session['segments'] = serialized_segments
|
||||
self.request.session[key] = serialized_segments
|
||||
if key == "segments":
|
||||
self._segment_cache = cache_segments
|
||||
|
||||
def get_segment_by_id(self, segment_id):
|
||||
@ -134,10 +135,11 @@ class SessionSegmentsAdapter(BaseSegmentsAdapter):
|
||||
:rtype: wagtail_personalisation.models.Segment or None
|
||||
|
||||
"""
|
||||
segments = self.get_segments()
|
||||
return next((s for s in segments if s.pk == segment_id), None)
|
||||
segments = self._segments(ids=[segment_id])
|
||||
if segments.exists():
|
||||
return segments.get()
|
||||
|
||||
def add_page_visit(self, page, path=None):
|
||||
def add_page_visit(self, page):
|
||||
"""Mark the page as visited by the user"""
|
||||
visit_count = self.request.session.setdefault('visit_count', [])
|
||||
page_visits = [visit for visit in visit_count if visit['id'] == page.pk]
|
||||
@ -145,18 +147,19 @@ class SessionSegmentsAdapter(BaseSegmentsAdapter):
|
||||
if page_visits:
|
||||
for page_visit in page_visits:
|
||||
page_visit['count'] += 1
|
||||
page_visit['path'] = page.url_path if page else self.request.path
|
||||
self.request.session.modified = True
|
||||
else:
|
||||
visit_count.append({
|
||||
'slug': page.slug,
|
||||
'id': page.pk,
|
||||
'path': path or self.request.path,
|
||||
'path': page.url_path if page else self.request.path,
|
||||
'count': 1,
|
||||
})
|
||||
|
||||
def get_visit_count(self, page=None):
|
||||
"""Return the number of visits on the current request or given page"""
|
||||
path = page.path if page else self.request.path
|
||||
path = page.url_path if page else self.request.path
|
||||
visit_count = self.request.session.setdefault('visit_count', [])
|
||||
for visit in visit_count:
|
||||
if visit['path'] == path:
|
||||
@ -179,25 +182,44 @@ class SessionSegmentsAdapter(BaseSegmentsAdapter):
|
||||
still apply to the requesting visitor.
|
||||
|
||||
"""
|
||||
all_segments = Segment.objects.filter(status=Segment.STATUS_ENABLED)
|
||||
enabled_segments = Segment.objects.enabled()
|
||||
rule_models = AbstractBaseRule.get_descendant_models()
|
||||
|
||||
current_segments = self.get_segments()
|
||||
rules = AbstractBaseRule.__subclasses__()
|
||||
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 all_segments:
|
||||
segment_rules = []
|
||||
for rule in rules:
|
||||
segment_rules += rule.objects.filter(segment=segment)
|
||||
|
||||
result = self._test_rules(
|
||||
segment_rules, self.request, match_any=segment.match_any)
|
||||
|
||||
if result:
|
||||
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() or
|
||||
segment in excluded_segments):
|
||||
continue
|
||||
elif not segment.is_static or not segment.is_full:
|
||||
segment_rules = []
|
||||
for rule_model in rule_models:
|
||||
segment_rules.extend(rule_model.objects.filter(segment=segment))
|
||||
|
||||
result = self._test_rules(segment_rules, self.request,
|
||||
match_any=segment.match_any)
|
||||
|
||||
if result and segment.randomise_into_segment():
|
||||
if segment.is_static and not segment.is_full:
|
||||
if self.request.user.is_authenticated:
|
||||
segment.static_users.add(self.request.user)
|
||||
additional_segments.append(segment)
|
||||
elif result:
|
||||
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()
|
||||
|
||||
|
||||
@ -209,8 +231,6 @@ SEGMENT_ADAPTER_CLASS = import_string(getattr(
|
||||
|
||||
def get_segment_adapter(request):
|
||||
"""Return the Segment Adapter for the given request"""
|
||||
try:
|
||||
return request.segment_adapter
|
||||
except AttributeError:
|
||||
if not hasattr(request, 'segment_adapter'):
|
||||
request.segment_adapter = SEGMENT_ADAPTER_CLASS(request)
|
||||
return request.segment_adapter
|
||||
|
@ -13,4 +13,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,15 +1,15 @@
|
||||
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():
|
||||
for segment in Segment.objects.all():
|
||||
yield (segment.pk, segment.name)
|
||||
for pk, name in Segment.objects.values_list('pk', 'name'):
|
||||
yield pk, name
|
||||
|
||||
|
||||
class PersonalisedStructBlock(blocks.StructBlock):
|
||||
@ -37,7 +37,7 @@ class PersonalisedStructBlock(blocks.StructBlock):
|
||||
|
||||
if value['segment']:
|
||||
for segment in user_segments:
|
||||
if segment['id'] == int(value['segment']):
|
||||
if segment.id == int(value['segment']):
|
||||
return super(PersonalisedStructBlock, self).render(
|
||||
value, context)
|
||||
|
||||
|
@ -1,43 +1,138 @@
|
||||
from wagtail.wagtailadmin.forms import WagtailAdminPageForm
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from datetime import datetime
|
||||
from importlib import import_module
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.contrib.staticfiles.templatetags.staticfiles 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.admin.forms import WagtailAdminModelForm
|
||||
|
||||
SessionStore = import_module(settings.SESSION_ENGINE).SessionStore
|
||||
|
||||
|
||||
class AdminPersonalisablePageForm(WagtailAdminPageForm):
|
||||
"""The personalisable page form that allows creation of variants."""
|
||||
@lru_cache(maxsize=1000)
|
||||
def user_from_data(user_id):
|
||||
User = get_user_model()
|
||||
try:
|
||||
return User.objects.get(id=user_id)
|
||||
except User.DoesNotExist:
|
||||
return AnonymousUser()
|
||||
|
||||
def save(self, commit=True):
|
||||
"""Save a copy of the original page, linked to a segment.
|
||||
|
||||
:returns: The original page, or a new page.
|
||||
:rtype: wagtail_personalisation.models.PersonalisablePage
|
||||
class SegmentAdminForm(WagtailAdminModelForm):
|
||||
|
||||
def count_matching_users(self, rules, match_any):
|
||||
""" Calculates how many users match the given static rules
|
||||
"""
|
||||
page = super(AdminPersonalisablePageForm, self).save(commit=False)
|
||||
count = 0
|
||||
|
||||
if page.segment:
|
||||
segment = page.segment
|
||||
slug = "{}-{}".format(page.slug, segment.encoded_name())
|
||||
title = "{} ({})".format(page.title, segment.name)
|
||||
update_attrs = {
|
||||
'title': title,
|
||||
'slug': slug,
|
||||
'segment': segment,
|
||||
'live': False,
|
||||
'canonical_page': page,
|
||||
'is_segmented': True,
|
||||
}
|
||||
static_rules = [rule for rule in rules if rule.static]
|
||||
|
||||
if page.is_segmented:
|
||||
slug = "{}-{}".format(
|
||||
page.canonical_page.slug, segment.encoded_name())
|
||||
title = "{} ({})".format(
|
||||
page.canonical_page.title, segment.name)
|
||||
page.slug = slug
|
||||
page.title = title
|
||||
page.save()
|
||||
return page
|
||||
if not static_rules:
|
||||
return count
|
||||
|
||||
User = get_user_model()
|
||||
users = User.objects.filter(is_active=True, is_staff=False)
|
||||
|
||||
for user in users.iterator():
|
||||
if match_any:
|
||||
if any(rule.test_user(None, user) for rule in static_rules):
|
||||
count += 1
|
||||
elif all(rule.test_user(None, user) for rule in static_rules):
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super(SegmentAdminForm, self).clean()
|
||||
Segment = self._meta.model
|
||||
|
||||
rules = [
|
||||
form.instance for formset in self.formsets.values()
|
||||
for form in formset
|
||||
if form not in formset.deleted_forms
|
||||
]
|
||||
consistent = rules and Segment.all_static(rules)
|
||||
|
||||
if cleaned_data.get('type') == Segment.TYPE_STATIC and not cleaned_data.get('count') and not consistent:
|
||||
self.add_error('count', _('Static segments with non-static compatible rules must include a count.'))
|
||||
|
||||
if self.instance.id and self.instance.is_static:
|
||||
if self.has_changed():
|
||||
self.add_error_to_fields(self, excluded=['name', 'enabled'])
|
||||
|
||||
for formset in self.formsets.values():
|
||||
if formset.has_changed():
|
||||
for form in formset:
|
||||
if form not in formset.deleted_forms:
|
||||
self.add_error_to_fields(form)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
def add_error_to_fields(self, form, excluded=list()):
|
||||
for field in form.changed_data:
|
||||
if field not in excluded:
|
||||
form.add_error(field, _('Cannot update a static segment'))
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
is_new = not self.instance.id
|
||||
|
||||
if not self.instance.is_static:
|
||||
self.instance.count = 0
|
||||
|
||||
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
|
||||
if form not in formset.deleted_forms
|
||||
]
|
||||
self.instance.matched_users_count = self.count_matching_users(
|
||||
rules, self.instance.match_any)
|
||||
self.instance.matched_count_updated_at = datetime.now()
|
||||
|
||||
instance = super(SegmentAdminForm, self).save(*args, **kwargs)
|
||||
|
||||
if is_new and instance.is_static and instance.all_rules_static:
|
||||
from .adapters import get_segment_adapter
|
||||
|
||||
request = RequestFactory().get('/')
|
||||
request.session = SessionStore()
|
||||
adapter = get_segment_adapter(request)
|
||||
|
||||
users_to_add = []
|
||||
users_to_exclude = []
|
||||
|
||||
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:
|
||||
new_page = page.copy(
|
||||
update_attrs=update_attrs, copy_revisions=False)
|
||||
return new_page
|
||||
users_to_exclude.append(user)
|
||||
|
||||
return page
|
||||
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)
|
||||
|
||||
return instance
|
||||
|
||||
@property
|
||||
def media(self):
|
||||
media = super(SegmentAdminForm, self).media
|
||||
media.add_js(
|
||||
[static('js/segment_form_control.js')]
|
||||
)
|
||||
return media
|
||||
|
352
src/wagtail_personalisation/locale/en/LC_MESSAGES/django.po
Normal file
@ -0,0 +1,352 @@
|
||||
# Wagtail Personalisation english translation strings.
|
||||
# Copyright (C) 2017 Lab Digital B.V.
|
||||
# This file is distributed under the same license as the wagtail_personalisation package.
|
||||
# Boris Besemer <b.besemer@labdigital.nl>, 2017.
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: wagtail_personalisation 0.1.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2017-05-31 09:30-0500\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
"Language: \n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
#: blocks.py:20
|
||||
msgid "Personalisation segment"
|
||||
msgstr ""
|
||||
|
||||
#: blocks.py:21
|
||||
msgid "Only show this content block for users in this segment"
|
||||
msgstr ""
|
||||
|
||||
#: config.py:8
|
||||
msgid "Wagtail Personalisation"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:26
|
||||
msgid "Enabled"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:27
|
||||
msgid "Disabled"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:39
|
||||
msgid "Should the segment persist between visits?"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:42
|
||||
msgid "Should the segment match all the rules or just one of them?"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:60
|
||||
msgid "Rules"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:167
|
||||
msgid "Content"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:169
|
||||
msgid "Variations"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:171
|
||||
msgid "Promote"
|
||||
msgstr ""
|
||||
|
||||
#: models.py:173
|
||||
msgid "Settings"
|
||||
msgstr ""
|
||||
|
||||
#: rules.py:29 rules.py:48
|
||||
msgid "Abstract segmentation rule"
|
||||
msgstr ""
|
||||
|
||||
#: rules.py:63
|
||||
msgid "Starting time"
|
||||
msgstr ""
|
||||
|
||||
#: rules.py:64
|
||||
msgid "Ending time"
|
||||
msgstr ""
|
||||
|
||||
#: rules.py:74
|
||||
msgid "Time Rule"
|
||||
msgstr ""
|
||||
|
||||
#: rules.py:85
|
||||
msgid "These users visit between"
|
||||
msgstr ""
|
||||
|
||||
#: rules.py:86
|
||||
msgid "{} and {}"
|
||||
msgstr ""
|
||||
|
||||
#: rules.py:103
|
||||
msgid "Monday"
|
||||
msgstr ""
|
||||
|
||||
#: rules.py:104
|
||||
msgid "Tuesday"
|
||||
msgstr ""
|
||||
|
||||
#: rules.py:105
|
||||
msgid "Wednesday"
|
||||
msgstr ""
|
||||
|
||||
#: rules.py:106
|
||||
msgid "Thursday"
|
||||
msgstr ""
|
||||
|
||||
#: rules.py:107
|
||||
msgid "Friday"
|
||||
msgstr ""
|
||||
|
||||
#: rules.py:108
|
||||
msgid "Saturday"
|
||||
msgstr ""
|
||||
|
||||
#: rules.py:109
|
||||
msgid "Sunday"
|
||||
msgstr ""
|
||||
|
||||
#: rules.py:122
|
||||
msgid "Day Rule"
|
||||
msgstr ""
|
||||
|
||||
#: rules.py:146
|
||||
msgid "These users visit on"
|
||||
msgstr ""
|
||||
|
||||
#: rules.py:162
|
||||
msgid "Regular expression to match the referrer"
|
||||
msgstr ""
|
||||
|
||||
#: rules.py:169
|
||||
msgid "Referral Rule"
|
||||
msgstr ""
|
||||
|
||||
#: rules.py:182
|
||||
msgid "These visits originate from"
|
||||
msgstr ""
|
||||
|
||||
#: rules.py:183 rules.py:366
|
||||
msgid "{}"
|
||||
msgstr ""
|
||||
|
||||
#: rules.py:202
|
||||
msgid "More than"
|
||||
msgstr ""
|
||||
|
||||
#: rules.py:203
|
||||
msgid "Less than"
|
||||
msgstr ""
|
||||
|
||||
#: rules.py:204
|
||||
msgid "Equal to"
|
||||
msgstr ""
|
||||
|
||||
#: rules.py:247
|
||||
msgid "Visit count Rule"
|
||||
msgstr ""
|
||||
|
||||
#: rules.py:251
|
||||
msgid "These users visited {}"
|
||||
msgstr ""
|
||||
|
||||
#: rules.py:254
|
||||
msgid "{} {} times"
|
||||
msgstr ""
|
||||
|
||||
#: rules.py:271
|
||||
msgid "The query parameter to search for"
|
||||
msgstr ""
|
||||
|
||||
#: rules.py:273
|
||||
msgid "The value of the parameter to match"
|
||||
msgstr ""
|
||||
|
||||
#: rules.py:282
|
||||
msgid "Query Rule"
|
||||
msgstr ""
|
||||
|
||||
#: rules.py:293
|
||||
msgid "These users used a URL with the query"
|
||||
msgstr ""
|
||||
|
||||
#: rules.py:294
|
||||
msgid "?{}={}"
|
||||
msgstr ""
|
||||
|
||||
#: rules.py:312
|
||||
msgid "Mobile phone"
|
||||
msgstr ""
|
||||
|
||||
#: rules.py:313
|
||||
msgid "Tablet"
|
||||
msgstr ""
|
||||
|
||||
#: rules.py:314
|
||||
msgid "Desktop"
|
||||
msgstr ""
|
||||
|
||||
#: rules.py:323
|
||||
msgid "Device Rule"
|
||||
msgstr ""
|
||||
|
||||
#: rules.py:354
|
||||
msgid "Logged in Rule"
|
||||
msgstr ""
|
||||
|
||||
#: rules.py:360
|
||||
msgid "Logged in"
|
||||
msgstr ""
|
||||
|
||||
#: rules.py:362
|
||||
msgid "Not logged in"
|
||||
msgstr ""
|
||||
|
||||
#: rules.py:365
|
||||
msgid "These visitors are"
|
||||
msgstr ""
|
||||
|
||||
#: templates/modeladmin/wagtail_personalisation/segment/base.html:28
|
||||
msgid "Switch view"
|
||||
msgstr ""
|
||||
|
||||
#: templates/modeladmin/wagtail_personalisation/segment/dashboard.html:14
|
||||
#: templates/modeladmin/wagtail_personalisation/segment/index.html:14
|
||||
msgid "Filter"
|
||||
msgstr ""
|
||||
|
||||
#: templates/modeladmin/wagtail_personalisation/segment/dashboard.html:30
|
||||
msgid "This segment has been visited"
|
||||
msgstr ""
|
||||
|
||||
#: templates/modeladmin/wagtail_personalisation/segment/dashboard.html:31
|
||||
msgid "time"
|
||||
msgstr ""
|
||||
|
||||
#: templates/modeladmin/wagtail_personalisation/segment/dashboard.html:34
|
||||
msgid "This segment has been active for"
|
||||
msgstr ""
|
||||
|
||||
#: templates/modeladmin/wagtail_personalisation/segment/dashboard.html:35
|
||||
msgid "day"
|
||||
msgstr ""
|
||||
|
||||
#: templates/modeladmin/wagtail_personalisation/segment/dashboard.html:43
|
||||
msgid "The visitor must match"
|
||||
msgstr ""
|
||||
|
||||
#: templates/modeladmin/wagtail_personalisation/segment/dashboard.html:45
|
||||
msgid "Any rule"
|
||||
msgstr ""
|
||||
|
||||
#: templates/modeladmin/wagtail_personalisation/segment/dashboard.html:47
|
||||
msgid "All rules"
|
||||
msgstr ""
|
||||
|
||||
#: templates/modeladmin/wagtail_personalisation/segment/dashboard.html:52
|
||||
msgid "The persistence of this segment is"
|
||||
msgstr ""
|
||||
|
||||
#: templates/modeladmin/wagtail_personalisation/segment/dashboard.html:54
|
||||
msgid "This segment persists in between visits"
|
||||
msgstr ""
|
||||
|
||||
#: templates/modeladmin/wagtail_personalisation/segment/dashboard.html:54
|
||||
msgid "Persistent"
|
||||
msgstr ""
|
||||
|
||||
#: templates/modeladmin/wagtail_personalisation/segment/dashboard.html:56
|
||||
msgid "This segment is re-evaluated on every visit"
|
||||
msgstr ""
|
||||
|
||||
#: templates/modeladmin/wagtail_personalisation/segment/dashboard.html:56
|
||||
msgid "Fleeting"
|
||||
msgstr ""
|
||||
|
||||
#: templates/modeladmin/wagtail_personalisation/segment/dashboard.html:76
|
||||
msgid "Enable this segment"
|
||||
msgstr ""
|
||||
|
||||
#: templates/modeladmin/wagtail_personalisation/segment/dashboard.html:78
|
||||
msgid "Disable this segment"
|
||||
msgstr ""
|
||||
|
||||
#: templates/modeladmin/wagtail_personalisation/segment/dashboard.html:80
|
||||
msgid "Configure this segment"
|
||||
msgstr ""
|
||||
|
||||
#: templates/modeladmin/wagtail_personalisation/segment/dashboard.html:88
|
||||
#, python-format
|
||||
msgid ""
|
||||
"\n"
|
||||
" <a class=\"block suggestion\" href="
|
||||
"\"%(url)s\">\n"
|
||||
" <span class=\"suggestive_text\">Add "
|
||||
"a new %(name)s</span>\n"
|
||||
" </a>\n"
|
||||
" "
|
||||
msgstr ""
|
||||
|
||||
#: templates/modeladmin/wagtail_personalisation/segment/dashboard.html:101
|
||||
#: templates/modeladmin/wagtail_personalisation/segment/index.html:45
|
||||
#, python-format
|
||||
msgid "Page %(current_page)s of %(num_pages)s."
|
||||
msgstr ""
|
||||
|
||||
#: templates/modeladmin/wagtail_personalisation/segment/index.html:24
|
||||
#, python-format
|
||||
msgid ""
|
||||
"No %(name)s have been created yet. One of the following must be created "
|
||||
"before you can add any %(name)s:"
|
||||
msgstr ""
|
||||
|
||||
#: templates/modeladmin/wagtail_personalisation/segment/index.html:29
|
||||
#, python-format
|
||||
msgid "No %(name)s have been created yet."
|
||||
msgstr ""
|
||||
|
||||
#: templates/modeladmin/wagtail_personalisation/segment/index.html:31
|
||||
#, python-format
|
||||
msgid ""
|
||||
"\n"
|
||||
" Why not <a href=\"%(url)s\">add "
|
||||
"one</a>?\n"
|
||||
" "
|
||||
msgstr ""
|
||||
|
||||
#: views.py:60
|
||||
#, python-brace-format
|
||||
msgid "{visits} visits"
|
||||
msgstr ""
|
||||
|
||||
#: views.py:63
|
||||
#, python-brace-format
|
||||
msgid "{days} days"
|
||||
msgstr ""
|
||||
|
||||
#: wagtail_hooks.py:121
|
||||
msgid "Variants"
|
||||
msgstr ""
|
||||
|
||||
#: wagtail_hooks.py:126
|
||||
msgid "Create a new variant"
|
||||
msgstr ""
|
||||
|
||||
#: wagtail_hooks.py:146
|
||||
msgid "Create this variant"
|
||||
msgstr ""
|
||||
|
||||
#: wagtail_hooks.py:159
|
||||
msgid "Segments"
|
||||
msgstr ""
|
@ -12,7 +12,7 @@ class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('wagtailcore', '0030_index_on_pagerevision_created_at'),
|
||||
('wagtailcore', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@ -21,7 +21,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')),
|
||||
('is_segmented', models.BooleanField(default=False)),
|
||||
('canonical_page', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='variations', to='wagtail_personalisation.PersonalisablePage')),
|
||||
('canonical_page', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='variants', to='wagtail_personalisation.PersonalisablePage')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
|
@ -0,0 +1,48 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.1 on 2017-05-31 11:01
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wagtail_personalisation', '0009_auto_20170531_0428'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='dayrule',
|
||||
options={'verbose_name': 'Day Rule'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='devicerule',
|
||||
options={'verbose_name': 'Device Rule'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='queryrule',
|
||||
options={'verbose_name': 'Query Rule'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='referralrule',
|
||||
options={'verbose_name': 'Referral Rule'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='timerule',
|
||||
options={'verbose_name': 'Time Rule'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='userisloggedinrule',
|
||||
options={'verbose_name': 'Logged in Rule'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='visitcountrule',
|
||||
options={'verbose_name': 'Visit count Rule'},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='referralrule',
|
||||
name='regex_string',
|
||||
field=models.TextField(verbose_name='Regular expression to match the referrer'),
|
||||
),
|
||||
]
|
@ -0,0 +1,30 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.1 on 2017-05-31 14:28
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wagtailcore', '0001_initial'),
|
||||
('wagtail_personalisation', '0010_auto_20170531_1101'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PersonalisablePageMetadata',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('is_segmented', models.BooleanField(default=False)),
|
||||
('canonical_page', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='personalisable_canonical_metadata', to='wagtailcore.Page')),
|
||||
('segment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='page_metadata', to='wagtail_personalisation.Segment')),
|
||||
('variant', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='_personalisable_page_metadata', to='wagtailcore.Page')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
@ -0,0 +1,19 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.1 on 2017-06-01 11:48
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wagtail_personalisation', '0011_personalisablepagemetadata'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='personalisablepagemetadata',
|
||||
name='is_segmented',
|
||||
),
|
||||
]
|
@ -0,0 +1,31 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.6 on 2017-10-17 11:18
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('sessions', '0001_initial'),
|
||||
('wagtail_personalisation', '0012_remove_personalisablepagemetadata_is_segmented'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='segment',
|
||||
name='count',
|
||||
field=models.PositiveSmallIntegerField(default=0, help_text='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.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='segment',
|
||||
name='sessions',
|
||||
field=models.ManyToManyField(to='sessions.Session'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='segment',
|
||||
name='type',
|
||||
field=models.CharField(choices=[('dynamic', 'Dynamic'), ('static', 'Static')], default='dynamic', help_text='\n </br></br><strong>Dynamic:</strong> Users in this segment will change\n as more or less meet the rules specified in the segment.\n </br><strong>Static:</strong> If the segment contains only static\n compatible rules the segment will contain the members that pass\n those rules when the segment is created. Mixed static segments or\n those containing entirely non static compatible rules will be\n populated using the count variable.\n ', max_length=20),
|
||||
),
|
||||
]
|
26
src/wagtail_personalisation/migrations/0015_static_users.py
Normal file
@ -0,0 +1,26 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.6 on 2017-11-01 15:58
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('wagtail_personalisation', '0013_add_dynamic_static_to_segment'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='segment',
|
||||
name='sessions',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='segment',
|
||||
name='static_users',
|
||||
field=models.ManyToManyField(to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
@ -0,0 +1,25 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.9 on 2018-01-25 09:18
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wagtail_personalisation', '0015_static_users'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='segment',
|
||||
name='matched_count_updated_at',
|
||||
field=models.DateTimeField(editable=False, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='segment',
|
||||
name='matched_users_count',
|
||||
field=models.PositiveIntegerField(default=0, editable=False),
|
||||
),
|
||||
]
|
@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.8 on 2018-01-31 16:12
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wagtail_personalisation', '0016_auto_20180125_0918'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='segment',
|
||||
name='randomisation_percent',
|
||||
field=models.PositiveSmallIntegerField(blank=True, default=None, help_text='If this number is set each user matching the rules will have this percentage chance of being placed in the segment.', null=True, validators=[django.core.validators.MaxValueValidator(100), django.core.validators.MinValueValidator(0)]),
|
||||
),
|
||||
]
|
@ -0,0 +1,22 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.9 on 2018-02-09 08:28
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('wagtail_personalisation', '0017_segment_randomisation_percent'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='segment',
|
||||
name='excluded_users',
|
||||
field=models.ManyToManyField(help_text='Users that matched the rules but were excluded from the segment for some reason e.g. randomisation', related_name='excluded_segments', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
@ -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
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
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
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
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'),
|
||||
),
|
||||
]
|
@ -0,0 +1,45 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.2 on 2017-08-10 13:48
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def forward(apps, schema_editor):
|
||||
Segment = apps.get_model('wagtail_personalisation', 'Segment')
|
||||
|
||||
for segment in Segment.objects.all():
|
||||
segment.enabled = segment.status == 'enabled'
|
||||
segment.save()
|
||||
|
||||
|
||||
def backward(apps, schema_editor):
|
||||
Segment = apps.get_model('wagtail_personalisation', 'Segment')
|
||||
|
||||
for segment in Segment.objects.all():
|
||||
if segment.enabled:
|
||||
segment.status = 'enabled'
|
||||
else:
|
||||
segment.status = 'disabled'
|
||||
|
||||
segment.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wagtail_personalisation', '0023_personalisablepagemetadata_variant_cascade'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='segment',
|
||||
name='enabled',
|
||||
field=models.BooleanField(default=True, help_text='Should the segment be active?'),
|
||||
),
|
||||
migrations.RunPython(forward, reverse_code=backward),
|
||||
migrations.RemoveField(
|
||||
model_name='segment',
|
||||
name='status',
|
||||
),
|
||||
]
|
@ -1,35 +1,39 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
import random
|
||||
|
||||
from django.db import models
|
||||
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.utils.decorators import cached_classmethod
|
||||
from wagtail.wagtailadmin.edit_handlers import (
|
||||
FieldPanel, FieldRowPanel, InlinePanel, MultiFieldPanel, ObjectList,
|
||||
PageChooserPanel, TabbedInterface)
|
||||
from wagtail.admin.edit_handlers import (
|
||||
FieldPanel, FieldRowPanel, InlinePanel, MultiFieldPanel)
|
||||
from wagtail.core.models import Page
|
||||
|
||||
from wagtail_personalisation.forms import AdminPersonalisablePageForm
|
||||
from wagtail_personalisation.rules import AbstractBaseRule
|
||||
from wagtail_personalisation.utils import count_active_days
|
||||
|
||||
from .forms import SegmentAdminForm
|
||||
|
||||
|
||||
class SegmentQuerySet(models.QuerySet):
|
||||
def enabled(self):
|
||||
return self.filter(status=self.model.STATUS_ENABLED)
|
||||
return self.filter(enabled=True)
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class Segment(ClusterableModel):
|
||||
"""The segment model."""
|
||||
STATUS_ENABLED = 'enabled'
|
||||
STATUS_DISABLED = 'disabled'
|
||||
TYPE_DYNAMIC = 'dynamic'
|
||||
TYPE_STATIC = 'static'
|
||||
|
||||
STATUS_CHOICES = (
|
||||
(STATUS_ENABLED, 'Enabled'),
|
||||
(STATUS_DISABLED, 'Disabled'),
|
||||
TYPE_CHOICES = (
|
||||
(TYPE_DYNAMIC, _('Dynamic')),
|
||||
(TYPE_STATIC, _('Static')),
|
||||
)
|
||||
|
||||
name = models.CharField(max_length=255)
|
||||
@ -38,33 +42,84 @@ class Segment(ClusterableModel):
|
||||
enable_date = models.DateTimeField(null=True, editable=False)
|
||||
disable_date = models.DateTimeField(null=True, editable=False)
|
||||
visit_count = models.PositiveIntegerField(default=0, editable=False)
|
||||
status = models.CharField(
|
||||
max_length=20, choices=STATUS_CHOICES, default=STATUS_ENABLED)
|
||||
enabled = models.BooleanField(
|
||||
default=True, help_text=_("Should the segment be active?"))
|
||||
persistent = models.BooleanField(
|
||||
default=False, help_text=_("Should the segment persist between visits?"))
|
||||
match_any = models.BooleanField(
|
||||
default=False,
|
||||
help_text=_("Should the segment match all the rules or just one of them?")
|
||||
)
|
||||
type = models.CharField(
|
||||
max_length=20,
|
||||
choices=TYPE_CHOICES,
|
||||
default=TYPE_DYNAMIC,
|
||||
help_text=mark_safe(_("""
|
||||
</br></br><strong>Dynamic:</strong> Users in this segment will change
|
||||
as more or less meet the rules specified in the segment.
|
||||
</br><strong>Static:</strong> 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.
|
||||
"""))
|
||||
)
|
||||
count = models.PositiveSmallIntegerField(
|
||||
default=0,
|
||||
help_text=_(
|
||||
"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."
|
||||
)
|
||||
)
|
||||
static_users = models.ManyToManyField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
)
|
||||
excluded_users = models.ManyToManyField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
help_text=_("Users that matched the rules but were excluded from the "
|
||||
"segment for some reason e.g. randomisation"),
|
||||
related_name="excluded_segments"
|
||||
)
|
||||
|
||||
matched_users_count = models.PositiveIntegerField(default=0, editable=False)
|
||||
matched_count_updated_at = models.DateTimeField(null=True, editable=False)
|
||||
|
||||
randomisation_percent = models.PositiveSmallIntegerField(
|
||||
null=True, blank=True, default=None,
|
||||
help_text=_(
|
||||
"If this number is set each user matching the rules will "
|
||||
"have this percentage chance of being placed in the segment."
|
||||
), validators=[
|
||||
MaxValueValidator(100),
|
||||
MinValueValidator(0)
|
||||
])
|
||||
|
||||
objects = SegmentQuerySet.as_manager()
|
||||
|
||||
base_form_class = SegmentAdminForm
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
Segment.panels = [
|
||||
MultiFieldPanel([
|
||||
FieldPanel('name', classname="title"),
|
||||
FieldRowPanel([
|
||||
FieldPanel('status'),
|
||||
FieldPanel('enabled'),
|
||||
FieldPanel('persistent'),
|
||||
]),
|
||||
FieldPanel('match_any'),
|
||||
FieldPanel('type', widget=forms.RadioSelect),
|
||||
FieldPanel('count', classname='count_field'),
|
||||
FieldPanel('randomisation_percent', classname='percent_field'),
|
||||
], heading="Segment"),
|
||||
MultiFieldPanel([
|
||||
InlinePanel(
|
||||
"{}_related".format(rule._meta.db_table),
|
||||
label=rule.__str__,
|
||||
) for rule in AbstractBaseRule.__subclasses__()
|
||||
], heading="Rules"),
|
||||
"{}s".format(rule_model._meta.db_table),
|
||||
label='{}{}'.format(
|
||||
rule_model._meta.verbose_name,
|
||||
' ({})'.format(_('Static compatible')) if rule_model.static else ''
|
||||
),
|
||||
) for rule_model in AbstractBaseRule.__subclasses__()
|
||||
], heading=_("Rules")),
|
||||
]
|
||||
|
||||
super(Segment, self).__init__(*args, **kwargs)
|
||||
@ -72,6 +127,23 @@ class Segment(ClusterableModel):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def is_static(self):
|
||||
return self.type == self.TYPE_STATIC
|
||||
|
||||
@classmethod
|
||||
def all_static(cls, rules):
|
||||
return all(rule.static for rule in rules)
|
||||
|
||||
@property
|
||||
def all_rules_static(self):
|
||||
rules = self.get_rules()
|
||||
return rules and self.all_static(rules)
|
||||
|
||||
@property
|
||||
def is_full(self):
|
||||
return self.static_users.count() >= self.count
|
||||
|
||||
def encoded_name(self):
|
||||
"""Return a string with a slug for the segment."""
|
||||
return slugify(self.name.lower())
|
||||
@ -80,108 +152,143 @@ class Segment(ClusterableModel):
|
||||
"""Return the amount of days the segment has been active."""
|
||||
return count_active_days(self.enable_date, self.disable_date)
|
||||
|
||||
def get_used_pages(self):
|
||||
"""Return the pages that have variants using this segment."""
|
||||
return PersonalisablePageMetadata.objects.filter(segment=self)
|
||||
|
||||
def get_created_variants(self):
|
||||
"""Return the variants using this segment."""
|
||||
return Page.objects.filter(_personalisable_page_metadata__segment=self)
|
||||
|
||||
def get_rules(self):
|
||||
"""Retrieve all rules in the segment."""
|
||||
rules = AbstractBaseRule.__subclasses__()
|
||||
segment_rules = []
|
||||
for rule in rules:
|
||||
segment_rules += rule.objects.filter(segment=self)
|
||||
for rule_model in AbstractBaseRule.get_descendant_models():
|
||||
segment_rules.extend(
|
||||
rule_model._default_manager.filter(segment=self))
|
||||
|
||||
return segment_rules
|
||||
|
||||
def toggle(self, save=True):
|
||||
self.enabled = not self.enabled
|
||||
if save:
|
||||
self.save()
|
||||
|
||||
class PersonalisablePageMixin(models.Model):
|
||||
def randomise_into_segment(self):
|
||||
""" Returns True if randomisation_percent is not set or it generates
|
||||
a random number less than the randomisation_percent
|
||||
This is so there is some randomisation in which users are added to the
|
||||
segment
|
||||
"""
|
||||
if self.randomisation_percent is None:
|
||||
return True
|
||||
|
||||
if random.randint(1, 100) <= self.randomisation_percent:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class PersonalisablePageMetadata(ClusterableModel):
|
||||
"""The personalisable page model. Allows creation of variants with linked
|
||||
segments.
|
||||
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
# Canonical pages should not ever be deleted if they have variants
|
||||
# because the variants will be orphaned.
|
||||
canonical_page = models.ForeignKey(
|
||||
'self', related_name='variations', on_delete=models.SET_NULL,
|
||||
blank=True, null=True
|
||||
Page, models.PROTECT, related_name='personalisable_canonical_metadata',
|
||||
null=True
|
||||
)
|
||||
segment = models.ForeignKey(
|
||||
Segment, related_name='segments', on_delete=models.PROTECT,
|
||||
blank=True, null=True
|
||||
|
||||
# Delete metadata of the variant if its page gets deleted.
|
||||
variant = models.OneToOneField(
|
||||
Page, models.CASCADE, related_name='_personalisable_page_metadata',
|
||||
null=True
|
||||
)
|
||||
is_segmented = models.BooleanField(default=False)
|
||||
|
||||
variation_panels = [
|
||||
MultiFieldPanel([
|
||||
FieldPanel('segment'),
|
||||
PageChooserPanel('canonical_page', page_type=None),
|
||||
])
|
||||
]
|
||||
|
||||
base_form_class = AdminPersonalisablePageForm
|
||||
|
||||
def __str__(self):
|
||||
return "{}".format(self.title)
|
||||
segment = models.ForeignKey(Segment, models.PROTECT, null=True,
|
||||
related_name='page_metadata')
|
||||
|
||||
@cached_property
|
||||
def has_variations(self):
|
||||
def has_variants(self):
|
||||
"""Return a boolean indicating whether or not the personalisable page
|
||||
has variations.
|
||||
has variants.
|
||||
|
||||
:returns: A boolean indicating whether or not the personalisable page
|
||||
has variations.
|
||||
has variants.
|
||||
:rtype: bool
|
||||
|
||||
"""
|
||||
return self.variations.exists()
|
||||
return self.variants_metadata.exists()
|
||||
|
||||
@cached_property
|
||||
def variants_metadata(self):
|
||||
return (
|
||||
PersonalisablePageMetadata.objects
|
||||
.filter(canonical_page_id=self.canonical_page_id)
|
||||
.exclude(variant_id=self.variant_id)
|
||||
.exclude(variant_id=self.canonical_page_id))
|
||||
|
||||
@cached_property
|
||||
def is_canonical(self):
|
||||
"""Return a boolean indicating whether or not the personalisable page
|
||||
is a canonical page.
|
||||
|
||||
:returns: A boolean indicating whether or not the personalisable page
|
||||
:returns: A boolean indicating whether or not the personalisable
|
||||
page
|
||||
is a canonical page.
|
||||
:rtype: bool
|
||||
|
||||
"""
|
||||
return not self.canonical_page and self.has_variations
|
||||
return self.canonical_page_id == self.variant_id
|
||||
|
||||
def copy_for_segment(self, segment):
|
||||
slug = "{}-{}".format(self.slug, segment.encoded_name())
|
||||
title = "{} ({})".format(self.title, segment.name)
|
||||
page = self.canonical_page
|
||||
|
||||
slug = "{}-{}".format(page.slug, segment.encoded_name())
|
||||
title = "{} ({})".format(page.title, segment.name)
|
||||
update_attrs = {
|
||||
'title': title,
|
||||
'slug': slug,
|
||||
'segment': segment,
|
||||
'live': False,
|
||||
'canonical_page': self,
|
||||
'is_segmented': True,
|
||||
}
|
||||
|
||||
return self.copy(update_attrs=update_attrs, copy_revisions=False)
|
||||
with transaction.atomic():
|
||||
new_page = self.canonical_page.copy(
|
||||
update_attrs=update_attrs, copy_revisions=False)
|
||||
|
||||
def variants_for_segments(self, segments):
|
||||
return self.__class__.objects.filter(
|
||||
canonical_page=self, segment__in=segments)
|
||||
PersonalisablePageMetadata.objects.create(
|
||||
canonical_page=page,
|
||||
variant=new_page,
|
||||
segment=segment)
|
||||
return new_page
|
||||
|
||||
def metadata_for_segments(self, segments):
|
||||
return (
|
||||
self.__class__.objects
|
||||
.filter(
|
||||
canonical_page_id=self.canonical_page_id,
|
||||
segment__in=segments))
|
||||
|
||||
def get_unused_segments(self):
|
||||
if self.is_canonical:
|
||||
return (
|
||||
Segment.objects
|
||||
.exclude(page_metadata__canonical_page_id=self.canonical_page_id))
|
||||
return Segment.objects.none()
|
||||
|
||||
|
||||
@cached_classmethod
|
||||
def get_edit_handler(cls):
|
||||
"""Add additional edit handlers to pages that are allowed to have
|
||||
variations.
|
||||
class PersonalisablePageMixin:
|
||||
"""The personalisable page model. Allows creation of variants with linked
|
||||
segments.
|
||||
|
||||
"""
|
||||
tabs = []
|
||||
if cls.content_panels:
|
||||
tabs.append(ObjectList(cls.content_panels, heading=_("Content")))
|
||||
if cls.variation_panels:
|
||||
tabs.append(ObjectList(cls.variation_panels, heading=_("Variations")))
|
||||
if cls.promote_panels:
|
||||
tabs.append(ObjectList(cls.promote_panels, heading=_("Promote")))
|
||||
if cls.settings_panels:
|
||||
tabs.append(ObjectList(cls.settings_panels, heading=_("Settings"),
|
||||
classname='settings'))
|
||||
|
||||
edit_handler = TabbedInterface(tabs, base_form_class=cls.base_form_class)
|
||||
return edit_handler.bind_to_model(cls)
|
||||
|
||||
|
||||
PersonalisablePageMixin.get_edit_handler = get_edit_handler
|
||||
@cached_property
|
||||
def personalisation_metadata(self):
|
||||
try:
|
||||
metadata = self._personalisable_page_metadata
|
||||
except AttributeError:
|
||||
metadata = PersonalisablePageMetadata.objects.create(
|
||||
canonical_page=self, variant=self)
|
||||
return metadata
|
||||
|
@ -7,16 +7,16 @@ from wagtail_personalisation.models import Segment
|
||||
def check_status_change(sender, instance, *args, **kwargs):
|
||||
"""Check if the status has changed. Alter dates accordingly."""
|
||||
try:
|
||||
original_status = sender.objects.get(pk=instance.id).status
|
||||
original_status = sender.objects.get(pk=instance.id).enabled
|
||||
except sender.DoesNotExist:
|
||||
original_status = ""
|
||||
original_status = None
|
||||
|
||||
if original_status != instance.status:
|
||||
if instance.status == instance.STATUS_ENABLED:
|
||||
if original_status != instance.enabled:
|
||||
if instance.enabled is True:
|
||||
instance.enable_date = timezone.now()
|
||||
instance.visit_count = 0
|
||||
return instance
|
||||
if instance.status == instance.STATUS_DISABLED:
|
||||
if instance.enabled is False:
|
||||
instance.disable_date = timezone.now()
|
||||
|
||||
|
||||
|
@ -2,31 +2,42 @@ from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from importlib import import_module
|
||||
|
||||
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 python_2_unicode_compatible
|
||||
from django.test.client import RequestFactory
|
||||
from django.utils.encoding import force_text, python_2_unicode_compatible
|
||||
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)
|
||||
|
||||
SessionStore = import_module(settings.SESSION_ENGINE).SessionStore
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class AbstractBaseRule(models.Model):
|
||||
"""Base for creating rules to segment users with."""
|
||||
icon = 'fa-circle-o'
|
||||
static = False
|
||||
|
||||
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:
|
||||
abstract = True
|
||||
verbose_name = 'Abstract segmentation rule'
|
||||
|
||||
def __str__(self):
|
||||
return _('Abstract segmentation rule')
|
||||
return force_text(self._meta.verbose_name)
|
||||
|
||||
def test_user(self):
|
||||
"""Test if the user matches this rule."""
|
||||
@ -34,7 +45,7 @@ class AbstractBaseRule(models.Model):
|
||||
|
||||
def encoded_name(self):
|
||||
"""Return a string with a slug for the rule."""
|
||||
return slugify(self.__str__().lower())
|
||||
return slugify(force_text(self).lower())
|
||||
|
||||
def description(self):
|
||||
"""Return a description explaining the functionality of the rule.
|
||||
@ -46,13 +57,17 @@ class AbstractBaseRule(models.Model):
|
||||
"""
|
||||
description = {
|
||||
'title': _('Abstract segmentation rule'),
|
||||
'value': _(''),
|
||||
'value': '',
|
||||
}
|
||||
|
||||
return description
|
||||
|
||||
@classmethod
|
||||
def get_descendant_models(cls):
|
||||
return [model for model in apps.get_models()
|
||||
if issubclass(model, AbstractBaseRule)]
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class TimeRule(AbstractBaseRule):
|
||||
"""Time rule to segment users based on a start and end time.
|
||||
|
||||
@ -60,6 +75,8 @@ class TimeRule(AbstractBaseRule):
|
||||
set start time and end time.
|
||||
|
||||
"""
|
||||
icon = 'fa-clock-o'
|
||||
|
||||
start_time = models.TimeField(_("Starting time"))
|
||||
end_time = models.TimeField(_("Ending time"))
|
||||
|
||||
@ -70,18 +87,14 @@ class TimeRule(AbstractBaseRule):
|
||||
]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return _('Time Rule')
|
||||
class Meta:
|
||||
verbose_name = _('Time Rule')
|
||||
|
||||
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
|
||||
return self.start_time <= datetime.now().time() <= self.end_time
|
||||
|
||||
def description(self):
|
||||
description = {
|
||||
return {
|
||||
'title': _('These users visit between'),
|
||||
'value': _('{} and {}').format(
|
||||
self.start_time.strftime("%H:%M"),
|
||||
@ -89,10 +102,7 @@ class TimeRule(AbstractBaseRule):
|
||||
),
|
||||
}
|
||||
|
||||
return description
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class DayRule(AbstractBaseRule):
|
||||
"""Day rule to segment users based on the day(s) of a visit.
|
||||
|
||||
@ -100,6 +110,8 @@ class DayRule(AbstractBaseRule):
|
||||
set in the rule.
|
||||
|
||||
"""
|
||||
icon = 'fa-calendar-check-o'
|
||||
|
||||
mon = models.BooleanField(_("Monday"), default=False)
|
||||
tue = models.BooleanField(_("Tuesday"), default=False)
|
||||
wed = models.BooleanField(_("Wednesday"), default=False)
|
||||
@ -118,39 +130,28 @@ class DayRule(AbstractBaseRule):
|
||||
FieldPanel('sun'),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return _('Day Rule')
|
||||
class Meta:
|
||||
verbose_name = _('Day Rule')
|
||||
|
||||
def test_user(self, request=None):
|
||||
current_day = datetime.today().weekday()
|
||||
|
||||
days = [self.mon, self.tue, self.wed, self.thu,
|
||||
self.fri, self.sat, self.sun]
|
||||
|
||||
return days[current_day]
|
||||
return [self.mon, self.tue, self.wed, self.thu,
|
||||
self.fri, self.sat, self.sun][datetime.today().weekday()]
|
||||
|
||||
def description(self):
|
||||
days = {
|
||||
'mon': self.mon, 'tue': self.tue, 'wed': self.wed,
|
||||
'thu': self.thu, 'fri': self.fri, 'sat': self.sat,
|
||||
'sun': self.sun
|
||||
}
|
||||
days = (
|
||||
('mon', self.mon), ('tue', self.tue), ('wed', self.wed),
|
||||
('thu', self.thu), ('fri', self.fri), ('sat', self.sat),
|
||||
('sun', self.sun),
|
||||
)
|
||||
|
||||
chosen_days = []
|
||||
chosen_days = [day_name for day_name, chosen in days if chosen]
|
||||
|
||||
for key, value in days.items():
|
||||
if days[key]:
|
||||
chosen_days.append(key)
|
||||
|
||||
description = {
|
||||
return {
|
||||
'title': _('These users visit on'),
|
||||
'value': (', '.join(_(day) for day in chosen_days)).title()
|
||||
'value': ", ".join([day for day in chosen_days]).title(),
|
||||
}
|
||||
|
||||
return description
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class ReferralRule(AbstractBaseRule):
|
||||
"""Referral rule to segment users based on a regex test.
|
||||
|
||||
@ -158,15 +159,17 @@ class ReferralRule(AbstractBaseRule):
|
||||
the set regex test.
|
||||
|
||||
"""
|
||||
icon = 'fa-globe'
|
||||
|
||||
regex_string = models.TextField(
|
||||
_("Regex string to match the referer with"))
|
||||
_("Regular expression to match the referrer"))
|
||||
|
||||
panels = [
|
||||
FieldPanel('regex_string'),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return _('Referral Rule')
|
||||
class Meta:
|
||||
verbose_name = _('Referral Rule')
|
||||
|
||||
def test_user(self, request):
|
||||
pattern = re.compile(self.regex_string)
|
||||
@ -178,18 +181,13 @@ class ReferralRule(AbstractBaseRule):
|
||||
return False
|
||||
|
||||
def description(self):
|
||||
description = {
|
||||
return {
|
||||
'title': _('These visits originate from'),
|
||||
'value': _('{}').format(
|
||||
self.regex_string
|
||||
),
|
||||
'value': self.regex_string,
|
||||
'code': True
|
||||
}
|
||||
|
||||
return description
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class VisitCountRule(AbstractBaseRule):
|
||||
"""Visit count rule to segment users based on amount of visits to a
|
||||
specified page.
|
||||
@ -198,6 +196,9 @@ class VisitCountRule(AbstractBaseRule):
|
||||
when visiting the set page.
|
||||
|
||||
"""
|
||||
icon = 'fa-calculator'
|
||||
static = True
|
||||
|
||||
OPERATOR_CHOICES = (
|
||||
('more_than', _("More than")),
|
||||
('less_than', _("Less than")),
|
||||
@ -222,16 +223,49 @@ class VisitCountRule(AbstractBaseRule):
|
||||
]),
|
||||
]
|
||||
|
||||
def test_user(self, request):
|
||||
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):
|
||||
# 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()
|
||||
visit_count = adapter.get_visit_count(self.counted_page)
|
||||
if visit_count and operator == "more_than":
|
||||
if visit_count > segment_count:
|
||||
return True
|
||||
@ -243,11 +277,8 @@ class VisitCountRule(AbstractBaseRule):
|
||||
return True
|
||||
return False
|
||||
|
||||
def __str__(self):
|
||||
return _('Visit count Rule')
|
||||
|
||||
def description(self):
|
||||
description = {
|
||||
return {
|
||||
'title': _('These users visited {}').format(
|
||||
self.counted_page
|
||||
),
|
||||
@ -257,10 +288,29 @@ class VisitCountRule(AbstractBaseRule):
|
||||
),
|
||||
}
|
||||
|
||||
return description
|
||||
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)
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class QueryRule(AbstractBaseRule):
|
||||
"""Query rule to segment users based on matching queries.
|
||||
|
||||
@ -268,6 +318,8 @@ class QueryRule(AbstractBaseRule):
|
||||
present in the request query.
|
||||
|
||||
"""
|
||||
icon = 'fa-link'
|
||||
|
||||
parameter = models.SlugField(_("The query parameter to search for"),
|
||||
max_length=20)
|
||||
value = models.SlugField(_("The value of the parameter to match"),
|
||||
@ -278,19 +330,15 @@ class QueryRule(AbstractBaseRule):
|
||||
FieldPanel('value'),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return _('Query Rule')
|
||||
class Meta:
|
||||
verbose_name = _('Query Rule')
|
||||
|
||||
def test_user(self, request):
|
||||
parameter = self.parameter
|
||||
value = self.value
|
||||
|
||||
req_value = request.GET.get(parameter, '')
|
||||
return req_value == value
|
||||
return request.GET.get(self.parameter, '') == self.value
|
||||
|
||||
def description(self):
|
||||
description = {
|
||||
'title': _('These users used a url with the query'),
|
||||
return {
|
||||
'title': _('These users used a URL with the query'),
|
||||
'value': _('?{}={}').format(
|
||||
self.parameter,
|
||||
self.value
|
||||
@ -298,10 +346,7 @@ class QueryRule(AbstractBaseRule):
|
||||
'code': True
|
||||
}
|
||||
|
||||
return description
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class DeviceRule(AbstractBaseRule):
|
||||
"""Device rule to segment users based on matching devices.
|
||||
|
||||
@ -309,6 +354,8 @@ class DeviceRule(AbstractBaseRule):
|
||||
in the request user agent headers.
|
||||
|
||||
"""
|
||||
icon = 'fa-tablet'
|
||||
|
||||
mobile = models.BooleanField(_("Mobile phone"), default=False)
|
||||
tablet = models.BooleanField(_("Tablet"), default=False)
|
||||
desktop = models.BooleanField(_("Desktop"), default=False)
|
||||
@ -319,8 +366,8 @@ class DeviceRule(AbstractBaseRule):
|
||||
FieldPanel('desktop'),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return _('Device Rule')
|
||||
class Meta:
|
||||
verbose_name = _('Device Rule')
|
||||
|
||||
def test_user(self, request=None):
|
||||
ua_header = request.META['HTTP_USER_AGENT']
|
||||
@ -328,15 +375,13 @@ class DeviceRule(AbstractBaseRule):
|
||||
|
||||
if user_agent.is_mobile:
|
||||
return self.mobile
|
||||
elif user_agent.is_tablet:
|
||||
if user_agent.is_tablet:
|
||||
return self.tablet
|
||||
elif user_agent.is_pc:
|
||||
if user_agent.is_pc:
|
||||
return self.desktop
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class UserIsLoggedInRule(AbstractBaseRule):
|
||||
"""User is logged in rule to segment users based on their authentication
|
||||
status.
|
||||
@ -344,28 +389,22 @@ class UserIsLoggedInRule(AbstractBaseRule):
|
||||
Matches when the user is authenticated.
|
||||
|
||||
"""
|
||||
icon = 'fa-user'
|
||||
|
||||
is_logged_in = models.BooleanField(default=False)
|
||||
|
||||
panels = [
|
||||
FieldPanel('is_logged_in'),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return _('Logged In Rule')
|
||||
class Meta:
|
||||
verbose_name = _('Logged in Rule')
|
||||
|
||||
def test_user(self, request=None):
|
||||
return request.user.is_authenticated() == self.is_logged_in
|
||||
|
||||
def description(self):
|
||||
status = _('Logged in')
|
||||
if self.is_logged_in is False:
|
||||
status = _('Not logged in')
|
||||
|
||||
description = {
|
||||
return {
|
||||
'title': _('These visitors are'),
|
||||
'value': _('{}').format(
|
||||
status
|
||||
),
|
||||
'value': _('Logged in') if self.is_logged_in else _('Not logged in'),
|
||||
}
|
||||
|
||||
return description
|
||||
|
@ -1,2 +1,2 @@
|
||||
.nice-padding{padding-left:50px;padding-right:50px}.block_container{display:block;margin-top:30px}.block_container .block{display:block;float:left;-webkit-box-sizing:border-box;box-sizing:border-box;position:relative;width:calc(50% - 10px);min-height:216px;padding:10px 20px;margin-bottom:20px;border:1px solid #d9d9d9;border-radius:3px;background-color:#fff;-webkit-box-shadow:0 1px 3px transparent,0 1px 2px transparent;box-shadow:0 1px 3px transparent,0 1px 2px transparent;-webkit-transition:border .3s cubic-bezier(.25,.8,.25,1),-webkit-box-shadow .3s cubic-bezier(.25,.8,.25,1);transition:border .3s cubic-bezier(.25,.8,.25,1),-webkit-box-shadow .3s cubic-bezier(.25,.8,.25,1);transition:box-shadow .3s cubic-bezier(.25,.8,.25,1),border .3s cubic-bezier(.25,.8,.25,1);transition:box-shadow .3s cubic-bezier(.25,.8,.25,1),border .3s cubic-bezier(.25,.8,.25,1),-webkit-box-shadow .3s cubic-bezier(.25,.8,.25,1);cursor:pointer}.block_container .block--disabled .inspect_container,.block_container .block--disabled h2{opacity:.5}.block_container .block h2{display:inline-block;width:auto}.block_container .block:nth-child(odd){margin-right:20px}.block_container .block .block_actions{list-style:none;margin:0;padding:0}.block_container .block .block_actions li{float:left;margin-right:10px}.block_container .block .block_actions li:last-child{margin-right:0}.block_container .block.suggestion{border:1px dashed #d9d9d9}.block_container .block:hover{border:1px solid #fff;-webkit-box-shadow:0 3px 6px rgba(0,0,0,.16),0 3px 6px rgba(0,0,0,.23);box-shadow:0 3px 6px rgba(0,0,0,.16),0 3px 6px rgba(0,0,0,.23)}@media (max-width:699px){.block_container .block{width:100%;margin-right:0}}.block_container .block .inspect_container{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row;-ms-flex-wrap:nowrap;flex-wrap:nowrap;-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between;-webkit-box-align:stretch;-ms-flex-align:stretch;align-items:stretch;margin-bottom:10px}.block_container .block .inspect_container .inspect{display:block;float:left;width:calc(50% - 10px);padding:0;margin:0;list-style:none}.block_container .block .inspect_container .inspect li{display:inline-block;margin-bottom:5px}.block_container .block .inspect_container .inspect li span{display:block;font-size:20px;font-weight:700;margin:5px 0;overflow-wrap:break-word}.block_container .block .inspect_container .inspect li span:before{display:inline-block;content:"";width:16px;height:16px;margin-right:5px;background-size:contain;background-image:url(../img/ruler_icon.png)}.block_container .block .inspect_container .segment_stats .visit_stat span:before{background-image:url(../img/rocket_icon.png)}.block_container .block .inspect_container .segment_stats .days_stat span:before{background-image:url(../img/calendar_icon.png)}.block_container .block .inspect_container .segment_rules .persistent_state span:before{background-image:url(../img/persistent_icon.png)}.block_container .block .inspect_container .segment_rules .persistent_state.fleeting span:before{-webkit-transform:rotate(45deg) translateY(-2px);transform:rotate(45deg) translateY(-2px)}.block_container .block .inspect_container .segment_rules .time-rule span:before{background-image:url(../img/time_icon.png)}.block_container .block .inspect_container .segment_rules .visit-count-rule span:before{background-image:url(../img/visit_count_icon.png)}.block_container .block .inspect_container .segment_rules .logged-in-rule span:before{background-image:url(../img/key_icon.png)}.block_container .block .inspect_container .segment_rules .day-rule span:before{background-image:url(../img/calendar_icon.png)}.block_container .block .inspect_container .inspect li pre{position:relative;-webkit-box-sizing:border-box;box-sizing:border-box;width:auto;background-color:#eee;border:1px solid #ccc;margin:5px 0 5px 21px;padding:2px 5px;word-wrap:break-word;word-break:break-all;border-radius:3px}.block_container .block .inspect_container .inspect li pre:before{display:inline-block;position:absolute;content:"";left:-21px;top:6px;width:16px;height:16px;margin-right:5px;background-size:contain;background-image:url(../img/ruler_icon.png)}.block_container .block .inspect_container .segment_rules .referral-rule pre:before{background-image:url(../img/referral_icon.png)}.block_container .block .inspect_container .segment_rules .query-rule pre:before{background-image:url(../img/referral_icon.png)}.block_container .block.suggestion .suggestive_text{display:block;position:absolute;width:calc(100% - 40px);text-align:center;top:50%;-webkit-transform:translateY(-50%);transform:translateY(-50%);color:#d9d9d9;font-size:20px;font-weight:100}
|
||||
.nice-padding{padding-left:50px;padding-right:50px}.block_container{display:block;margin-top:30px}.block_container .block{display:block;float:left;-webkit-box-sizing:border-box;box-sizing:border-box;position:relative;width:calc(50% - 10px);min-height:216px;padding:10px 20px;margin-bottom:20px;border:1px solid #d9d9d9;border-radius:3px;background-color:#fff;-webkit-box-shadow:0 1px 3px transparent,0 1px 2px transparent;box-shadow:0 1px 3px transparent,0 1px 2px transparent;-webkit-transition:border .3s cubic-bezier(.25,.8,.25,1),-webkit-box-shadow .3s cubic-bezier(.25,.8,.25,1);transition:border .3s cubic-bezier(.25,.8,.25,1),-webkit-box-shadow .3s cubic-bezier(.25,.8,.25,1);transition:box-shadow .3s cubic-bezier(.25,.8,.25,1),border .3s cubic-bezier(.25,.8,.25,1);transition:box-shadow .3s cubic-bezier(.25,.8,.25,1),border .3s cubic-bezier(.25,.8,.25,1),-webkit-box-shadow .3s cubic-bezier(.25,.8,.25,1);cursor:pointer}.block_container .block--disabled .inspect_container,.block_container .block--disabled h2{opacity:.5}.block_container .block h2{display:inline-block;width:auto}.block_container .block:nth-child(odd){margin-right:20px}.block_container .block .block_actions{list-style:none;margin:0;padding:0}.block_container .block .block_actions li{float:left;margin-right:10px}.block_container .block .block_actions li:last-child{margin-right:0}.block_container .block.suggestion{border:1px dashed #d9d9d9}.block_container .block:hover{border:1px solid #fff;-webkit-box-shadow:0 3px 6px rgba(0,0,0,.16),0 3px 6px rgba(0,0,0,.23);box-shadow:0 3px 6px rgba(0,0,0,.16),0 3px 6px rgba(0,0,0,.23)}@media (max-width:699px){.block_container .block{width:100%;margin-right:0}}.block_container .block .inspect_container{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row;-ms-flex-wrap:nowrap;flex-wrap:nowrap;-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between;-webkit-box-align:stretch;-ms-flex-align:stretch;align-items:stretch;margin-bottom:10px}.block_container .block .inspect_container .inspect{display:block;float:left;width:calc(50% - 10px);padding:0;margin:0;list-style:none}.block_container .block .inspect_container .inspect .stat_card{display:inline-block;margin-bottom:5px;margin-right:10px}.block_container .block span.icon:before{margin-right:.3em;vertical-align:bottom}.block_container .block .inspect_container .inspect li span{display:block;font-size:20px;font-weight:700;margin:5px 0;overflow-wrap:break-word}.block_container .block .inspect_container .inspect li pre{position:relative;-webkit-box-sizing:border-box;box-sizing:border-box;width:auto;background-color:#eee;border:1px solid #ccc;margin:5px 0 5px 21px;padding:2px 5px;word-wrap:break-word;word-break:break-all;border-radius:3px}.block_container .block.suggestion .suggestive_text{display:block;position:absolute;width:calc(100% - 40px);text-align:center;top:50%;-webkit-transform:translateY(-50%);transform:translateY(-50%);color:#d9d9d9;font-size:20px;font-weight:100}
|
||||
/*# sourceMappingURL=dashboard.css.map*/
|
@ -0,0 +1,20 @@
|
||||
(function($) {
|
||||
$(document).ready( () => {
|
||||
var count = $('.count_field');
|
||||
var typeRadio = $('input:radio[name="type"]');
|
||||
|
||||
var updateCountDispay = function(value) {
|
||||
if (value == 'dynamic') {
|
||||
count.slideUp(250);
|
||||
} else {
|
||||
count.slideDown(250);
|
||||
}
|
||||
};
|
||||
|
||||
updateCountDispay(typeRadio.filter(':checked').val());
|
||||
|
||||
typeRadio.change( event => {
|
||||
updateCountDispay(event.target.value);
|
||||
});
|
||||
});
|
||||
})(jQuery);
|
@ -22,62 +22,91 @@
|
||||
<div class="nice-padding block_container">
|
||||
{% if all_count %}
|
||||
{% for segment in object_list %}
|
||||
<div class="block block--{{ segment.status }}" onclick="location.href = 'edit/{{ segment.pk }}'">
|
||||
<div class="block block--{{ segment.enabled|yesno:"enabled,disabled" }}" 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="visit_stat">
|
||||
{% trans "This segmented has been visited" %}
|
||||
<span>{{ segment.visit_count|localize }} {% trans "time" %}{{ segment.visit_count|pluralize }}</span>
|
||||
<li class="stat_card">
|
||||
{% trans "This segment has been visited" %}
|
||||
<span class="icon icon-fa-rocket">{{ segment.visit_count|localize }} {% trans "time" %}{{ segment.visit_count|pluralize }}</span>
|
||||
</li>
|
||||
<li class="days_stat">
|
||||
<li class="stat_card">
|
||||
{% trans "This segment has been active for" %}
|
||||
<span>{{ segment.enable_date|days_since:segment.disable_date }} {% trans "day" %}{{ segment.enable_date|days_since:segment.disable_date|pluralize }}</span>
|
||||
<span class="icon icon-fa-calendar">{{ segment.enable_date|days_since:segment.disable_date }} {% trans "day" %}{{ segment.enable_date|days_since:segment.disable_date|pluralize }}</span>
|
||||
</li>
|
||||
{% if segment.is_static %}
|
||||
<li class="stat_card">
|
||||
{% trans "This segment is Static" %}
|
||||
<span class="icon icon-fa-user">
|
||||
{{ segment.static_users.count|localize }}
|
||||
{% if segment.static_users.count < segment.count %}
|
||||
/ {{ segment.count }} {% trans "member" %}{{ segment.count|pluralize }}
|
||||
{% else %}
|
||||
{% trans "member" %}{{ segment.count|pluralize }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
<hr />
|
||||
|
||||
<ul class="inspect segment_rules">
|
||||
<li class="match_state {{ segment.match_any|yesno:"any,all" }}">
|
||||
<li class="stat_card {{ segment.match_any|yesno:"any,all" }}">
|
||||
{% trans "The visitor must match" %}
|
||||
{% if segment.match_any %}
|
||||
<span>{% trans "Any rule" %}</span>
|
||||
<span class="icon icon-fa-cube">{% trans "Any rule" %}</span>
|
||||
{% else %}
|
||||
<span>{% trans "All rules" %}</span>
|
||||
<span class="icon icon-fa-cubes">{% trans "All rules" %}</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
|
||||
<li class="persistent_state {{ segment.persistent|yesno:"persistent,fleeting" }}">
|
||||
<li class="stat_card {{ segment.persistent|yesno:"persistent,fleeting" }}">
|
||||
{% trans "The persistence of this segment is" %}
|
||||
{% if segment.persistent %}
|
||||
<span title="{% trans "This segment persists in between visits" %}">{% trans "Persistent" %}</span>
|
||||
<span class="icon icon-fa-bookmark" title="{% trans "This segment persists in between visits" %}">{% trans "Persistent" %}</span>
|
||||
{% else %}
|
||||
<span title="{% trans "This segment is reevaluated on every visit" %}">{% trans "Fleeting" %}</span>
|
||||
<span class="icon icon-fa-bookmark-o" title="{% trans "This segment is reevaluated on every visit" %}">{% trans "Fleeting" %}</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
|
||||
{% if segment.randomisation_percent is not None %}
|
||||
<li class="stat_card">
|
||||
<span>{{ segment.randomisation_percent }} %</span>
|
||||
{% trans "Chance that visitors matching the rules are added to the segment" %}
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% for rule in segment.get_rules %}
|
||||
<li class="{{ rule.encoded_name }}">
|
||||
<li class="stat_card {{ rule.encoded_name }}">
|
||||
{{ rule.description.title }}
|
||||
{% if rule.description.code %}
|
||||
<pre>{{ rule.description.value }}</pre>
|
||||
{% else %}
|
||||
<span>{{ rule.description.value }}</span>
|
||||
<span class="icon icon-{{ rule.icon }}">{{ rule.description.value }}</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% if segment.matched_users_count > 0 %}
|
||||
<li class="stat_card">
|
||||
<span class="icon icon-fa-user"> {{ segment.matched_users_count }} {% trans "user" %}{{ segment.matched_users_count|pluralize }}</span> {% trans "were possible matches for this segment at creation" %}
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{% if user_can_create %}
|
||||
<ul class="block_actions">
|
||||
{% if segment.status == segment.STATUS_DISABLED %}
|
||||
<li><a href="{% url 'segment:toggle' segment.pk %}" title="{% trans "Enable this segment" %}">enable</a></li>
|
||||
{% elif segment.status == segment.STATUS_ENABLED %}
|
||||
{% if segment.enabled %}
|
||||
<li><a href="{% url 'segment:toggle' segment.pk %}" title="{% trans "Disable this segment" %}">disable</a></li>
|
||||
{% else %}
|
||||
<li><a href="{% url 'segment:toggle' segment.pk %}" title="{% trans "Enable this segment" %}">enable</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 %}
|
||||
<li><a href="edit/{{ segment.pk }}" title="{% trans "Configure this segment" %}">configure this</a></li>
|
||||
</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 %}
|
@ -3,57 +3,3 @@
|
||||
|
||||
{% block toggle_view %}to Dashboard {% endblock%}
|
||||
|
||||
{% block content_main %}
|
||||
<div>
|
||||
<div class="row">
|
||||
{% block content_cols %}
|
||||
|
||||
{% block filters %}
|
||||
{% if view.has_filters and all_count %}
|
||||
<div class="changelist-filter col3">
|
||||
<h2>{% trans 'Filter' %}</h2>
|
||||
{% for spec in view.filter_specs %}{% admin_list_filter view spec %}{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
<div class="result-list {% if view.has_filters and all_count %}col9{% else %}col12{% endif %}">
|
||||
{% block result_list %}
|
||||
{% if not all_count %}
|
||||
<div class="nice-padding" style="margin-top:30px;">
|
||||
{% if no_valid_parents %}
|
||||
<p>{% blocktrans with view.verbose_name_plural as name %}No {{ name }} have been created yet. One of the following must be created before you can add any {{ name }}:{% endblocktrans %}</p>
|
||||
<ul>
|
||||
{% for type in required_parent_types %}<li><b>{{ type|title }}</b></li>{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>{% blocktrans with view.verbose_name_plural as name %}No {{ name }} have been created yet.{% endblocktrans %}
|
||||
{% if user_can_create %}
|
||||
{% blocktrans with view.create_url as url %}
|
||||
Why not <a href="{{ url }}">add one</a>?
|
||||
{% endblocktrans %}
|
||||
{% endif %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% result_list %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
||||
{% block pagination %}
|
||||
<div class="pagination {% if view.has_filters and all_count %}col9{% else %}col12{% endif %}">
|
||||
<p>{% blocktrans with page_obj.number as current_page and paginator.num_pages as num_pages %}Page {{ current_page }} of {{ num_pages }}.{% endblocktrans %}</p>
|
||||
{% if paginator.num_pages > 1 %}
|
||||
<ul>
|
||||
{% pagination_link_previous page_obj view %}
|
||||
{% pagination_link_next page_obj view %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% endblock %}
|
||||
</div>
|
||||
</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 %}
|
@ -16,7 +16,7 @@ def do_segment(parser, token):
|
||||
|
||||
# If no segment is provided this block will raise an error
|
||||
if set(kwargs.keys()) != {'name'}:
|
||||
usage = '{% {tag_name} name="segmentname" %} ... {% end{tag_name} %}'.format(tag_name=tag_name)
|
||||
usage = '{% segment name="segmentname" %} ... {% endsegment %}'
|
||||
raise TemplateSyntaxError("Usage: %s" % usage)
|
||||
|
||||
nodelist = parser.parse(('endsegment',))
|
||||
@ -43,7 +43,7 @@ class SegmentNode(template.Node):
|
||||
def render(self, context):
|
||||
# Check if segment exists
|
||||
name = self.name.resolve(context)
|
||||
segment = Segment.objects.filter(name=name).first()
|
||||
segment = Segment.objects.enabled().filter(name=name).first()
|
||||
if not segment:
|
||||
return ""
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
import time
|
||||
|
||||
from django.db.models import F
|
||||
from django.template.base import FilterExpression, kwarg_re
|
||||
from django.utils import timezone
|
||||
|
||||
@ -93,3 +94,25 @@ def parse_tag(token, parser):
|
||||
args.append(FilterExpression(bit, parser))
|
||||
|
||||
return (tag_name, args, kwargs)
|
||||
|
||||
|
||||
def exclude_variants(pages):
|
||||
"""Checks if page is not a variant
|
||||
|
||||
:param pages: Set of pages to check
|
||||
:type pages: QuerySet
|
||||
:return: Queryset of pages that aren't variants
|
||||
:rtype: QuerySet
|
||||
"""
|
||||
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
|
||||
|
@ -1,18 +1,21 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from django import forms
|
||||
import csv
|
||||
|
||||
from django.http import HttpResponseForbidden, HttpResponseRedirect, HttpResponseBadRequest, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, reverse
|
||||
from django.views.decorators.cache import never_cache
|
||||
from django.views.decorators.http import require_POST
|
||||
from django import forms
|
||||
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.adapters import get_segment_adapter
|
||||
from wagtail_personalisation.models import Segment
|
||||
from wagtail_personalisation.utils import can_delete_pages
|
||||
|
||||
|
||||
class SegmentModelIndexView(IndexView):
|
||||
@ -35,15 +38,53 @@ 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
|
||||
menu_icon = 'group'
|
||||
delete_view_class = SegmentModelDeleteView
|
||||
menu_icon = 'fa-snowflake-o'
|
||||
add_to_settings_menu = False
|
||||
list_display = ('name', 'visits', 'active_days', 'status')
|
||||
list_display = ('name', 'persistent', 'match_any', 'enabled',
|
||||
'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']
|
||||
@ -59,11 +100,15 @@ class SegmentModelAdmin(ModelAdmin):
|
||||
|
||||
return view_class.as_view(**kwargs)(request)
|
||||
|
||||
def visits(self, obj):
|
||||
return _("{visits} visits").format(visits=obj.visit_count)
|
||||
def page_count(self, obj):
|
||||
return len(obj.get_used_pages())
|
||||
|
||||
def active_days(self, obj):
|
||||
return _("{days} days").format(days=obj.get_active_days())
|
||||
def variant_count(self, obj):
|
||||
return len(obj.get_created_variants())
|
||||
|
||||
def statistics(self, obj):
|
||||
return _("{visits} visits in {days} days").format(
|
||||
visits=obj.visit_count, days=obj.get_active_days())
|
||||
|
||||
|
||||
def toggle_segment_view(request):
|
||||
@ -101,12 +146,7 @@ def toggle(request, segment_id):
|
||||
if request.user.has_perm('wagtailadmin.access_admin'):
|
||||
segment = get_object_or_404(Segment, pk=segment_id)
|
||||
|
||||
if segment.status == Segment.STATUS_ENABLED:
|
||||
segment.status = Segment.STATUS_DISABLED
|
||||
elif segment.status == Segment.STATUS_DISABLED:
|
||||
segment.status = Segment.STATUS_ENABLED
|
||||
|
||||
segment.save()
|
||||
segment.toggle()
|
||||
|
||||
return HttpResponseRedirect(request.META.get('HTTP_REFERER', '/'))
|
||||
|
||||
@ -130,11 +170,13 @@ def copy_page_view(request, page_id, segment_id):
|
||||
if request.user.has_perm('wagtailadmin.access_admin'):
|
||||
segment = get_object_or_404(Segment, pk=segment_id)
|
||||
page = get_object_or_404(Page, pk=page_id).specific
|
||||
variants = page.variants_for_segments([segment])
|
||||
if variants.exists():
|
||||
variant = variants.first()
|
||||
|
||||
metadata = page.personalisation_metadata
|
||||
variant_metadata = metadata.metadata_for_segments([segment])
|
||||
if variant_metadata.exists():
|
||||
variant = variant_metadata.first()
|
||||
else:
|
||||
variant = page.copy_for_segment(segment)
|
||||
variant = metadata.copy_for_segment(segment)
|
||||
edit_url = reverse('wagtailadmin_pages:edit', args=[variant.id])
|
||||
|
||||
return HttpResponseRedirect(edit_url)
|
||||
@ -142,34 +184,30 @@ def copy_page_view(request, page_id, segment_id):
|
||||
return HttpResponseForbidden()
|
||||
|
||||
|
||||
@never_cache
|
||||
@require_POST
|
||||
def visit_page(request):
|
||||
"""Allows a frontend user to submit a page view and retrieve their current
|
||||
segments on a site that is behind a cache or CDN.
|
||||
# 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)
|
||||
|
||||
On each page view, the user must POST to this view the page ID and path
|
||||
that they are browsing. It will return a JSON-formatted document containing
|
||||
a list of segments that are currently active for them.
|
||||
"""
|
||||
segment_adapter = get_segment_adapter(request)
|
||||
page_id = request.POST.get('page_id')
|
||||
path = request.POST.get('path')
|
||||
response = HttpResponse(content_type='text/csv; charset=utf-8')
|
||||
response['Content-Disposition'] = \
|
||||
'attachment;filename=segment-%s-users.csv' % str(segment_id)
|
||||
|
||||
if page_id is None or path is None:
|
||||
return HttpResponseBadRequest()
|
||||
headers = ['Username']
|
||||
for rule in segment.get_rules():
|
||||
if rule.static:
|
||||
headers.append(rule.get_column_header())
|
||||
|
||||
page = Page.objects.filter(id=page_id).only('id', 'slug', 'live').first()
|
||||
writer = csv.writer(response)
|
||||
writer.writerow(headers)
|
||||
|
||||
if page is None or page.live is False:
|
||||
return HttpResponseBadRequest()
|
||||
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)
|
||||
|
||||
segment_adapter.add_page_visit(page, path=path)
|
||||
segment_adapter.refresh()
|
||||
return response
|
||||
|
||||
return JsonResponse({
|
||||
'segments': [
|
||||
segment['encoded_name']
|
||||
for segment in segment_adapter.get_segments()
|
||||
]
|
||||
})
|
||||
return HttpResponseForbidden()
|
||||
|
@ -3,18 +3,21 @@ from __future__ import absolute_import, unicode_literals
|
||||
import logging
|
||||
|
||||
from django.conf.urls import include, url
|
||||
from django.shortcuts import reverse
|
||||
from django.db import transaction
|
||||
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 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
|
||||
from wagtail.admin.views.pages import get_valid_next_url_from_request
|
||||
from wagtail.admin.widgets import Button, ButtonWithDropdownFromHook
|
||||
from wagtail.core import hooks
|
||||
from wagtail.core.models import Page
|
||||
|
||||
from wagtail_personalisation import admin_urls
|
||||
from wagtail_personalisation import admin_urls, models, utils
|
||||
from wagtail_personalisation.adapters import get_segment_adapter
|
||||
from wagtail_personalisation.models import PersonalisablePageMixin, Segment
|
||||
from wagtail_personalisation.utils import impersonate_other_page
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -24,9 +27,7 @@ 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')),
|
||||
admin_urls, namespace='wagtail_personalisation')),
|
||||
]
|
||||
|
||||
|
||||
@ -36,7 +37,7 @@ def set_visit_count(page, request, serve_args, serve_kwargs):
|
||||
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
|
||||
|
||||
@ -50,7 +51,7 @@ 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
|
||||
|
||||
@ -58,49 +59,64 @@ 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])
|
||||
|
||||
|
||||
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_variation(page, request, serve_args, serve_kwargs):
|
||||
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 variation if one is available for the visitor's segment,
|
||||
: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 = []
|
||||
if not isinstance(page, models.PersonalisablePageMixin):
|
||||
return
|
||||
|
||||
adapter = get_segment_adapter(request)
|
||||
user_segments = adapter.get_segments()
|
||||
|
||||
if len(user_segments) > 0:
|
||||
variations = _check_for_variations(user_segments, page)
|
||||
if user_segments:
|
||||
metadata = page.personalisation_metadata
|
||||
|
||||
if variations:
|
||||
variation = variations[0]
|
||||
|
||||
impersonate_other_page(variation, page)
|
||||
|
||||
return variation.serve(request, *serve_args, **serve_kwargs)
|
||||
# TODO: This is never more then one page? (fix query count)
|
||||
metadata = metadata.metadata_for_segments(user_segments)
|
||||
if metadata:
|
||||
variant = metadata.first().variant.specific
|
||||
return variant.serve(request, *serve_args, **serve_kwargs)
|
||||
|
||||
|
||||
def _check_for_variations(segments, page):
|
||||
"""Check whether there are variations available for the provided segments
|
||||
on the page being served.
|
||||
|
||||
:param segments: The segments applicable to the request.
|
||||
:type segments: list of wagtail_personalisation.models.Segment
|
||||
:param page: The page being served
|
||||
:type page: wagtail_personalisation.models.PersonalisablePage or
|
||||
wagtail.wagtailcore.models.Page
|
||||
:returns: A variant of the requested page matching the segments or None
|
||||
:rtype: wagtail_personalisation.models.PersonalisablePage or None
|
||||
|
||||
"""
|
||||
return page.variants_for_segments(segments)
|
||||
@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')
|
||||
@ -109,21 +125,18 @@ def page_listing_variant_buttons(page, page_perms, is_parent=False):
|
||||
the page (if any) and a 'Create a new variant' button.
|
||||
|
||||
"""
|
||||
|
||||
if not hasattr(page, 'segment'):
|
||||
if not isinstance(page, models.PersonalisablePageMixin):
|
||||
return
|
||||
pages = page.__class__.objects.filter(pk=page.pk)
|
||||
segments = Segment.objects.all()
|
||||
|
||||
if pages and len(segments) > 0 and not (
|
||||
any(item.segment for item in pages)):
|
||||
metadata = page.personalisation_metadata
|
||||
if metadata.is_canonical:
|
||||
yield ButtonWithDropdownFromHook(
|
||||
_('Variants'),
|
||||
hook_name='register_page_listing_variant_buttons',
|
||||
page=page,
|
||||
page_perms=page_perms,
|
||||
is_parent=is_parent,
|
||||
attrs={'target': '_blank', 'title': _('Create a new variant')},
|
||||
attrs={'target': '_blank', 'title': _('Create or edit a variant')},
|
||||
priority=100)
|
||||
|
||||
|
||||
@ -133,17 +146,59 @@ def page_listing_more_buttons(page, page_perms, is_parent=False):
|
||||
create a new variant for the selected segment.
|
||||
|
||||
"""
|
||||
model = page.__class__
|
||||
segments = Segment.objects.all()
|
||||
available_segments = [
|
||||
item for item in segments
|
||||
if not model.objects.filter(segment=item, pk=page.pk)
|
||||
]
|
||||
if not isinstance(page, models.PersonalisablePageMixin):
|
||||
return
|
||||
|
||||
for segment in available_segments:
|
||||
yield Button(segment.name,
|
||||
reverse('segment:copy_page', args=[page.id, segment.id]),
|
||||
attrs={"title": _('Create this variant')})
|
||||
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)
|
||||
|
||||
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(_('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 CorrectedPagesSummaryItem(PagesSummaryItem):
|
||||
def get_context(self):
|
||||
# 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()
|
||||
|
||||
if root_page.is_root():
|
||||
page_count -= 1
|
||||
|
||||
context['total_pages'] = page_count
|
||||
|
||||
return context
|
||||
|
||||
|
||||
@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] = CorrectedPagesSummaryItem(request)
|
||||
|
||||
|
||||
class SegmentSummaryPanel(SummaryItem):
|
||||
@ -151,22 +206,102 @@ class SegmentSummaryPanel(SummaryItem):
|
||||
site and allowing quick access to the Segment dashboard.
|
||||
|
||||
"""
|
||||
order = 500
|
||||
order = 2000
|
||||
|
||||
def render(self):
|
||||
segment_count = Segment.objects.count()
|
||||
segment_count = models.Segment.objects.count()
|
||||
target_url = reverse('wagtail_personalisation_segment_modeladmin_index')
|
||||
title = _("Segments")
|
||||
return mark_safe("""
|
||||
<li class="icon icon-group">
|
||||
<li class="icon icon-fa-snowflake-o">
|
||||
<a href="{}"><span>{}</span>{}</a>
|
||||
</li>""".format(target_url, segment_count, title))
|
||||
|
||||
|
||||
class PersonalisedPagesSummaryPanel(PagesSummaryItem):
|
||||
order = 2100
|
||||
|
||||
def render(self):
|
||||
page_count = models.PersonalisablePageMetadata.objects.filter(
|
||||
segment__isnull=True).count()
|
||||
title = _("Personalised Page")
|
||||
return mark_safe("""
|
||||
<li class="icon icon-fa-file-o">
|
||||
<span>{}</span>{}{}
|
||||
</li>""".format(page_count, title, pluralize(page_count)))
|
||||
|
||||
|
||||
class VariantPagesSummaryPanel(PagesSummaryItem):
|
||||
order = 2200
|
||||
|
||||
def render(self):
|
||||
page_count = models.PersonalisablePageMetadata.objects.filter(
|
||||
segment__isnull=False).count()
|
||||
title = _("Variant")
|
||||
return mark_safe("""
|
||||
<li class="icon icon-fa-files-o">
|
||||
<span>{}</span>{}{}
|
||||
</li>""".format(page_count, title, pluralize(page_count)))
|
||||
|
||||
|
||||
@hooks.register('construct_homepage_summary_items')
|
||||
def add_segment_summary_panel(request, 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 dashboard.
|
||||
of segments on the site and allowing quick access to the Segment
|
||||
dashboard.
|
||||
|
||||
"""
|
||||
return items.append(SegmentSummaryPanel(request))
|
||||
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
|
||||
variants_metadata = variants_metadata.select_related('variant')
|
||||
with transaction.atomic():
|
||||
for metadata in variants_metadata.iterator():
|
||||
# Call delete() on objects to trigger any signals or hooks.
|
||||
metadata.variant.delete()
|
||||
# Delete the page's main variant and the page itself.
|
||||
page.personalisation_metadata.delete()
|
||||
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,7 +1,6 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import pytest
|
||||
from wagtail.wagtailcore.models import Page, Site
|
||||
|
||||
pytest_plugins = [
|
||||
'tests.fixtures'
|
||||
@ -10,7 +9,9 @@ pytest_plugins = [
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def django_db_setup(django_db_setup, django_db_blocker):
|
||||
from wagtail.core.models import Page, Site
|
||||
|
||||
with django_db_blocker.unblock():
|
||||
# Remove some initial data that is brought by the sandbox module
|
||||
# Remove some initial data that is brought by the tests.site module
|
||||
Site.objects.all().delete()
|
||||
Page.objects.all().exclude(depth=1).delete()
|
||||
|
@ -2,14 +2,23 @@ from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import factory
|
||||
from django.utils.text import slugify
|
||||
from wagtail_factories.factories import MP_NodeFactory
|
||||
from wagtail_factories.factories import PageFactory
|
||||
|
||||
from tests.sandbox.pages.models import HomePage
|
||||
from tests.site.pages import models
|
||||
|
||||
|
||||
class PageFactory(MP_NodeFactory):
|
||||
class ContentPageFactory(PageFactory):
|
||||
parent = None
|
||||
title = 'Test page'
|
||||
slug = factory.LazyAttribute(lambda obj: slugify(obj.title))
|
||||
|
||||
class Meta:
|
||||
model = HomePage
|
||||
model = models.ContentPage
|
||||
|
||||
|
||||
class RegularPageFactory(PageFactory):
|
||||
title = 'Regular page'
|
||||
slug = factory.LazyAttribute(lambda obj: slugify(obj.title))
|
||||
|
||||
class Meta:
|
||||
model = models.RegularPage
|
||||
|
@ -7,7 +7,7 @@ from wagtail_personalisation import models
|
||||
|
||||
class SegmentFactory(factory.DjangoModelFactory):
|
||||
name = 'TestSegment'
|
||||
status = models.Segment.STATUS_ENABLED
|
||||
enabled = True
|
||||
|
||||
class Meta:
|
||||
model = models.Segment
|
||||
|
@ -1,14 +1,14 @@
|
||||
import factory
|
||||
from wagtail.wagtailcore.models import Site
|
||||
from wagtail.core.models import Site
|
||||
|
||||
from tests.factories.page import PageFactory
|
||||
from tests.factories.page import ContentPageFactory
|
||||
|
||||
|
||||
class SiteFactory(factory.DjangoModelFactory):
|
||||
hostname = 'localhost'
|
||||
port = factory.Sequence(lambda n: 81 + n)
|
||||
site_name = 'Test site'
|
||||
root_page = factory.SubFactory(PageFactory, parent=None)
|
||||
root_page = factory.SubFactory(ContentPageFactory, parent=None)
|
||||
is_default_site = False
|
||||
|
||||
class Meta:
|
||||
|