diff --git a/.travis.yml b/.travis.yml index a28ac85..3a78086 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,33 +4,11 @@ language: python matrix: include: - # Django 1.9, Wagtail 1.9 - python: 2.7 - env: TOXENV=py27-django19-wagtail19 - - python: 3.5 - env: TOXENV=py35-django19-wagtail19 - - python: 3.6 - env: TOXENV=py36-django19-wagtail19 + env: lint - # Django 1.10, Wagtail 1.10 - python: 2.7 - env: TOXENV=py27-django110-wagtail110 - - 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 + env: TOXENV=py27-django111-wagtail113 install: - pip install tox codecov @@ -41,3 +19,13 @@ script: after_success: - tox -e coverage-report - codecov + +deploy: + provider: pypi + distributions: sdist bdist_wheel + user: praekelt.org + password: + secure: IxPnc95OFNQsl7kFfLlLc38KfHh/W79VXnhEkdb2x1GZznOsG167QlhpAnyXIJysCQxgMMwLMuPOOdk1WIxOpGNM1/M80hNzPAfxMYWPuwposDdwoIc8SyREPJt16BXWAY+rAH8SHxld9p1YdRbOEPSSglODe4dCmQWsCbKjV3aKv+gZxBvf6pMxUglp2fBIlCwrE77MyMYh9iW0AGzD6atC2xN9NgAm4f+2WFlKCUL/MztLhNWdvWEiibQav6Tts7p8tWrsBVzywDW2IOy3O0ihPgRdISZ7QrxhiJTjlHYPAy4eRGOnYSQXvp6Dy8ONE5a6Uv5g3Xw2UmQo85sSMUs2VxR0G7d+PQgL0A7ENBQ5i7eSAFHYs8EswiGilW2A7mM4qRXwg9URLelYSdkM+aNXvR+25dCsXakiO4NjCz/BPgNzxPeQLlBdxR5vHugeM/XYuhy6CHlZrR/uueaO0X8RyhJcqeDjBy58IrwYS3Mpj7QCfBpQ9PqsqXEWV9BKwKiBXM2+3hkhawFDWa0GW2PDbORKtSLy/ORfGLx5Y9qxQYKEGvFQA3iqkTjrLj+KeUziKtuvEAcvsdBIJVIxeoHwdl+qqxEm8A7YuRBnWVxWc3jE6wBXboeFP92gVe/ueoXmY22riK9Ja0pli3TyNga8by9WM8Qp4D2ZqkVXHwg= + on: + tags: true + condition: $TOXENV = py27-django111-wagtail113 diff --git a/CHANGES b/CHANGES index 1c77492..a5f1c2b 100644 --- a/CHANGES +++ b/CHANGES @@ -1,3 +1,50 @@ +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) ================== diff --git a/MANIFEST.in b/MANIFEST.in index d0a6bf1..bb487c9 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,6 @@ include README.rst -recursive-include src \ No newline at end of file +recursive-include src * + +recursive-exclude src __pycache__ +recursive-exclude src *.py[co] diff --git a/docs/conf.py b/docs/conf.py index 965ca75..1a7483a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -55,10 +55,10 @@ author = 'Lab Digital BV' # built documents. # # The short X.Y version. -version = '0.9.1' +version = '0.11.3' # The full version, including alpha/beta/rc tags. -release = '0.9.1' +release = '0.11.3' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/frontend/scss/dashboard.scss b/frontend/scss/dashboard.scss index 6f5147c..bb49e69 100644 --- a/frontend/scss/dashboard.scss +++ b/frontend/scss/dashboard.scss @@ -86,6 +86,11 @@ padding: 0; margin: 0; list-style: none; + .stat_card { + display: inline-block; + margin-bottom: 5px; + margin-right: 10px; + } } .block_container .block span.icon::before { @@ -93,11 +98,6 @@ 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; diff --git a/setup.cfg b/setup.cfg index 8bb9e92..5faf6ce 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.9.1 +current_version = 0.11.3 commit = true tag = true tag_name = {new_version} @@ -15,14 +15,14 @@ python_paths = . [flake8] ignore = E731 max-line-length = 120 -exclude = +exclude = src/**/migrations/*.py [wheel] universal = 1 [coverage:run] -omit = +omit = src/**/migrations/*.py [bumpversion:file:setup.py] diff --git a/setup.py b/setup.py index c701ef0..1c0ce9c 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,6 @@ import re from setuptools import find_packages, setup - install_requires = [ 'wagtail>=2.0,<2.1', 'user-agents>=1.0.1', @@ -21,6 +20,7 @@ tests_require = [ 'pytest-sugar==0.9.1', 'pytest==3.4.2', 'wagtail_factories==1.0.0', + 'pytest-mock==1.6.3', ] docs_require = [ @@ -33,11 +33,11 @@ with open('README.rst') as fh: setup( name='wagtail-personalisation', - version='0.9.1', + 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={ @@ -55,16 +55,10 @@ setup( 'Intended Audience :: Developers', 'License :: OSI Approved :: BSD License', 'Operating System :: OS Independent', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Framework :: Django', - 'Framework :: Django :: 1.9', - 'Framework :: Django :: 1.10', - 'Framework :: Django :: 1.11', + 'Framework :: Django :: 2', 'Topic :: Internet :: WWW/HTTP :: Site Management', ], ) diff --git a/src/wagtail_personalisation/adapters.py b/src/wagtail_personalisation/adapters.py index 933f744..7c84f42 100644 --- a/src/wagtail_personalisation/adapters.py +++ b/src/wagtail_personalisation/adapters.py @@ -66,17 +66,21 @@ class SessionSegmentsAdapter(BaseSegmentsAdapter): self.request.session.setdefault('segments', []) self._segment_cache = None - def get_segments(self): + 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 = ( @@ -86,14 +90,17 @@ class SessionSegmentsAdapter(BaseSegmentsAdapter): .in_bulk(segment_ids)) retval = [segments[pk] for pk in segment_ids if pk in segments] - self._segment_cache = retval + if key == "segments": + self._segment_cache = retval return retval - 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 = [] @@ -108,8 +115,9 @@ class SessionSegmentsAdapter(BaseSegmentsAdapter): serialized_segments.append(serialized) segment_ids.add(segment.pk) - self.request.session['segments'] = serialized_segments - self._segment_cache = cache_segments + self.request.session[key] = serialized_segments + if key == "segments": + self._segment_cache = cache_segments def get_segment_by_id(self, segment_id): """Find and return a single segment from the request session. @@ -132,18 +140,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': 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: @@ -170,21 +179,37 @@ class SessionSegmentsAdapter(BaseSegmentsAdapter): rule_models = AbstractBaseRule.get_descendant_models() current_segments = self.get_segments() + excluded_segments = self.get_segments("excluded_segments") # Run tests on all remaining enabled segments to verify applicability. additional_segments = [] for segment in enabled_segments: - 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: + 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() diff --git a/src/wagtail_personalisation/admin_urls.py b/src/wagtail_personalisation/admin_urls.py index 87848c0..e5c978c 100644 --- a/src/wagtail_personalisation/admin_urls.py +++ b/src/wagtail_personalisation/admin_urls.py @@ -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[0-9]+)$', + views.segment_user_data, name='segment_user_data'), ] diff --git a/src/wagtail_personalisation/forms.py b/src/wagtail_personalisation/forms.py new file mode 100644 index 0000000..02555eb --- /dev/null +++ b/src/wagtail_personalisation/forms.py @@ -0,0 +1,138 @@ +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.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 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: + users_to_exclude.append(user) + + instance.matched_users_count = matched_count + instance.matched_count_updated_at = datetime.now() + instance.static_users.add(*users_to_add) + instance.excluded_users.add(*users_to_exclude) + + return instance + + @property + def media(self): + media = super(SegmentAdminForm, self).media + media.add_js( + [static('js/segment_form_control.js')] + ) + return media diff --git a/src/wagtail_personalisation/migrations/0011_personalisablepagemetadata.py b/src/wagtail_personalisation/migrations/0011_personalisablepagemetadata.py index e8eccad..a4834f5 100644 --- a/src/wagtail_personalisation/migrations/0011_personalisablepagemetadata.py +++ b/src/wagtail_personalisation/migrations/0011_personalisablepagemetadata.py @@ -2,8 +2,8 @@ # Generated by Django 1.11.1 on 2017-05-31 14:28 from __future__ import unicode_literals -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/src/wagtail_personalisation/migrations/0013_add_dynamic_static_to_segment.py b/src/wagtail_personalisation/migrations/0013_add_dynamic_static_to_segment.py new file mode 100644 index 0000000..6196fa8 --- /dev/null +++ b/src/wagtail_personalisation/migrations/0013_add_dynamic_static_to_segment.py @@ -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

Dynamic: Users in this segment will change\n as more or less meet the rules specified in the segment.\n
Static: 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), + ), + ] diff --git a/src/wagtail_personalisation/migrations/0015_static_users.py b/src/wagtail_personalisation/migrations/0015_static_users.py new file mode 100644 index 0000000..ea76aa8 --- /dev/null +++ b/src/wagtail_personalisation/migrations/0015_static_users.py @@ -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), + ), + ] diff --git a/src/wagtail_personalisation/migrations/0016_auto_20180125_0918.py b/src/wagtail_personalisation/migrations/0016_auto_20180125_0918.py new file mode 100644 index 0000000..ae7bdd1 --- /dev/null +++ b/src/wagtail_personalisation/migrations/0016_auto_20180125_0918.py @@ -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), + ), + ] diff --git a/src/wagtail_personalisation/migrations/0017_segment_randomisation_percent.py b/src/wagtail_personalisation/migrations/0017_segment_randomisation_percent.py new file mode 100644 index 0000000..bd68335 --- /dev/null +++ b/src/wagtail_personalisation/migrations/0017_segment_randomisation_percent.py @@ -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)]), + ), + ] diff --git a/src/wagtail_personalisation/migrations/0018_segment_excluded_users.py b/src/wagtail_personalisation/migrations/0018_segment_excluded_users.py new file mode 100644 index 0000000..bafa477 --- /dev/null +++ b/src/wagtail_personalisation/migrations/0018_segment_excluded_users.py @@ -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), + ), + ] diff --git a/src/wagtail_personalisation/models.py b/src/wagtail_personalisation/models.py index 64dfe61..0e61e77 100644 --- a/src/wagtail_personalisation/models.py +++ b/src/wagtail_personalisation/models.py @@ -1,9 +1,14 @@ 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.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.admin.edit_handlers import ( @@ -13,6 +18,8 @@ from wagtail.core.models import Page 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): @@ -30,6 +37,14 @@ class Segment(ClusterableModel): (STATUS_DISABLED, _('Disabled')), ) + TYPE_DYNAMIC = 'dynamic' + TYPE_STATIC = 'static' + + TYPE_CHOICES = ( + (TYPE_DYNAMIC, _('Dynamic')), + (TYPE_STATIC, _('Static')), + ) + name = models.CharField(max_length=255) create_date = models.DateTimeField(auto_now_add=True) edit_date = models.DateTimeField(auto_now=True) @@ -44,9 +59,54 @@ class Segment(ClusterableModel): 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(_(""" +

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. + """)) + ) + 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([ @@ -56,11 +116,17 @@ class Segment(ClusterableModel): 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_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__() ], heading=_("Rules")), ] @@ -70,6 +136,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()) @@ -102,6 +185,19 @@ class Segment(ClusterableModel): if 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): """The personalisable page model. Allows creation of variants with linked diff --git a/src/wagtail_personalisation/rules.py b/src/wagtail_personalisation/rules.py index 223f17c..c012cd0 100644 --- a/src/wagtail_personalisation/rules.py +++ b/src/wagtail_personalisation/rules.py @@ -2,22 +2,30 @@ 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.core.exceptions import ObjectDoesNotExist +from django.contrib.sessions.models import Session from django.db import models from django.template.defaultfilters import slugify from django.utils.encoding import force_text, python_2_unicode_compatible from django.utils.translation import ugettext_lazy as _ +from django.test.client import RequestFactory from modelcluster.fields import ParentalKey from user_agents import parse 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', @@ -190,6 +198,7 @@ class VisitCountRule(AbstractBaseRule): """ icon = 'fa-calculator' + static = True OPERATOR_CHOICES = ( ('more_than', _("More than")), @@ -218,16 +227,46 @@ class VisitCountRule(AbstractBaseRule): class Meta: verbose_name = _('Visit count Rule') - def test_user(self, request): + 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 @@ -250,6 +289,28 @@ class VisitCountRule(AbstractBaseRule): ), } + def get_column_header(self): + return "Visit count - %s" % self.counted_page + + def get_user_info_string(self, user): + # Local import for cyclic import + from wagtail_personalisation.adapters import ( + get_segment_adapter, SessionSegmentsAdapter, SEGMENT_ADAPTER_CLASS) + + # Create a fake request so we can use the adapter + request = RequestFactory().get('/') + request.user = user + + # If we're using the session adapter check for an active session + if SEGMENT_ADAPTER_CLASS == SessionSegmentsAdapter: + request.session = self._get_user_session(user) + else: + request.session = SessionStore() + + adapter = get_segment_adapter(request) + visit_count = adapter.get_visit_count(self.counted_page) + return str(visit_count) + class QueryRule(AbstractBaseRule): """Query rule to segment users based on matching queries. diff --git a/src/wagtail_personalisation/static/css/dashboard.css b/src/wagtail_personalisation/static/css/dashboard.css index b1dc0ed..3731509 100644 --- a/src/wagtail_personalisation/static/css/dashboard.css +++ b/src/wagtail_personalisation/static/css/dashboard.css @@ -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*/ \ No newline at end of file diff --git a/src/wagtail_personalisation/static/css/dashboard.css.map b/src/wagtail_personalisation/static/css/dashboard.css.map index b51f920..6c3c9c8 100644 --- a/src/wagtail_personalisation/static/css/dashboard.css.map +++ b/src/wagtail_personalisation/static/css/dashboard.css.map @@ -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":""} \ No newline at end of file +{"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":""} \ No newline at end of file diff --git a/src/wagtail_personalisation/static/js/segment_form_control.js b/src/wagtail_personalisation/static/js/segment_form_control.js new file mode 100644 index 0000000..269cd4b --- /dev/null +++ b/src/wagtail_personalisation/static/js/segment_form_control.js @@ -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); diff --git a/src/wagtail_personalisation/templates/modeladmin/wagtail_personalisation/segment/dashboard.html b/src/wagtail_personalisation/templates/modeladmin/wagtail_personalisation/segment/dashboard.html index f3e2255..0e24ba1 100644 --- a/src/wagtail_personalisation/templates/modeladmin/wagtail_personalisation/segment/dashboard.html +++ b/src/wagtail_personalisation/templates/modeladmin/wagtail_personalisation/segment/dashboard.html @@ -22,25 +22,38 @@
{% if all_count %} {% for segment in object_list %} -
+

{{ segment }}

    -
  • +
  • {% trans "This segment has been visited" %} {{ segment.visit_count|localize }} {% trans "time" %}{{ segment.visit_count|pluralize }}
  • -
  • +
  • {% trans "This segment has been active for" %} {{ segment.enable_date|days_since:segment.disable_date }} {% trans "day" %}{{ segment.enable_date|days_since:segment.disable_date|pluralize }}
  • + {% if segment.is_static %} +
  • + {% trans "This segment is Static" %} + + {{ 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 %} + +
  • + {% endif %}

    -
  • +
  • {% trans "The visitor must match" %} {% if segment.match_any %} {% trans "Any rule" %} @@ -49,7 +62,7 @@ {% endif %}
  • -
  • +
  • {% trans "The persistence of this segment is" %} {% if segment.persistent %} {% trans "Persistent" %} @@ -58,8 +71,15 @@ {% endif %}
  • + {% if segment.randomisation_percent is not None %} +
  • + {{ segment.randomisation_percent }} % + {% trans "Chance that visitors matching the rules are added to the segment" %} +
  • + {% endif %} + {% for rule in segment.get_rules %} -
  • +
  • {{ rule.description.title }} {% if rule.description.code %}
    {{ rule.description.value }}
    @@ -68,6 +88,11 @@ {% endif %}
  • {% endfor %} + {% if segment.matched_users_count > 0 %} +
  • + {{ segment.matched_users_count }} {% trans "user" %}{{ segment.matched_users_count|pluralize }} {% trans "were possible matches for this segment at creation" %} +
  • + {% endif %}
@@ -78,7 +103,10 @@ {% elif segment.status == segment.STATUS_ENABLED %}
  • disable
  • {% endif %} -
  • configure this
  • +
  • configure this
  • + {% if segment.is_static %} +
  • download users csv
  • + {% endif %} {% endif %}
    diff --git a/src/wagtail_personalisation/views.py b/src/wagtail_personalisation/views.py index 1dfce6e..d8f6b36 100644 --- a/src/wagtail_personalisation/views.py +++ b/src/wagtail_personalisation/views.py @@ -1,9 +1,12 @@ from __future__ import absolute_import, unicode_literals +import csv + from django import forms -from django.urls import reverse -from django.http import HttpResponseForbidden, HttpResponseRedirect +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 @@ -139,3 +142,32 @@ def copy_page_view(request, page_id, segment_id): return HttpResponseRedirect(edit_url) return HttpResponseForbidden() + + +# CSV download views +def segment_user_data(request, segment_id): + if request.user.has_perm('wagtailadmin.access_admin'): + segment = get_object_or_404(Segment, pk=segment_id) + + response = HttpResponse(content_type='text/csv; charset=utf-8') + response['Content-Disposition'] = \ + 'attachment;filename=segment-%s-users.csv' % str(segment_id) + + headers = ['Username'] + for rule in segment.get_rules(): + if rule.static: + headers.append(rule.get_column_header()) + + writer = csv.writer(response) + writer.writerow(headers) + + for user in segment.static_users.all(): + row = [user.username] + for rule in segment.get_rules(): + if rule.static: + row.append(rule.get_user_info_string(user)) + writer.writerow(row) + + return response + + return HttpResponseForbidden() diff --git a/src/wagtail_personalisation/wagtail_hooks.py b/src/wagtail_personalisation/wagtail_hooks.py index 0f81b28..3436512 100644 --- a/src/wagtail_personalisation/wagtail_hooks.py +++ b/src/wagtail_personalisation/wagtail_hooks.py @@ -3,11 +3,11 @@ from __future__ import absolute_import, unicode_literals import logging from django.conf.urls import include, url -from django.urls import reverse 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.admin.site_summary import SummaryItem, PagesSummaryItem +from wagtail.admin.site_summary import PagesSummaryItem, SummaryItem from wagtail.admin.widgets import Button, ButtonWithDropdownFromHook from wagtail.core import hooks from wagtail.core.models import Page diff --git a/tests/fixtures.py b/tests/fixtures.py index 03efbd9..9f5adfc 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -44,3 +44,8 @@ class RequestFactory(BaseRequestFactory): request.session = SessionStore() request._messages = FallbackStorage(request) return request + + +@pytest.fixture +def user(django_user_model): + return django_user_model.objects.create(username='user') diff --git a/tests/settings.py b/tests/settings.py index 5f41fd7..9349f5c 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -1,9 +1,6 @@ from __future__ import absolute_import, unicode_literals import os -from pkg_resources import parse_version as V - -import django DATABASES = { @@ -57,15 +54,15 @@ TEMPLATES = [ ] MIDDLEWARE = ( - 'django.middleware.common.CommonMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', - 'wagtail.core.middleware.SiteMiddleware', - ) + 'wagtail.core.middleware.SiteMiddleware', +) INSTALLED_APPS = ( 'wagtail_personalisation', diff --git a/tests/site/pages/migrations/0001_initial.py b/tests/site/pages/migrations/0001_initial.py index 8fa6664..bbd7542 100644 --- a/tests/site/pages/migrations/0001_initial.py +++ b/tests/site/pages/migrations/0001_initial.py @@ -21,7 +21,7 @@ class Migration(migrations.Migration): migrations.CreateModel( name='ContentPage', 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)), ('body', wagtail.core.fields.RichTextField(blank=True, default='')), ], diff --git a/tests/site/pages/migrations/0002_regularpage.py b/tests/site/pages/migrations/0002_regularpage.py index 840e635..8e7bbfb 100644 --- a/tests/site/pages/migrations/0002_regularpage.py +++ b/tests/site/pages/migrations/0002_regularpage.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.11.1 on 2017-06-02 04:26 from __future__ import unicode_literals -from django.db import migrations, models import django.db.models.deletion + import wagtail.core.fields +from django.db import migrations, models class Migration(migrations.Migration): @@ -18,7 +18,7 @@ class Migration(migrations.Migration): migrations.CreateModel( name='RegularPage', 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)), ('body', wagtail.core.fields.RichTextField(blank=True, default='')), ], diff --git a/tests/unit/test_factories.py b/tests/unit/test_factories.py index a29892f..0ce5b7c 100644 --- a/tests/unit/test_factories.py +++ b/tests/unit/test_factories.py @@ -4,16 +4,14 @@ import datetime import pytest -from tests.factories.page import ContentPageFactory -from tests.factories.rule import ( - DayRuleFactory, DeviceRuleFactory, ReferralRuleFactory, TimeRuleFactory) +from tests.factories.rule import ReferralRuleFactory, QueryRuleFactory from tests.factories.segment import SegmentFactory -from tests.factories.site import SiteFactory from wagtail_personalisation.models import Segment from wagtail_personalisation.rules import TimeRule # Factory tests + @pytest.mark.django_db def test_segment_create(): factoried_segment = SegmentFactory() @@ -27,8 +25,6 @@ def test_segment_create(): assert factoried_segment.status == segment.status - - @pytest.mark.django_db def test_referral_rule_create(): segment = SegmentFactory(name='Referral') @@ -37,3 +33,15 @@ def test_referral_rule_create(): segment=segment) 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' diff --git a/tests/unit/test_rules_time.py b/tests/unit/test_rules_time.py index 33dec18..f1fa005 100644 --- a/tests/unit/test_rules_time.py +++ b/tests/unit/test_rules_time.py @@ -16,6 +16,8 @@ def test_time_rule_create(): segment=segment) assert time_rule.start_time == datetime.time(8, 0, 0) + + @pytest.mark.django_db @freeze_time("10:00:00") def test_requesttime_segment(client, site): diff --git a/tests/unit/test_rules_visitcount.py b/tests/unit/test_rules_visitcount.py index a4d7d60..321f043 100644 --- a/tests/unit/test_rules_visitcount.py +++ b/tests/unit/test_rules_visitcount.py @@ -1,5 +1,9 @@ import pytest +from tests.factories.rule import VisitCountRuleFactory +from tests.factories.segment import SegmentFactory +from wagtail_personalisation.rules import VisitCountRule + @pytest.mark.django_db def test_visit_count(site, client): @@ -20,3 +24,56 @@ def test_visit_count(site, client): visit_count = client.session['visit_count'] assert visit_count[0]['count'] == 2 assert visit_count[1]['count'] == 1 + + +@pytest.mark.django_db +def test_call_test_user_on_invalid_rule_fails(site, user, mocker): + rule = VisitCountRule() + assert not (rule.test_user(None, user)) + + +@pytest.mark.django_db +def test_visit_count_call_test_user_with_user(site, client, user): + segment = SegmentFactory(name='VisitCount') + rule = VisitCountRuleFactory(counted_page=site.root_page, segment=segment) + + session = client.session + session['visit_count'] = [{'path': '/', 'count': 2}] + session.save() + client.force_login(user) + + assert rule.test_user(None, user) + + +@pytest.mark.django_db +def test_visit_count_call_test_user_with_user_or_request_fails(site, client, user): + segment = SegmentFactory(name='VisitCount') + rule = VisitCountRuleFactory(counted_page=site.root_page, segment=segment) + + session = client.session + session['visit_count'] = [{'path': '/', 'count': 2}] + session.save() + client.force_login(user) + + assert not rule.test_user(None) + + +@pytest.mark.django_db +def test_get_column_header(site): + segment = SegmentFactory(name='VisitCount') + rule = VisitCountRuleFactory(counted_page=site.root_page, segment=segment) + + assert rule.get_column_header() == 'Visit count - Test page' + + +@pytest.mark.django_db +def test_get_user_info_string_returns_count(site, client, user): + segment = SegmentFactory(name='VisitCount') + rule = VisitCountRuleFactory(counted_page=site.root_page, segment=segment) + + session = client.session + session['visit_count'] = [{'path': '/', 'count': 2}] + session.save() + client.force_login(user) + + assert rule.get_user_info_string(user) == '2' diff --git a/tests/unit/test_static_dynamic_segments.py b/tests/unit/test_static_dynamic_segments.py new file mode 100644 index 0000000..d4dead8 --- /dev/null +++ b/tests/unit/test_static_dynamic_segments.py @@ -0,0 +1,574 @@ +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 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_user_added_to_static_segment_at_creation(site, user, mocker): + segment = SegmentFactory.build(type=Segment.TYPE_STATIC) + rule = VisitCountRule(counted_page=site.root_page) + form = form_with_data(segment, rule) + mocker.patch('wagtail_personalisation.rules.VisitCountRule.test_user', return_value=True) + instance = form.save() + + assert user in instance.static_users.all() + + +@pytest.mark.django_db +def test_user_not_added_to_full_static_segment_at_creation(site, django_user_model, mocker): + django_user_model.objects.create(username='first') + django_user_model.objects.create(username='second') + segment = SegmentFactory.build(type=Segment.TYPE_STATIC, count=1) + rule = VisitCountRule(counted_page=site.root_page) + form = form_with_data(segment, rule) + mocker.patch('wagtail_personalisation.rules.VisitCountRule.test_user', + side_effect=[True, True]) + instance = form.save() + + assert len(instance.static_users.all()) == 1 + + +@pytest.mark.django_db +def test_anonymous_user_not_added_to_static_segment_at_creation(site, client, mocker): + session = client.session + session.save() + client.get(site.root_page.url) + + segment = SegmentFactory.build(type=Segment.TYPE_STATIC) + rule = VisitCountRule(counted_page=site.root_page) + form = form_with_data(segment, rule) + mock_test_rule = mocker.patch('wagtail_personalisation.adapters.SessionSegmentsAdapter._test_rules') + instance = form.save() + + assert not instance.static_users.all() + assert mock_test_rule.call_count == 0 + + +@pytest.mark.django_db +def test_match_any_correct_populates(site, django_user_model, mocker): + user = django_user_model.objects.create(username='first') + other_user = django_user_model.objects.create(username='second') + other_page = site.root_page.get_last_child() + + segment = SegmentFactory.build(type=Segment.TYPE_STATIC, match_any=True) + rule_1 = VisitCountRule(counted_page=site.root_page) + rule_2 = VisitCountRule(counted_page=other_page) + form = form_with_data(segment, rule_1, rule_2) + mocker.patch('wagtail_personalisation.rules.VisitCountRule.test_user', side_effect=[True, False, True, False]) + instance = form.save() + + assert 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, mocker): + 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) + + mock_test_rule = mocker.patch('wagtail_personalisation.adapters.SessionSegmentsAdapter._test_rules') + instance = form.save() + + assert not instance.static_users.all() + assert mock_test_rule.call_count == 0 + + +@pytest.mark.django_db +def test_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(mocker): + 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) + mock_test_rule = mocker.patch('wagtail_personalisation.adapters.SessionSegmentsAdapter._test_rules') + instance = form.save() + + assert not instance.static_users.all() + assert mock_test_rule.call_count == 0 + + +@pytest.mark.django_db +def test_does_not_calculate_the_segment_again(site, client, mocker, user): + segment = SegmentFactory.build(type=Segment.TYPE_STATIC, count=2) + rule = VisitCountRule(counted_page=site.root_page, segment=segment) + form = form_with_data(segment, rule) + mocker.patch('wagtail_personalisation.rules.VisitCountRule.test_user', return_value=True) + instance = form.save() + + assert user in instance.static_users.all() + + mock_test_rule = mocker.patch('wagtail_personalisation.adapters.SessionSegmentsAdapter._test_rules') + session = client.session + session.save() + client.force_login(user) + client.get(site.root_page.url) + assert mock_test_rule.call_count == 0 + + +@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_static_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 instance.id == client.session['segments'][0]['id'] + assert user in instance.static_users.all() + assert user not in instance.excluded_users.all() + + +@pytest.mark.django_db +def test_not_in_static_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 len(client.session['segments']) == 0 + assert user not in instance.static_users.all() + assert user in instance.excluded_users.all() + + +@pytest.mark.django_db +def test_offered_dynamic_segment_if_random_is_below_percentage(site, client, mocker): + segment = SegmentFactory.build(type=Segment.TYPE_DYNAMIC, + 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.get(site.root_page.url) + + assert len(client.session['excluded_segments']) == 0 + assert instance.id == client.session['segments'][0]['id'] + + +@pytest.mark.django_db +def test_not_offered_dynamic_segment_if_random_is_above_percentage(site, client, mocker): + segment = SegmentFactory.build(type=Segment.TYPE_DYNAMIC, + 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.get(site.root_page.url) + + assert len(client.session['segments']) == 0 + assert instance.id == client.session['excluded_segments'][0]['id'] + + +@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 len(client.session['segments']) == 0 + assert user not in instance.static_users.all() + assert user in instance.excluded_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 instance.id == client.session['segments'][0]['id'] + assert user in instance.static_users.all() + assert user not in instance.excluded_users.all() + + +@pytest.mark.django_db +def test_not_added_to_static_segment_at_creation_if_random_above_percent(site, mocker, user): + mocker.patch('random.randint', return_value=41) + segment = SegmentFactory.build(type=Segment.TYPE_STATIC, randomisation_percent=40) + rule = VisitCountRule(counted_page=site.root_page) + form = form_with_data(segment, rule) + mocker.patch('wagtail_personalisation.rules.VisitCountRule.test_user', return_value=True) + instance = form.save() + + assert user not in instance.static_users.all() + assert user in instance.excluded_users.all() + + +@pytest.mark.django_db +def test_added_to_static_segment_at_creation_if_random_below_percent(site, mocker, user): + mocker.patch('random.randint', return_value=39) + segment = SegmentFactory.build(type=Segment.TYPE_STATIC, randomisation_percent=40) + rule = VisitCountRule(counted_page=site.root_page) + form = form_with_data(segment, rule) + mocker.patch('wagtail_personalisation.rules.VisitCountRule.test_user', return_value=True) + instance = form.save() + + assert user in instance.static_users.all() + assert user not in instance.excluded_users.all() + + +@pytest.mark.django_db +def test_rules_check_skipped_if_user_in_excluded(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() + instance.excluded_users.add(user) + instance.save + + mock_test_rule = mocker.patch( + 'wagtail_personalisation.adapters.SessionSegmentsAdapter._test_rules') + + session = client.session + session.save() + client.force_login(user) + client.get(site.root_page.url) + + assert mock_test_rule.call_count == 0 + assert len(client.session['segments']) == 0 + assert user not in instance.static_users.all() + assert user in instance.excluded_users.all() + + +@pytest.mark.django_db +def test_rules_check_skipped_if_dynamic_segment_in_excluded(site, client, mocker, user): + segment = SegmentFactory.build(type=Segment.TYPE_DYNAMIC, + randomisation_percent=100) + rule = VisitCountRule(counted_page=site.root_page) + form = form_with_data(segment, rule) + instance = form.save() + instance.persistent = True + instance.save() + + session = client.session + session['excluded_segments'] = [{'id': instance.pk}] + session.save() + + mock_test_rule = mocker.patch( + 'wagtail_personalisation.adapters.SessionSegmentsAdapter._test_rules') + + client.force_login(user) + client.get(site.root_page.url) + + assert mock_test_rule.call_count == 0 + assert len(client.session['segments']) == 0 + + +@pytest.mark.django_db +def test_matched_user_count_added_to_segment_at_creation(site, mocker, django_user_model): + django_user_model.objects.create(username='first') + django_user_model.objects.create(username='second') + + segment = SegmentFactory.build(type=Segment.TYPE_STATIC) + rule = VisitCountRule() + + form = form_with_data(segment, rule) + form.instance.type = Segment.TYPE_STATIC + mock_test_user = mocker.patch('wagtail_personalisation.rules.VisitCountRule.test_user', return_value=True) + instance = form.save() + + 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, 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(counted_page=site.root_page) + form = form_with_data(segment, rule) + mocker.patch('wagtail_personalisation.rules.VisitCountRule.test_user', return_value=True) + + assert form.count_matching_users([rule], True) is 2 + + +@pytest.mark.django_db +def test_count_matching_users_excludes_staff(site, client, mocker, django_user_model): + django_user_model.objects.create(username='first') + django_user_model.objects.create(username='second', is_staff=True) + + segment = SegmentFactory.build(type=Segment.TYPE_STATIC) + rule = VisitCountRule(counted_page=site.root_page) + form = form_with_data(segment, rule) + mock_test_user = mocker.patch('wagtail_personalisation.rules.VisitCountRule.test_user', return_value=True) + + assert form.count_matching_users([rule], True) is 1 + assert mock_test_user.call_count == 1 + + +@pytest.mark.django_db +def test_count_matching_users_excludes_inactive(site, client, mocker, django_user_model): + django_user_model.objects.create(username='first') + django_user_model.objects.create(username='second', is_active=False) + + segment = SegmentFactory.build(type=Segment.TYPE_STATIC) + rule = VisitCountRule(counted_page=site.root_page) + form = form_with_data(segment, rule) + mock_test_user = mocker.patch('wagtail_personalisation.rules.VisitCountRule.test_user', return_value=True) + + assert form.count_matching_users([rule], True) is 1 + assert mock_test_user.call_count == 1 + + +@pytest.mark.django_db +def test_count_matching_users_only_counts_static_rules(site, client, mocker, django_user_model): + django_user_model.objects.create(username='first') + django_user_model.objects.create(username='second') + + segment = SegmentFactory.build(type=Segment.TYPE_STATIC) + rule = TimeRule( + start_time=datetime.time(0, 0, 0), + end_time=datetime.time(23, 59, 59), + segment=segment, + ) + form = form_with_data(segment, rule) + mock_test_user = mocker.patch('wagtail_personalisation.rules.TimeRule.test_user') + + assert form.count_matching_users([rule], True) is 0 + assert mock_test_user.call_count == 0 + + +@pytest.mark.django_db +def test_count_matching_users_handles_match_any(site, client, mocker, django_user_model): + django_user_model.objects.create(username='first') + django_user_model.objects.create(username='second') + + segment = SegmentFactory.build(type=Segment.TYPE_STATIC) + first_rule = VisitCountRule(counted_page=site.root_page) + other_page = site.root_page.get_last_child() + second_rule = VisitCountRule(counted_page=other_page) + form = form_with_data(segment, first_rule, second_rule) + + mock_test_user = mocker.patch( + 'wagtail_personalisation.rules.VisitCountRule.test_user', + side_effect=[True, False, True, False]) + + assert form.count_matching_users([first_rule, second_rule], True) is 2 + mock_test_user.call_count == 4 + + +@pytest.mark.django_db +def test_count_matching_users_handles_match_all(site, client, mocker, django_user_model): + django_user_model.objects.create(username='first') + django_user_model.objects.create(username='second') + + segment = SegmentFactory.build(type=Segment.TYPE_STATIC) + first_rule = VisitCountRule(counted_page=site.root_page) + other_page = site.root_page.get_last_child() + second_rule = VisitCountRule(counted_page=other_page) + form = form_with_data(segment, first_rule, second_rule) + + mock_test_user = mocker.patch( + 'wagtail_personalisation.rules.VisitCountRule.test_user', + side_effect=[True, True, False, True]) + + assert form.count_matching_users([first_rule, second_rule], False) is 1 + mock_test_user.call_count == 4 diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index daab476..f5cee8a 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -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): @@ -19,3 +20,40 @@ def test_impersonate_other_page(): impersonate_other_page(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 == [] diff --git a/tests/unit/test_views.py b/tests/unit/test_views.py new file mode 100644 index 0000000..b2f3cc2 --- /dev/null +++ b/tests/unit/test_views.py @@ -0,0 +1,55 @@ +import pytest + +from django.contrib.auth.models import Permission +from django.core.urlresolvers import reverse +from wagtail_personalisation.models import Segment +from wagtail_personalisation.rules import VisitCountRule + + +@pytest.mark.django_db +def test_segment_user_data_view_requires_admin_access(site, client, django_user_model): + user = django_user_model.objects.create(username='first') + + segment = Segment(type=Segment.TYPE_STATIC, count=1) + segment.save() + + client.force_login(user) + url = reverse('segment:segment_user_data', args=(segment.id,)) + response = client.get(url) + + assert response.status_code == 302 + assert response.url == '/admin/login/?next=%s' % url + + +@pytest.mark.django_db +def test_segment_user_data_view(site, client, mocker, django_user_model): + user1 = django_user_model.objects.create(username='first') + user2 = django_user_model.objects.create(username='second') + admin_user = django_user_model.objects.create(username='admin') + permission = Permission.objects.get(codename='access_admin') + admin_user.user_permissions.add(permission) + + segment = Segment(type=Segment.TYPE_STATIC, count=1) + segment.save() + segment.static_users.add(user1) + segment.static_users.add(user2) + + rule1 = VisitCountRule(counted_page=site.root_page, segment=segment) + rule2 = VisitCountRule(counted_page=site.root_page.get_last_child(), + segment=segment) + rule1.save() + rule2.save() + + mocker.patch('wagtail_personalisation.rules.VisitCountRule.get_user_info_string', + side_effect=[3, 9, 0, 1]) + + client.force_login(admin_user) + response = client.get( + reverse('segment:segment_user_data', args=(segment.id,))) + + assert response.status_code == 200 + data_lines = response.content.decode().split("\n") + + assert data_lines[0] == 'Username,Visit count - Test page,Visit count - Regular page\r' + assert data_lines[1] == 'first,3,9\r' + assert data_lines[2] == 'second,0,1\r' diff --git a/tox.ini b/tox.ini index 112c646..3254a8e 100644 --- a/tox.ini +++ b/tox.ini @@ -17,7 +17,6 @@ commands = coverage combine coverage report - [testenv:lint] basepython = python3.6 deps = flake8