7

Compare commits

...

83 Commits

Author SHA1 Message Date
7bf1bc3f19 Merge branch 'release/0.10.6' 2018-02-06 15:26:02 +02:00
4c60bcbe6b Version 0.10.6 2018-02-06 15:25:51 +02:00
ad4f75d471 Merge pull request #11 from praekeltfoundation/feature/SAS-93-use-randomisation-percentage
Use randomisation percentage
2018-02-06 14:51:56 +02:00
086168954d Test randomisation of static segments at creation 2018-02-05 12:30:12 +02:00
881090f2f9 Test randomisation for dynamic segments 2018-02-05 12:21:09 +02:00
d073c7d268 Randomise into static segments when they are created 2018-02-05 12:20:10 +02:00
7200b5b4c4 If a session passes segment rules randomise them into the segement 2018-02-05 12:19:36 +02:00
6f97c76958 Add method to randomise matching sessions into the segment 2018-02-05 12:18:22 +02:00
ecb4f928fb Merge pull request #10 from praekeltfoundation/feature/SAS-92-store-randomisation-percentage
Add randomisation percentage to segment model
2018-02-05 11:54:23 +02:00
29aa91477e Migrations 2018-02-02 10:15:20 +02:00
5c3acc6661 Display randomisation percentages on segment dashboard 2018-02-02 10:15:04 +02:00
602919d2d4 Test randomisation percentage added to segments 2018-02-02 10:14:18 +02:00
ae97118c3f Store randomisation percentage on segment model 2018-02-02 10:13:18 +02:00
51774b939e Version 0.10.5 2018-01-26 17:57:43 +02:00
908f85e295 Merge pull request #9 from praekeltfoundation/feature/SAS-86-display-record-counter-on-dash
Display record counter on segments in dash
2018-01-26 17:51:34 +02:00
99f9700ed0 Display record counter for active segments 2018-01-26 16:21:15 +02:00
7fa8ee1a46 Merge pull request #8 from praekeltfoundation/feature/SAS-85-only-count-active-frontend-users
Don't include staff and inactive users when counting matched users
2018-01-26 16:01:02 +02:00
5ad70d68f6 Don't include staff and inactive users when counting matched users 2018-01-26 15:38:26 +02:00
06bfe77901 Merge pull request #7 from praekeltfoundation/feature/SAS-85-calculate-matching-users
Count number of users that match static rules for a segment
2018-01-26 08:58:00 +02:00
d5e89d374b Remove unnecessary imports 2018-01-25 19:51:50 +02:00
5b39e82f80 Fixed test for adding user counter to segment 2018-01-25 18:42:38 +02:00
fbcebb43a4 Store record count on a segment when it is created 2018-01-25 15:14:19 +02:00
ef271587ec Test count_matching_users method 2018-01-25 13:26:05 +02:00
786a8801b1 Migrations for Segment.matched_user_count 2018-01-25 11:26:57 +02:00
caf73aa43c Add matched_users_count field to segments 2018-01-25 11:12:46 +02:00
4021d2c915 Add method to calculate the number of users that match a segment 2018-01-24 22:00:28 +02:00
33f96af4a3 Allow test_user() for static rules to accept a user 2018-01-24 15:14:24 +02:00
6299feb497 Merge tag '0.10.4' into develop
0.10.4
2018-01-22 12:58:49 +02:00
7ced6db126 Merge branch 'release/0.10.4' 2018-01-22 12:58:31 +02:00
c6ce67c9c9 Version bump 0.10.4 2018-01-22 12:58:12 +02:00
3df3fc0b16 Merge pull request #6 from praekeltfoundation/feature/SAS-87-fix-static-rules
Set QueryRule to be static
2018-01-22 12:03:29 +02:00
a00929846e Set query rule to be static 2018-01-18 16:17:30 +02:00
49fba11049 Merge pull request #5 from praekeltfoundation/enable-linting
Enable and fix lint
2018-01-08 10:23:36 +00:00
e3488e87ad Enable and fix lint 2018-01-08 09:08:11 +00:00
808aa6d202 Add tests for exclude_variants 2018-01-08 09:07:15 +00:00
efb060cc6e Merge branch 'release/0.10.3' into develop 2018-01-05 19:18:14 +02:00
414afa5269 Merge branch 'release/0.10.3' 2018-01-05 19:17:18 +02:00
b3f0ac2d58 Version Bump 0.10.3 2018-01-05 19:16:45 +02:00
4f9c18d2cf Merge pull request #3 from praekeltfoundation/fix-segment-edit-link
Fix segment edit link
2018-01-05 17:08:12 +00:00
a4a283e4f3 Fix segment edit link
This is hardcoded at the moment which doesn't work unless you
redirect URLs to have a trailing slash.

Using the reverse URL lookup works in all cases.
2018-01-05 17:00:15 +00:00
30318549e2 Merge pull request #4 from praekeltfoundation/feature/issue-4-update-wagtail-django-dependencies
Update wagtail and django dependencies
2018-01-05 18:55:18 +02:00
f19de241b0 Update dependencies for wagtail and django
Only run tests for wagtail v1.13 and django v1.11
2018-01-05 18:28:40 +02:00
95ecd8d200 Merge branch 'release/0.10.2' into develop 2017-11-23 16:33:16 +02:00
6436b85b1d Merge branch 'release/0.10.2' 2017-11-23 16:32:31 +02:00
06471248d3 Version Bump 0.10.2 2017-11-23 16:32:20 +02:00
e3df03f559 Merge pull request #2 from torchbox/fix/visitor-rule-not-updating
Fix visitor rule not updating correct paths
2017-11-23 16:29:13 +02:00
0a42ce3eeb Fix not updating visitor count rule properly 2017-11-23 14:10:16 +00:00
e5068894c3 Merge branch 'release/0.10.1' into develop 2017-11-13 15:03:41 +02:00
fdc2b97194 Merge branch 'release/0.10.1' 2017-11-13 15:03:32 +02:00
a8d3aeab68 Version Bump 0.10.1 2017-11-13 15:02:56 +02:00
c76d6d1617 Update manifest to include missing js files 2017-11-13 14:58:20 +02:00
a8c4b66d6e Merge branch 'release/0.10.0' into develop 2017-11-09 16:53:42 +02:00
f3fbee99a2 Merge branch 'release/0.10.0' 2017-11-09 16:53:37 +02:00
4918c99b5f Version Bump 0.10.0 2017-11-09 16:53:22 +02:00
330c3bd377 Merge branch 'feature/issue-1-create-pypi-release' into develop 2017-11-09 16:49:29 +02:00
9c9a9d3acd add missing function call 2017-11-09 16:37:52 +02:00
51e9aa9724 remove all the extra testing environments 2017-11-09 13:36:43 +02:00
a5705fd53c Removed extra newline 2017-11-09 13:27:31 +02:00
9d1f3074c0 add pypi password 2017-11-09 13:18:22 +02:00
3bfd5b8e8f Add deploy instructions to travis file 2017-11-09 11:54:13 +02:00
232609fb4e Update setup file for new package name 2017-11-09 11:52:55 +02:00
35fd4836b0 update the realease 2017-11-08 13:58:50 +00:00
b786b0a4d2 Merge pull request #1 from torchbox/feature/dynamic-segments
Add the logic for static segments
2017-11-08 11:16:25 +00:00
23b1456438 Add tests which cover anonymous users 2017-11-01 17:10:03 +00:00
1f4a4536ab Make the static elements tracked users only
We cannot track anonymous users as the session expires after 10 minutes of
inactivity. This also avoids an issue where there is an error when the user's
session has expired and they navigate a page
2017-11-01 16:43:22 +00:00
b8bf27fb99 add enabled to the excluded segments checked 2017-10-27 07:29:41 +01:00
d07e06b4f0 lock down the static segment to prevent changes 2017-10-26 14:34:16 +01:00
71d7faba1f Correctly initialise the form 2017-10-26 13:11:16 +01:00
743d3f668e Limit the segemnt to count for all static case 2017-10-26 12:47:59 +01:00
bc0b69fde5 Hide and show the count input as required 2017-10-26 11:47:28 +01:00
7cf22d05f6 Tidy up the logic checks and remove the frozen property 2017-10-26 10:55:13 +01:00
9e0fc8e6fd Make the static segments work with match_any and fix bug in visit count 2017-10-24 10:50:05 +01:00
a116b14d57 Update to use the save method on the form to populate the segments 2017-10-23 15:46:34 +01:00
44cc95617e Use a form to clean the instance 2017-10-23 15:00:31 +01:00
c6ff2801c5 Update to use a post_init signal to populate the segment 2017-10-20 17:33:47 +01:00
0d2834a55f Update the help text migration 2017-10-20 17:17:31 +01:00
ff236a095d Improve the clarity of the help text 2017-10-20 12:18:29 +01:00
ef20580334 Notify users to static compatible rules and update docs 2017-10-20 12:09:25 +01:00
cf41be4b76 Add clean method to ensure mixed static segments are valid 2017-10-20 10:57:19 +01:00
f339879907 Ensure that mixed static and dynamic segments are not populated at runtime 2017-10-20 09:53:18 +01:00
aa2a239aec Update the dashboard to display information about static segments 2017-10-19 17:19:53 +01:00
8c96fffd4e Ensure that the session is checked correctly 2017-10-17 17:35:57 +01:00
675d219f1f Add the logic for static segments 2017-10-17 16:57:07 +01:00
31 changed files with 1101 additions and 94 deletions

View File

@ -4,33 +4,11 @@ language: python
matrix: matrix:
include: include:
# Django 1.9, Wagtail 1.9
- python: 2.7 - python: 2.7
env: TOXENV=py27-django19-wagtail19 env: lint
- python: 3.5
env: TOXENV=py35-django19-wagtail19
- python: 3.6
env: TOXENV=py36-django19-wagtail19
# Django 1.10, Wagtail 1.10
- python: 2.7 - python: 2.7
env: TOXENV=py27-django110-wagtail110 env: TOXENV=py27-django111-wagtail113
- python: 3.5
env: TOXENV=py35-django110-wagtail110
- python: 3.6
env: TOXENV=py36-django110-wagtail110
# Django 1.11, Wagtail 1.10
- 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
install: install:
- pip install tox codecov - pip install tox codecov
@ -41,3 +19,13 @@ script:
after_success: after_success:
- tox -e coverage-report - tox -e coverage-report
- codecov - codecov
deploy:
provider: pypi
distributions: sdist bdist_wheel
user: praekelt.org
password:
secure: IxPnc95OFNQsl7kFfLlLc38KfHh/W79VXnhEkdb2x1GZznOsG167QlhpAnyXIJysCQxgMMwLMuPOOdk1WIxOpGNM1/M80hNzPAfxMYWPuwposDdwoIc8SyREPJt16BXWAY+rAH8SHxld9p1YdRbOEPSSglODe4dCmQWsCbKjV3aKv+gZxBvf6pMxUglp2fBIlCwrE77MyMYh9iW0AGzD6atC2xN9NgAm4f+2WFlKCUL/MztLhNWdvWEiibQav6Tts7p8tWrsBVzywDW2IOy3O0ihPgRdISZ7QrxhiJTjlHYPAy4eRGOnYSQXvp6Dy8ONE5a6Uv5g3Xw2UmQo85sSMUs2VxR0G7d+PQgL0A7ENBQ5i7eSAFHYs8EswiGilW2A7mM4qRXwg9URLelYSdkM+aNXvR+25dCsXakiO4NjCz/BPgNzxPeQLlBdxR5vHugeM/XYuhy6CHlZrR/uueaO0X8RyhJcqeDjBy58IrwYS3Mpj7QCfBpQ9PqsqXEWV9BKwKiBXM2+3hkhawFDWa0GW2PDbORKtSLy/ORfGLx5Y9qxQYKEGvFQA3iqkTjrLj+KeUziKtuvEAcvsdBIJVIxeoHwdl+qqxEm8A7YuRBnWVxWc3jE6wBXboeFP92gVe/ueoXmY22riK9Ja0pli3TyNga8by9WM8Qp4D2ZqkVXHwg=
on:
tags: true
all_branches: true

15
CHANGES
View File

@ -1,3 +1,18 @@
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) 0.9.1 (tbd)
================== ==================

View File

@ -1,3 +1,6 @@
include README.rst include README.rst
recursive-include src recursive-include src *
recursive-exclude src __pycache__
recursive-exclude src *.py[co]

View File

@ -55,10 +55,10 @@ author = 'Lab Digital BV'
# built documents. # built documents.
# #
# The short X.Y version. # The short X.Y version.
version = '0.9.1' version = '0.10.6'
# The full version, including alpha/beta/rc tags. # The full version, including alpha/beta/rc tags.
release = '0.9.1' release = '0.10.6'
# The language for content autogenerated by Sphinx. Refer to documentation # The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages. # for a list of supported languages.

View File

@ -86,6 +86,11 @@
padding: 0; padding: 0;
margin: 0; margin: 0;
list-style: none; list-style: none;
.stat_card {
display: inline-block;
margin-bottom: 5px;
margin-right: 10px;
}
} }
.block_container .block span.icon::before { .block_container .block span.icon::before {
@ -93,11 +98,6 @@
vertical-align: bottom; vertical-align: bottom;
} }
.block_container .block .inspect_container .inspect li {
display: inline-block;
margin-bottom: 5px;
}
.block_container .block .inspect_container .inspect li span { .block_container .block .inspect_container .inspect li span {
display: block; display: block;
font-size: 20px; font-size: 20px;

View File

@ -1,5 +1,5 @@
[bumpversion] [bumpversion]
current_version = 0.9.1 current_version = 0.10.6
commit = true commit = true
tag = true tag = true
tag_name = {new_version} tag_name = {new_version}
@ -15,14 +15,14 @@ python_paths = .
[flake8] [flake8]
ignore = E731 ignore = E731
max-line-length = 120 max-line-length = 120
exclude = exclude =
src/**/migrations/*.py src/**/migrations/*.py
[wheel] [wheel]
universal = 1 universal = 1
[coverage:run] [coverage:run]
omit = omit =
src/**/migrations/*.py src/**/migrations/*.py
[bumpversion:file:setup.py] [bumpversion:file:setup.py]

View File

@ -1,9 +1,8 @@
import re import re
from setuptools import find_packages, setup from setuptools import find_packages, setup
install_requires = [ install_requires = [
'wagtail>=1.9,<1.11', 'wagtail>=1.10,<1.14',
'user-agents>=1.0.1', 'user-agents>=1.0.1',
'wagtailfontawesome>=1.0.6', 'wagtailfontawesome>=1.0.6',
] ]
@ -18,6 +17,7 @@ tests_require = [
'pytest-cov==2.4.0', 'pytest-cov==2.4.0',
'pytest-django==3.1.2', 'pytest-django==3.1.2',
'pytest-sugar==0.7.1', 'pytest-sugar==0.7.1',
'pytest-mock==1.6.3',
'pytest==3.1.0', 'pytest==3.1.0',
'wagtail_factories==0.3.0', 'wagtail_factories==0.3.0',
] ]
@ -31,12 +31,12 @@ with open('README.rst') as fh:
'^.. start-no-pypi.*^.. end-no-pypi', '', fh.read(), flags=re.M | re.S) '^.. start-no-pypi.*^.. end-no-pypi', '', fh.read(), flags=re.M | re.S)
setup( setup(
name='wagtail-personalisation', name='wagtail-personalisation-molo',
version='0.9.1', version='0.10.6',
description='A Wagtail add-on for showing personalized content', description='A forked version of Wagtail add-on for showing personalized content',
author='Lab Digital BV', author='Praekelt.org',
author_email='opensource@labdigital.nl', author_email='dev@praekeltfoundation.org',
url='http://labdigital.nl', url='https://github.com/praekeltfoundation/wagtail-personalisation/',
install_requires=install_requires, install_requires=install_requires,
tests_require=tests_require, tests_require=tests_require,
extras_require={ extras_require={

View File

@ -132,18 +132,19 @@ class SessionSegmentsAdapter(BaseSegmentsAdapter):
if page_visits: if page_visits:
for page_visit in page_visits: for page_visit in page_visits:
page_visit['count'] += 1 page_visit['count'] += 1
page_visit['path'] = page.url_path if page else self.request.path
self.request.session.modified = True self.request.session.modified = True
else: else:
visit_count.append({ visit_count.append({
'slug': page.slug, 'slug': page.slug,
'id': page.pk, 'id': page.pk,
'path': self.request.path, 'path': page.url_path if page else self.request.path,
'count': 1, 'count': 1,
}) })
def get_visit_count(self, page=None): def get_visit_count(self, page=None):
"""Return the number of visits on the current request or given page""" """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', []) visit_count = self.request.session.setdefault('visit_count', [])
for visit in visit_count: for visit in visit_count:
if visit['path'] == path: if visit['path'] == path:
@ -174,15 +175,21 @@ class SessionSegmentsAdapter(BaseSegmentsAdapter):
# Run tests on all remaining enabled segments to verify applicability. # Run tests on all remaining enabled segments to verify applicability.
additional_segments = [] additional_segments = []
for segment in enabled_segments: for segment in enabled_segments:
segment_rules = [] if segment.is_static and segment.static_users.filter(id=self.request.user.id).exists():
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:
additional_segments.append(segment) additional_segments.append(segment)
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)
else:
additional_segments.append(segment)
self.set_segments(current_segments + additional_segments) self.set_segments(current_segments + additional_segments)
self.update_visit_count() self.update_visit_count()

View File

@ -0,0 +1,135 @@
from __future__ import absolute_import, unicode_literals
from datetime import datetime
from importlib import import_module
from itertools import takewhile
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
from django.contrib.sessions.models import Session
from django.contrib.staticfiles.templatetags.staticfiles import static
from django.test.client import RequestFactory
from django.utils.lru_cache import lru_cache
from django.utils.translation import ugettext_lazy as _
from wagtail.wagtailadmin.forms import WagtailAdminModelForm
SessionStore = import_module(settings.SESSION_ENGINE).SessionStore
@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()
class SegmentAdminForm(WagtailAdminModelForm):
def count_matching_users(self, rules, match_any):
""" Calculates how many users match the given static rules
"""
count = 0
static_rules = [rule for rule in rules if rule.static]
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:
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 = []
sessions = Session.objects.iterator()
take_session = takewhile(
lambda x: instance.count == 0 or len(users_to_add) <= instance.count,
sessions
)
for session in take_session:
session_data = session.get_decoded()
user = user_from_data(session_data.get('_auth_user_id'))
if user.is_authenticated():
request.user = user
request.session = SessionStore(session_key=session.session_key)
passes = adapter._test_rules(instance.get_rules(), request, instance.match_any)
if passes and instance.randomise_into_segment():
users_to_add.append(user)
instance.static_users.add(*users_to_add)
return instance
@property
def media(self):
media = super(SegmentAdminForm, self).media
media.add_js(
[static('js/segment_form_control.js')]
)
return media

View File

@ -2,8 +2,8 @@
# Generated by Django 1.11.1 on 2017-05-31 14:28 # Generated by Django 1.11.1 on 2017-05-31 14:28
from __future__ import unicode_literals from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -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),
),
]

View 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),
),
]

View File

@ -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),
),
]

View File

@ -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)]),
),
]

View File

@ -1,9 +1,14 @@
from __future__ import absolute_import, unicode_literals from __future__ import absolute_import, unicode_literals
import random
from django import forms
from django.conf import settings
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models, transaction from django.db import models, transaction
from django.template.defaultfilters import slugify from django.template.defaultfilters import slugify
from django.utils.encoding import python_2_unicode_compatible from django.utils.encoding import python_2_unicode_compatible
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from modelcluster.models import ClusterableModel from modelcluster.models import ClusterableModel
from wagtail.wagtailadmin.edit_handlers import ( from wagtail.wagtailadmin.edit_handlers import (
@ -13,6 +18,8 @@ from wagtail.wagtailcore.models import Page
from wagtail_personalisation.rules import AbstractBaseRule from wagtail_personalisation.rules import AbstractBaseRule
from wagtail_personalisation.utils import count_active_days from wagtail_personalisation.utils import count_active_days
from .forms import SegmentAdminForm
class SegmentQuerySet(models.QuerySet): class SegmentQuerySet(models.QuerySet):
def enabled(self): def enabled(self):
@ -30,6 +37,14 @@ class Segment(ClusterableModel):
(STATUS_DISABLED, _('Disabled')), (STATUS_DISABLED, _('Disabled')),
) )
TYPE_DYNAMIC = 'dynamic'
TYPE_STATIC = 'static'
TYPE_CHOICES = (
(TYPE_DYNAMIC, _('Dynamic')),
(TYPE_STATIC, _('Static')),
)
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
create_date = models.DateTimeField(auto_now_add=True) create_date = models.DateTimeField(auto_now_add=True)
edit_date = models.DateTimeField(auto_now=True) edit_date = models.DateTimeField(auto_now=True)
@ -44,9 +59,48 @@ class Segment(ClusterableModel):
default=False, default=False,
help_text=_("Should the segment match all the rules or just one of them?") 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,
)
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() objects = SegmentQuerySet.as_manager()
base_form_class = SegmentAdminForm
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
Segment.panels = [ Segment.panels = [
MultiFieldPanel([ MultiFieldPanel([
@ -56,11 +110,17 @@ class Segment(ClusterableModel):
FieldPanel('persistent'), FieldPanel('persistent'),
]), ]),
FieldPanel('match_any'), FieldPanel('match_any'),
FieldPanel('type', widget=forms.RadioSelect),
FieldPanel('count', classname='count_field'),
FieldPanel('randomisation_percent', classname='percent_field'),
], heading="Segment"), ], heading="Segment"),
MultiFieldPanel([ MultiFieldPanel([
InlinePanel( InlinePanel(
"{}_related".format(rule_model._meta.db_table), "{}_related".format(rule_model._meta.db_table),
label=rule_model._meta.verbose_name, label='{}{}'.format(
rule_model._meta.verbose_name,
' ({})'.format(_('Static compatible')) if rule_model.static else ''
),
) for rule_model in AbstractBaseRule.__subclasses__() ) for rule_model in AbstractBaseRule.__subclasses__()
], heading=_("Rules")), ], heading=_("Rules")),
] ]
@ -70,6 +130,23 @@ class Segment(ClusterableModel):
def __str__(self): def __str__(self):
return self.name 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): def encoded_name(self):
"""Return a string with a slug for the segment.""" """Return a string with a slug for the segment."""
return slugify(self.name.lower()) return slugify(self.name.lower())
@ -106,6 +183,19 @@ class Segment(ClusterableModel):
if save: if save:
self.save() self.save()
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): class PersonalisablePageMetadata(ClusterableModel):
"""The personalisable page model. Allows creation of variants with linked """The personalisable page model. Allows creation of variants with linked

View File

@ -18,6 +18,7 @@ from wagtail.wagtailadmin.edit_handlers import (
class AbstractBaseRule(models.Model): class AbstractBaseRule(models.Model):
"""Base for creating rules to segment users with.""" """Base for creating rules to segment users with."""
icon = 'fa-circle-o' icon = 'fa-circle-o'
static = False
segment = ParentalKey( segment = ParentalKey(
'wagtail_personalisation.Segment', 'wagtail_personalisation.Segment',
@ -190,6 +191,7 @@ class VisitCountRule(AbstractBaseRule):
""" """
icon = 'fa-calculator' icon = 'fa-calculator'
static = True
OPERATOR_CHOICES = ( OPERATOR_CHOICES = (
('more_than', _("More than")), ('more_than', _("More than")),
@ -218,7 +220,12 @@ class VisitCountRule(AbstractBaseRule):
class Meta: class Meta:
verbose_name = _('Visit count Rule') verbose_name = _('Visit count Rule')
def test_user(self, request): def test_user(self, request, user=None):
if user:
# This rule currently does not support testing a user directly
# TODO: Make this test a user directly when the rule uses
# historical data
return False
operator = self.operator operator = self.operator
segment_count = self.count segment_count = self.count
@ -227,7 +234,7 @@ class VisitCountRule(AbstractBaseRule):
adapter = get_segment_adapter(request) 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 and operator == "more_than":
if visit_count > segment_count: if visit_count > segment_count:
return True return True
@ -259,6 +266,7 @@ class QueryRule(AbstractBaseRule):
""" """
icon = 'fa-link' icon = 'fa-link'
static = True
parameter = models.SlugField(_("The query parameter to search for"), parameter = models.SlugField(_("The query parameter to search for"),
max_length=20) max_length=20)
@ -273,7 +281,13 @@ class QueryRule(AbstractBaseRule):
class Meta: class Meta:
verbose_name = _('Query Rule') verbose_name = _('Query Rule')
def test_user(self, request): def test_user(self, request, user=None):
if user:
# This rule currently does not support testing a user directly
# TODO: Make this test a user directly if/when the rule uses
# historical data
return False
return request.GET.get(self.parameter, '') == self.value return request.GET.get(self.parameter, '') == self.value
def description(self): def description(self):

View File

@ -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 span.icon:before{margin-right:.3em;vertical-align:bottom}.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 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} .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*/ /*# sourceMappingURL=dashboard.css.map*/

View File

@ -1 +1 @@
{"version":3,"sources":["webpack:///./scss/dashboard.scss"],"names":[],"mappings":"AAAA,cACI,kBACA,kBAAmB,CAGvB,iBACI,cACA,eAAgB,CAGpB,wBACI,cACA,WACA,8BAAsB,sBACtB,kBACA,uBACA,iBACA,kBACA,mBACA,yBACA,kBACA,sBACA,+DAAkE,uDAClE,2GAA8F,2UAC9F,cAAe,CAGnB,0FAEI,UAAY,CAGZ,2BACI,qBACA,UAAW,CAGnB,uCACI,iBAAkB,CAGlB,uCACI,gBACA,SACA,SAAU,CAGV,0CACI,WACA,iBAAkB,CAGtB,qDACI,cAAe,CAG3B,mCACI,yBAA0B,CAG9B,8BACI,sBACA,uEAAkE,+DAGtE,yBACI,wBACI,WACA,cAAe,CAClB,CAGL,2CACI,oBAAa,iCACb,8BAAmB,uEACnB,qBAAiB,iBACjB,yBAA8B,oDAC9B,0BAAoB,2CACpB,kBAAmB,CAGvB,oDACI,cACA,WACA,uBACA,UACA,SACA,eAAgB,CAGhB,yCACI,kBACA,qBAAsB,CAG1B,uDACI,qBACA,iBAAkB,CAGtB,4DACI,cACA,eACA,gBACA,aACA,wBAAyB,CAG7B,2DACI,kBACA,8BAAsB,sBACtB,WACA,sBACA,sBACA,sBACA,gBACA,qBACA,qBACA,iBAAkB,CAG1B,oDACI,cACA,kBACA,wBACA,kBACA,QACA,mCAA2B,2BAC3B,cACA,eACA,eAAgB","file":"../css/dashboard.css","sourcesContent":[".nice-padding {\n padding-left: 50px;\n padding-right: 50px;\n}\n\n.block_container {\n display: block;\n margin-top: 30px;\n}\n\n.block_container .block {\n display: block;\n float: left;\n box-sizing: border-box;\n position: relative;\n width: calc(50% - 10px);\n min-height: 216px;\n padding: 10px 20px;\n margin-bottom: 20px;\n border: 1px solid #d9d9d9;\n border-radius: 3px;\n background-color: #fff;\n box-shadow: 0 1px 3px rgba(0,0,0,0.00), 0 1px 2px rgba(0,0,0,0.00);\n transition: box-shadow 0.3s cubic-bezier(.25,.8,.25,1), border 0.3s cubic-bezier(.25,.8,.25,1);\n cursor: pointer;\n}\n\n.block_container .block--disabled h2,\n.block_container .block--disabled .inspect_container {\n opacity: 0.5;\n}\n\n .block_container .block h2 {\n display: inline-block;\n width: auto;\n }\n\n.block_container .block:nth-child(odd) {\n margin-right: 20px;\n}\n\n .block_container .block .block_actions {\n list-style: none;\n margin: 0;\n padding: 0;\n }\n\n .block_container .block .block_actions li {\n float: left;\n margin-right: 10px;\n }\n\n .block_container .block .block_actions li:last-child {\n margin-right: 0;\n }\n\n.block_container .block.suggestion {\n border: 1px dashed #d9d9d9;\n}\n\n.block_container .block:hover {\n border: 1px solid #fff;\n box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23);\n}\n\n@media (max-width: 699px) {\n .block_container .block {\n width: 100%;\n margin-right: 0;\n }\n}\n\n.block_container .block .inspect_container {\n display: flex;\n flex-direction: row;\n flex-wrap: nowrap;\n justify-content: space-between;\n align-items: stretch;\n margin-bottom: 10px;\n}\n\n.block_container .block .inspect_container .inspect {\n display: block;\n float: left;\n width: calc(50% - 10px);\n padding: 0;\n margin: 0;\n list-style: none;\n}\n\n .block_container .block span.icon::before {\n margin-right: 0.3em;\n vertical-align: bottom;\n }\n\n .block_container .block .inspect_container .inspect li {\n display: inline-block;\n margin-bottom: 5px;\n }\n\n .block_container .block .inspect_container .inspect li span {\n display: block;\n font-size: 20px;\n font-weight: bold;\n margin: 5px 0;\n overflow-wrap: break-word;\n }\n\n .block_container .block .inspect_container .inspect li pre {\n position: relative;\n box-sizing: border-box;\n width: auto;\n background-color: #eee;\n border: 1px solid #ccc;\n margin: 5px 0 5px 21px;\n padding: 2px 5px;\n word-wrap: break-word;\n word-break: break-all;\n border-radius: 3px;\n }\n\n.block_container .block.suggestion .suggestive_text {\n display: block;\n position: absolute;\n width: calc(100% - 40px);\n text-align: center;\n top: 50%;\n transform: translateY(-50%);\n color: #d9d9d9;\n font-size: 20px;\n font-weight: 100;\n}\n\n\n\n// WEBPACK FOOTER //\n// ./scss/dashboard.scss"],"sourceRoot":""} {"version":3,"sources":["webpack:///./scss/dashboard.scss"],"names":[],"mappings":"AAAA,cACI,kBACA,kBAAmB,CAGvB,iBACI,cACA,eAAgB,CAGpB,wBACI,cACA,WACA,8BAAsB,sBACtB,kBACA,uBACA,iBACA,kBACA,mBACA,yBACA,kBACA,sBACA,+DAAkE,uDAClE,2GAA8F,2UAC9F,cAAe,CAGnB,0FAEI,UAAY,CAGZ,2BACI,qBACA,UAAW,CAGnB,uCACI,iBAAkB,CAGlB,uCACI,gBACA,SACA,SAAU,CAGV,0CACI,WACA,iBAAkB,CAGtB,qDACI,cAAe,CAG3B,mCACI,yBAA0B,CAG9B,8BACI,sBACA,uEAAkE,+DAGtE,yBACI,wBACI,WACA,cAAe,CAClB,CAGL,2CACI,oBAAa,iCACb,8BAAmB,uEACnB,qBAAiB,iBACjB,yBAA8B,oDAC9B,0BAAoB,2CACpB,kBAAmB,CAGvB,oDACI,cACA,WACA,uBACA,UACA,SACA,eAAgB,CAMnB,+DAJO,qBACA,kBACA,iBAAkB,CAItB,yCACI,kBACA,qBAAsB,CAG1B,4DACI,cACA,eACA,gBACA,aACA,wBAAyB,CAG7B,2DACI,kBACA,8BAAsB,sBACtB,WACA,sBACA,sBACA,sBACA,gBACA,qBACA,qBACA,iBAAkB,CAG1B,oDACI,cACA,kBACA,wBACA,kBACA,QACA,mCAA2B,2BAC3B,cACA,eACA,eAAgB","file":"../css/dashboard.css","sourcesContent":[".nice-padding {\n padding-left: 50px;\n padding-right: 50px;\n}\n\n.block_container {\n display: block;\n margin-top: 30px;\n}\n\n.block_container .block {\n display: block;\n float: left;\n box-sizing: border-box;\n position: relative;\n width: calc(50% - 10px);\n min-height: 216px;\n padding: 10px 20px;\n margin-bottom: 20px;\n border: 1px solid #d9d9d9;\n border-radius: 3px;\n background-color: #fff;\n box-shadow: 0 1px 3px rgba(0,0,0,0.00), 0 1px 2px rgba(0,0,0,0.00);\n transition: box-shadow 0.3s cubic-bezier(.25,.8,.25,1), border 0.3s cubic-bezier(.25,.8,.25,1);\n cursor: pointer;\n}\n\n.block_container .block--disabled h2,\n.block_container .block--disabled .inspect_container {\n opacity: 0.5;\n}\n\n .block_container .block h2 {\n display: inline-block;\n width: auto;\n }\n\n.block_container .block:nth-child(odd) {\n margin-right: 20px;\n}\n\n .block_container .block .block_actions {\n list-style: none;\n margin: 0;\n padding: 0;\n }\n\n .block_container .block .block_actions li {\n float: left;\n margin-right: 10px;\n }\n\n .block_container .block .block_actions li:last-child {\n margin-right: 0;\n }\n\n.block_container .block.suggestion {\n border: 1px dashed #d9d9d9;\n}\n\n.block_container .block:hover {\n border: 1px solid #fff;\n box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23);\n}\n\n@media (max-width: 699px) {\n .block_container .block {\n width: 100%;\n margin-right: 0;\n }\n}\n\n.block_container .block .inspect_container {\n display: flex;\n flex-direction: row;\n flex-wrap: nowrap;\n justify-content: space-between;\n align-items: stretch;\n margin-bottom: 10px;\n}\n\n.block_container .block .inspect_container .inspect {\n display: block;\n float: left;\n width: calc(50% - 10px);\n padding: 0;\n margin: 0;\n list-style: none;\n .stat_card {\n display: inline-block;\n margin-bottom: 5px;\n margin-right: 10px;\n }\n}\n\n .block_container .block span.icon::before {\n margin-right: 0.3em;\n vertical-align: bottom;\n }\n\n .block_container .block .inspect_container .inspect li span {\n display: block;\n font-size: 20px;\n font-weight: bold;\n margin: 5px 0;\n overflow-wrap: break-word;\n }\n\n .block_container .block .inspect_container .inspect li pre {\n position: relative;\n box-sizing: border-box;\n width: auto;\n background-color: #eee;\n border: 1px solid #ccc;\n margin: 5px 0 5px 21px;\n padding: 2px 5px;\n word-wrap: break-word;\n word-break: break-all;\n border-radius: 3px;\n }\n\n.block_container .block.suggestion .suggestive_text {\n display: block;\n position: absolute;\n width: calc(100% - 40px);\n text-align: center;\n top: 50%;\n transform: translateY(-50%);\n color: #d9d9d9;\n font-size: 20px;\n font-weight: 100;\n}\n\n\n\n// WEBPACK FOOTER //\n// ./scss/dashboard.scss"],"sourceRoot":""}

View File

@ -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);

View File

@ -22,24 +22,37 @@
<div class="nice-padding block_container"> <div class="nice-padding block_container">
{% if all_count %} {% if all_count %}
{% for segment in object_list %} {% for segment in object_list %}
<div class="block block--{{ segment.status }}" onclick="location.href = 'edit/{{ segment.pk }}'"> <div class="block block--{{ segment.status }}" onclick="location.href = '{% url 'wagtail_personalisation_segment_modeladmin_edit' segment.pk %}'">
<h2>{{ segment }}</h2> <h2>{{ segment }}</h2>
<div class="inspect_container"> <div class="inspect_container">
<ul class="inspect segment_stats"> <ul class="inspect segment_stats">
<li class="visit_stat"> <li class="stat_card">
{% trans "This segment has been visited" %} {% trans "This segment has been visited" %}
<span class="icon icon-fa-rocket">{{ segment.visit_count|localize }} {% trans "time" %}{{ segment.visit_count|pluralize }}</span> <span class="icon icon-fa-rocket">{{ segment.visit_count|localize }} {% trans "time" %}{{ segment.visit_count|pluralize }}</span>
</li> </li>
<li class="days_stat"> <li class="stat_card">
{% trans "This segment has been active for" %} {% trans "This segment has been active for" %}
<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> <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> </li>
{% if segment.is_static %}
<li class="stat_card">
{% trans "This segment is Static" %}
<span class="icon icon-fa-user">
{{ segment.sessions.count|localize }}
{% if segment.sessions.count < segment.count %}
/ {{ segment.count }} {% trans "member" %}{{ segment.count|pluralize }}
{% else %}
{% trans "member" %}{{ segment.sessions.count|pluralize }}
{% endif %}
</span>
</li>
{% endif %}
</ul> </ul>
<hr /> <hr />
<ul class="inspect segment_rules"> <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" %} {% trans "The visitor must match" %}
{% if segment.match_any %} {% if segment.match_any %}
<span class="icon icon-fa-cube">{% trans "Any rule" %}</span> <span class="icon icon-fa-cube">{% trans "Any rule" %}</span>
@ -48,7 +61,7 @@
{% endif %} {% endif %}
</li> </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" %} {% trans "The persistence of this segment is" %}
{% if segment.persistent %} {% if segment.persistent %}
<span class="icon icon-fa-bookmark" 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>
@ -57,8 +70,15 @@
{% endif %} {% endif %}
</li> </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 %} {% for rule in segment.get_rules %}
<li class="{{ rule.encoded_name }}"> <li class="stat_card {{ rule.encoded_name }}">
{{ rule.description.title }} {{ rule.description.title }}
{% if rule.description.code %} {% if rule.description.code %}
<pre>{{ rule.description.value }}</pre> <pre>{{ rule.description.value }}</pre>
@ -67,6 +87,11 @@
{% endif %} {% endif %}
</li> </li>
{% endfor %} {% 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> </ul>
</div> </div>
@ -77,7 +102,7 @@
{% elif segment.status == segment.STATUS_ENABLED %} {% elif segment.status == segment.STATUS_ENABLED %}
<li><a href="{% url 'segment:toggle' segment.pk %}" title="{% trans "Disable this segment" %}">disable</a></li> <li><a href="{% url 'segment:toggle' segment.pk %}" title="{% trans "Disable this segment" %}">disable</a></li>
{% endif %} {% endif %}
<li><a href="edit/{{ segment.pk }}" title="{% trans "Configure this segment" %}">configure this</a></li> <li><a href="{% url 'wagtail_personalisation_segment_modeladmin_edit' segment.pk %}" title="{% trans "Configure this segment" %}">configure this</a></li>
</ul> </ul>
{% endif %} {% endif %}
</div> </div>

View File

@ -103,9 +103,17 @@ def exclude_variants(pages):
:return: List of pages that aren't variants :return: List of pages that aren't variants
:rtype: list :rtype: list
""" """
return [page for page in pages return [
if (hasattr(page, 'personalisation_metadata') is False) page for page in pages
or (hasattr(page, 'personalisation_metadata') if (
and page.personalisation_metadata is None) (
or (hasattr(page, 'personalisation_metadata') hasattr(page, 'personalisation_metadata') is False
and page.personalisation_metadata.is_canonical)] ) or
(
hasattr(page, 'personalisation_metadata') and page.personalisation_metadata is None
) or
(
hasattr(page, 'personalisation_metadata') and page.personalisation_metadata.is_canonical
)
)
]

View File

@ -7,7 +7,7 @@ from django.core.urlresolvers import reverse
from django.template.defaultfilters import pluralize from django.template.defaultfilters import pluralize
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from wagtail.wagtailadmin.site_summary import SummaryItem, PagesSummaryItem from wagtail.wagtailadmin.site_summary import PagesSummaryItem, SummaryItem
from wagtail.wagtailadmin.widgets import Button, ButtonWithDropdownFromHook from wagtail.wagtailadmin.widgets import Button, ButtonWithDropdownFromHook
from wagtail.wagtailcore import hooks from wagtail.wagtailcore import hooks
from wagtail.wagtailcore.models import Page from wagtail.wagtailcore.models import Page

View File

@ -44,3 +44,8 @@ class RequestFactory(BaseRequestFactory):
request.session = SessionStore() request.session = SessionStore()
request._messages = FallbackStorage(request) request._messages = FallbackStorage(request)
return request return request
@pytest.fixture
def user(django_user_model):
return django_user_model.objects.create(username='user')

View File

@ -1,10 +1,9 @@
from __future__ import absolute_import, unicode_literals from __future__ import absolute_import, unicode_literals
import os import os
from pkg_resources import parse_version as V
import django import django
from pkg_resources import parse_version as V
DATABASES = { DATABASES = {
'default': { 'default': {
@ -56,6 +55,7 @@ TEMPLATES = [
}, },
] ]
def get_middleware_settings(): def get_middleware_settings():
return ( return (
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
@ -69,6 +69,7 @@ def get_middleware_settings():
'wagtail.wagtailcore.middleware.SiteMiddleware', 'wagtail.wagtailcore.middleware.SiteMiddleware',
) )
# Django 1.10 started to use "MIDDLEWARE" instead of "MIDDLEWARE_CLASSES". # Django 1.10 started to use "MIDDLEWARE" instead of "MIDDLEWARE_CLASSES".
if V(django.get_version()) < V('1.10'): if V(django.get_version()) < V('1.10'):
MIDDLEWARE_CLASSES = get_middleware_settings() MIDDLEWARE_CLASSES = get_middleware_settings()

View File

@ -21,7 +21,7 @@ class Migration(migrations.Migration):
migrations.CreateModel( migrations.CreateModel(
name='ContentPage', name='ContentPage',
fields=[ fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.Page')), ('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.Page')), # noqa: E501
('subtitle', models.CharField(blank=True, default='', max_length=255)), ('subtitle', models.CharField(blank=True, default='', max_length=255)),
('body', wagtail.wagtailcore.fields.RichTextField(blank=True, default='')), ('body', wagtail.wagtailcore.fields.RichTextField(blank=True, default='')),
], ],

View File

@ -2,9 +2,9 @@
# Generated by Django 1.11.1 on 2017-06-02 04:26 # Generated by Django 1.11.1 on 2017-06-02 04:26
from __future__ import unicode_literals from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import wagtail.wagtailcore.fields import wagtail.wagtailcore.fields
from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -18,7 +18,7 @@ class Migration(migrations.Migration):
migrations.CreateModel( migrations.CreateModel(
name='RegularPage', name='RegularPage',
fields=[ fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.Page')), ('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.Page')), # noqa: E501
('subtitle', models.CharField(blank=True, default='', max_length=255)), ('subtitle', models.CharField(blank=True, default='', max_length=255)),
('body', wagtail.wagtailcore.fields.RichTextField(blank=True, default='')), ('body', wagtail.wagtailcore.fields.RichTextField(blank=True, default='')),
], ],

View File

@ -4,16 +4,14 @@ import datetime
import pytest import pytest
from tests.factories.page import ContentPageFactory from tests.factories.rule import ReferralRuleFactory, QueryRuleFactory
from tests.factories.rule import (
DayRuleFactory, DeviceRuleFactory, ReferralRuleFactory, TimeRuleFactory)
from tests.factories.segment import SegmentFactory from tests.factories.segment import SegmentFactory
from tests.factories.site import SiteFactory
from wagtail_personalisation.models import Segment from wagtail_personalisation.models import Segment
from wagtail_personalisation.rules import TimeRule from wagtail_personalisation.rules import TimeRule
# Factory tests # Factory tests
@pytest.mark.django_db @pytest.mark.django_db
def test_segment_create(): def test_segment_create():
factoried_segment = SegmentFactory() factoried_segment = SegmentFactory()
@ -27,8 +25,6 @@ def test_segment_create():
assert factoried_segment.status == segment.status assert factoried_segment.status == segment.status
@pytest.mark.django_db @pytest.mark.django_db
def test_referral_rule_create(): def test_referral_rule_create():
segment = SegmentFactory(name='Referral') segment = SegmentFactory(name='Referral')
@ -37,3 +33,16 @@ def test_referral_rule_create():
segment=segment) segment=segment)
assert referral_rule.regex_string == 'test.test' assert referral_rule.regex_string == 'test.test'
@pytest.mark.django_db
def test_query_rule_create():
segment = SegmentFactory(name='Query')
query_rule = QueryRuleFactory(
parameter="query",
value="value",
segment=segment)
assert query_rule.parameter == 'query'
assert query_rule.value == 'value'
assert query_rule.static

View File

@ -16,6 +16,8 @@ def test_time_rule_create():
segment=segment) segment=segment)
assert time_rule.start_time == datetime.time(8, 0, 0) assert time_rule.start_time == datetime.time(8, 0, 0)
@pytest.mark.django_db @pytest.mark.django_db
@freeze_time("10:00:00") @freeze_time("10:00:00")
def test_requesttime_segment(client, site): def test_requesttime_segment(client, site):

View File

@ -0,0 +1,547 @@
from __future__ import absolute_import, unicode_literals
import datetime
import pytest
from django.forms.models import model_to_dict
from tests.factories.segment import SegmentFactory
from wagtail_personalisation.forms import SegmentAdminForm
from wagtail_personalisation.models import Segment
from wagtail_personalisation.rules import (AbstractBaseRule, TimeRule,
VisitCountRule)
def form_with_data(segment, *rules):
model_fields = ['type', 'status', 'count', 'name', 'match_any', 'randomisation_percent']
class TestSegmentAdminForm(SegmentAdminForm):
class Meta:
model = Segment
fields = model_fields
data = model_to_dict(segment, model_fields)
for formset in TestSegmentAdminForm().formsets.values():
rule_data = {}
count = 0
for rule in rules:
if isinstance(rule, formset.model):
rule_data = model_to_dict(rule)
for key, value in rule_data.items():
data['{}-{}-{}'.format(formset.prefix, count, key)] = value
count += 1
data['{}-INITIAL_FORMS'.format(formset.prefix)] = 0
data['{}-TOTAL_FORMS'.format(formset.prefix)] = count
return TestSegmentAdminForm(data)
@pytest.mark.django_db
def test_session_added_to_static_segment_at_creation(site, client, user):
session = client.session
session.save()
client.force_login(user)
client.get(site.root_page.url)
segment = SegmentFactory.build(type=Segment.TYPE_STATIC)
rule = VisitCountRule(counted_page=site.root_page)
form = form_with_data(segment, rule)
instance = form.save()
assert user in instance.static_users.all()
@pytest.mark.django_db
def test_anonymous_user_not_added_to_static_segment_at_creation(site, client):
session = client.session
session.save()
client.get(site.root_page.url)
segment = SegmentFactory.build(type=Segment.TYPE_STATIC)
rule = VisitCountRule(counted_page=site.root_page)
form = form_with_data(segment, rule)
instance = form.save()
assert not instance.static_users.all()
@pytest.mark.django_db
def test_match_any_correct_populates(site, client, django_user_model):
user = django_user_model.objects.create(username='first')
session = client.session
client.force_login(user)
client.get(site.root_page.url)
other_user = django_user_model.objects.create(username='second')
client.cookies.clear()
second_session = client.session
other_page = site.root_page.get_last_child()
client.force_login(other_user)
client.get(other_page.url)
segment = SegmentFactory.build(type=Segment.TYPE_STATIC, match_any=True)
rule_1 = VisitCountRule(counted_page=site.root_page)
rule_2 = VisitCountRule(counted_page=other_page)
form = form_with_data(segment, rule_1, rule_2)
instance = form.save()
assert session.session_key != second_session.session_key
assert user in instance.static_users.all()
assert other_user in instance.static_users.all()
@pytest.mark.django_db
def test_mixed_static_dynamic_session_doesnt_generate_at_creation(site, client, user):
session = client.session
session.save()
client.force_login(user)
client.get(site.root_page.url)
segment = SegmentFactory.build(type=Segment.TYPE_STATIC, count=1)
static_rule = VisitCountRule(counted_page=site.root_page)
non_static_rule = TimeRule(
start_time=datetime.time(0, 0, 0),
end_time=datetime.time(23, 59, 59),
)
form = form_with_data(segment, static_rule, non_static_rule)
instance = form.save()
assert not instance.static_users.all()
@pytest.mark.django_db
def test_session_not_added_to_static_segment_after_creation(site, client, user):
segment = SegmentFactory.build(type=Segment.TYPE_STATIC, count=0)
rule = VisitCountRule(counted_page=site.root_page)
form = form_with_data(segment, rule)
instance = form.save()
session = client.session
session.save()
client.force_login(user)
client.get(site.root_page.url)
assert not instance.static_users.all()
@pytest.mark.django_db
def test_session_added_to_static_segment_after_creation(site, client, user):
segment = SegmentFactory.build(type=Segment.TYPE_STATIC, count=1)
rule = VisitCountRule(counted_page=site.root_page)
form = form_with_data(segment, rule)
instance = form.save()
session = client.session
session.save()
client.force_login(user)
client.get(site.root_page.url)
assert user in instance.static_users.all()
@pytest.mark.django_db
def test_anonymou_user_not_added_to_static_segment_after_creation(site, client):
segment = SegmentFactory.build(type=Segment.TYPE_STATIC, count=1)
rule = VisitCountRule(counted_page=site.root_page)
form = form_with_data(segment, rule)
instance = form.save()
session = client.session
session.save()
client.get(site.root_page.url)
assert not instance.static_users.all()
@pytest.mark.django_db
def test_session_not_added_to_static_segment_after_full(site, client, django_user_model):
user = django_user_model.objects.create(username='first')
other_user = django_user_model.objects.create(username='second')
segment = SegmentFactory.build(type=Segment.TYPE_STATIC, count=1)
rule = VisitCountRule(counted_page=site.root_page)
form = form_with_data(segment, rule)
instance = form.save()
assert not instance.static_users.all()
session = client.session
client.force_login(user)
client.get(site.root_page.url)
assert instance.static_users.count() == 1
client.cookies.clear()
second_session = client.session
client.force_login(other_user)
client.get(site.root_page.url)
assert session.session_key != second_session.session_key
assert instance.static_users.count() == 1
assert user in instance.static_users.all()
assert other_user not in instance.static_users.all()
@pytest.mark.django_db
def test_sessions_not_added_to_static_segment_if_rule_not_static(client, site, user):
session = client.session
session.save()
client.force_login(user)
client.get(site.root_page.url)
segment = SegmentFactory.build(type=Segment.TYPE_STATIC, count=1)
rule = TimeRule(
start_time=datetime.time(0, 0, 0),
end_time=datetime.time(23, 59, 59),
segment=segment,
)
form = form_with_data(segment, rule)
instance = form.save()
assert not instance.static_users.all()
@pytest.mark.django_db
def test_does_not_calculate_the_segment_again(site, client, mocker, user):
session = client.session
session.save()
client.force_login(user)
client.get(site.root_page.url)
segment = SegmentFactory.build(type=Segment.TYPE_STATIC, count=2)
rule = VisitCountRule(counted_page=site.root_page, segment=segment)
form = form_with_data(segment, rule)
instance = form.save()
assert user in instance.static_users.all()
mock_test_rule = mocker.patch('wagtail_personalisation.adapters.SessionSegmentsAdapter._test_rules')
client.get(site.root_page.url)
assert mock_test_rule.call_count == 0
@pytest.mark.django_db
def test_non_static_rules_have_a_count():
segment = SegmentFactory.build(type=Segment.TYPE_STATIC, count=0)
rule = TimeRule(
start_time=datetime.time(0, 0, 0),
end_time=datetime.time(23, 59, 59),
segment=segment,
)
form = form_with_data(segment, rule)
assert not form.is_valid()
@pytest.mark.django_db
def test_static_segment_with_static_rules_needs_no_count(site):
segment = SegmentFactory.build(type=Segment.TYPE_STATIC, count=0)
rule = VisitCountRule(counted_page=site.root_page, segment=segment)
form = form_with_data(segment, rule)
assert form.is_valid()
@pytest.mark.django_db
def test_dynamic_segment_with_non_static_rules_have_a_count():
segment = SegmentFactory.build(type=Segment.TYPE_DYNAMIC, count=0)
rule = TimeRule(
start_time=datetime.time(0, 0, 0),
end_time=datetime.time(23, 59, 59),
)
form = form_with_data(segment, rule)
assert form.is_valid(), form.errors
@pytest.mark.django_db
def test_randomisation_percentage_added_to_segment_at_creation(site, client, mocker, django_user_model):
segment = SegmentFactory.build(type=Segment.TYPE_STATIC)
segment.randomisation_percent = 80
rule = VisitCountRule()
form = form_with_data(segment, rule)
instance = form.save()
assert instance.randomisation_percent == 80
@pytest.mark.django_db
def test_randomisation_percentage_min_zero(site, client, mocker, django_user_model):
segment = SegmentFactory.build(type=Segment.TYPE_STATIC)
segment.randomisation_percent = -1
rule = VisitCountRule()
form = form_with_data(segment, rule)
assert not form.is_valid()
@pytest.mark.django_db
def test_randomisation_percentage_max_100(site, client, mocker, django_user_model):
segment = SegmentFactory.build(type=Segment.TYPE_STATIC)
segment.randomisation_percent = 101
rule = VisitCountRule()
form = form_with_data(segment, rule)
assert not form.is_valid()
@pytest.mark.django_db
def test_in_segment_if_random_is_below_percentage(site, client, mocker, user):
segment = SegmentFactory.build(type=Segment.TYPE_STATIC, count=1,
randomisation_percent=40)
rule = VisitCountRule(counted_page=site.root_page)
form = form_with_data(segment, rule)
instance = form.save()
mocker.patch('random.randint', return_value=39)
session = client.session
session.save()
client.force_login(user)
client.get(site.root_page.url)
assert user in instance.static_users.all()
@pytest.mark.django_db
def test_not_in_segment_if_random_is_above_percentage(site, client, mocker, user):
segment = SegmentFactory.build(type=Segment.TYPE_STATIC, count=1,
randomisation_percent=40)
rule = VisitCountRule(counted_page=site.root_page)
form = form_with_data(segment, rule)
instance = form.save()
mocker.patch('random.randint', return_value=41)
session = client.session
session.save()
client.force_login(user)
client.get(site.root_page.url)
assert user not in instance.static_users.all()
@pytest.mark.django_db
def test_not_in_segment_if_percentage_is_0(site, client, mocker, user):
segment = SegmentFactory.build(type=Segment.TYPE_STATIC, count=1,
randomisation_percent=0)
rule = VisitCountRule(counted_page=site.root_page)
form = form_with_data(segment, rule)
instance = form.save()
session = client.session
session.save()
client.force_login(user)
client.get(site.root_page.url)
assert user not in instance.static_users.all()
@pytest.mark.django_db
def test_always_in_segment_if_percentage_is_100(site, client, mocker, user):
segment = SegmentFactory.build(type=Segment.TYPE_STATIC, count=1,
randomisation_percent=100)
rule = VisitCountRule(counted_page=site.root_page)
form = form_with_data(segment, rule)
instance = form.save()
session = client.session
session.save()
client.force_login(user)
client.get(site.root_page.url)
assert user in instance.static_users.all()
@pytest.mark.django_db
def test_not_added_to_static_segment_at_creation_if_random_above_percent(site, client, mocker, user):
session = client.session
session.save()
client.force_login(user)
client.get(site.root_page.url)
mocker.patch('random.randint', return_value=41)
segment = SegmentFactory.build(type=Segment.TYPE_STATIC, randomisation_percent=40)
rule = VisitCountRule(counted_page=site.root_page)
form = form_with_data(segment, rule)
instance = form.save()
assert user not in instance.static_users.all()
@pytest.mark.django_db
def test_added_to_static_segment_at_creation_if_random_below_percent(site, client, mocker, user):
session = client.session
session.save()
client.force_login(user)
client.get(site.root_page.url)
mocker.patch('random.randint', return_value=39)
segment = SegmentFactory.build(type=Segment.TYPE_STATIC, randomisation_percent=40)
rule = VisitCountRule(counted_page=site.root_page)
form = form_with_data(segment, rule)
instance = form.save()
assert user in instance.static_users.all()
@pytest.mark.django_db
def test_matched_user_count_added_to_segment_at_creation(site, client, mocker, django_user_model):
django_user_model.objects.create(username='first')
django_user_model.objects.create(username='second')
segment = SegmentFactory.build(type=Segment.TYPE_STATIC)
rule = VisitCountRule()
form = form_with_data(segment, rule)
mock_test_user = mocker.patch('wagtail_personalisation.rules.VisitCountRule.test_user', return_value=True)
instance = form.save()
assert mock_test_user.call_count == 2
instance.matched_users_count = 2
@pytest.mark.django_db
def test_count_users_matching_static_rules(site, client, django_user_model):
class TestStaticRule(AbstractBaseRule):
static = True
class Meta:
app_label = 'wagtail_personalisation'
def test_user(self, request, user):
return True
django_user_model.objects.create(username='first')
django_user_model.objects.create(username='second')
segment = SegmentFactory.build(type=Segment.TYPE_STATIC)
rule = TestStaticRule()
form = form_with_data(segment, rule)
assert form.count_matching_users([rule], True) is 2
@pytest.mark.django_db
def test_count_matching_users_excludes_staff(site, client, django_user_model):
class TestStaticRule(AbstractBaseRule):
static = True
class Meta:
app_label = 'wagtail_personalisation'
def test_user(self, request, user):
return True
django_user_model.objects.create(username='first')
django_user_model.objects.create(username='second', is_staff=True)
segment = SegmentFactory.build(type=Segment.TYPE_STATIC)
rule = TestStaticRule()
form = form_with_data(segment, rule)
assert form.count_matching_users([rule], True) is 1
@pytest.mark.django_db
def test_count_matching_users_excludes_inactive(site, client, django_user_model):
class TestStaticRule(AbstractBaseRule):
static = True
class Meta:
app_label = 'wagtail_personalisation'
def test_user(self, request, user):
return True
django_user_model.objects.create(username='first')
django_user_model.objects.create(username='second', is_active=False)
segment = SegmentFactory.build(type=Segment.TYPE_STATIC)
rule = TestStaticRule()
form = form_with_data(segment, rule)
assert form.count_matching_users([rule], True) is 1
@pytest.mark.django_db
def test_count_matching_users_only_counts_static_rules(site, client, django_user_model):
class TestStaticRule(AbstractBaseRule):
class Meta:
app_label = 'wagtail_personalisation'
def test_user(self, request, user):
return True
django_user_model.objects.create(username='first')
django_user_model.objects.create(username='second')
segment = SegmentFactory.build(type=Segment.TYPE_STATIC)
rule = TestStaticRule()
form = form_with_data(segment, rule)
assert form.count_matching_users([rule], True) is 0
@pytest.mark.django_db
def test_count_matching_users_handles_match_any(site, client, django_user_model):
class TestStaticRuleFirst(AbstractBaseRule):
static = True
class Meta:
app_label = 'wagtail_personalisation'
def test_user(self, request, user):
if user.username == 'first':
return True
return False
class TestStaticRuleSecond(AbstractBaseRule):
static = True
class Meta:
app_label = 'wagtail_personalisation'
def test_user(self, request, user):
if user.username == 'second':
return True
return False
django_user_model.objects.create(username='first')
django_user_model.objects.create(username='second')
segment = SegmentFactory.build(type=Segment.TYPE_STATIC)
first_rule = TestStaticRuleFirst()
second_rule = TestStaticRuleSecond()
form = form_with_data(segment, first_rule, second_rule)
assert form.count_matching_users([first_rule, second_rule], True) is 2
@pytest.mark.django_db
def test_count_matching_users_handles_match_all(site, client, django_user_model):
class TestStaticRuleFirst(AbstractBaseRule):
static = True
class Meta:
app_label = 'wagtail_personalisation'
def test_user(self, request, user):
if user.username == 'first':
return True
return False
class TestStaticRuleContainsS(AbstractBaseRule):
static = True
class Meta:
app_label = 'wagtail_personalisation'
def test_user(self, request, user):
if 's' in user.username:
return True
return False
django_user_model.objects.create(username='first')
django_user_model.objects.create(username='second')
segment = SegmentFactory.build(type=Segment.TYPE_STATIC)
first_rule = TestStaticRuleFirst()
s_rule = TestStaticRuleContainsS()
form = form_with_data(segment, first_rule, s_rule)
assert form.count_matching_users([first_rule, s_rule], False) is 1

View File

@ -1,4 +1,5 @@
from wagtail_personalisation.utils import impersonate_other_page from wagtail_personalisation.utils import (
exclude_variants, impersonate_other_page)
class Page(object): class Page(object):
@ -19,3 +20,40 @@ def test_impersonate_other_page():
impersonate_other_page(page, other_page) impersonate_other_page(page, other_page)
assert page == other_page assert page == other_page
class Metadata(object):
def __init__(self, is_canonical=True):
self.is_canonical = is_canonical
class PersonalisationMetadataPage(object):
def __init__(self):
self.personalisation_metadata = Metadata()
def test_exclude_variants_includes_pages_with_no_metadata_property():
page = PersonalisationMetadataPage()
del page.personalisation_metadata
result = exclude_variants([page])
assert result == [page]
def test_exclude_variants_includes_pages_with_metadata_none():
page = PersonalisationMetadataPage()
page.personalisation_metadata = None
result = exclude_variants([page])
assert result == [page]
def test_exclude_variants_includes_pages_with_metadata_canonical():
page = PersonalisationMetadataPage()
result = exclude_variants([page])
assert result == [page]
def test_exclude_variants_excludes_pages_with_metadata_not_canonical():
page = PersonalisationMetadataPage()
page.personalisation_metadata.is_canonical = False
result = exclude_variants([page])
assert result == []

13
tox.ini
View File

@ -1,18 +1,15 @@
[tox] [tox]
envlist = py{27,35,36}-django{19,110,111}-wagtail{19,110},lint envlist = py{27}-django{111}-wagtail{113},lint
[testenv] [testenv]
commands = coverage run --parallel -m pytest {posargs} commands = coverage run --parallel -m pytest {posargs}
extras = test extras = test
deps = deps =
django19: django>=1.9,<1.10
django110: django>=1.10<1.11
django111: django>=1.11,<1.12 django111: django>=1.11,<1.12
wagtail19: wagtail>=1.9,<1.10 wagtail19: wagtail>=1.13,<1.14
wagtail110: wagtail>=1.10,<1.11
[testenv:coverage-report] [testenv:coverage-report]
basepython = python3.5 basepython = python2.7
deps = coverage deps = coverage
pip_pre = true pip_pre = true
skip_install = true skip_install = true
@ -22,8 +19,8 @@ commands =
[testenv:lint] [testenv:lint]
basepython = python3.5 basepython = python2.7
deps = flake8 deps = flake8==3.5.0
commands = commands =
flake8 src tests setup.py flake8 src tests setup.py
isort -q --recursive --diff src/ tests/ isort -q --recursive --diff src/ tests/