7

Compare commits

..

9 Commits

52 changed files with 1441 additions and 4682 deletions

View File

@ -1,89 +0,0 @@
---
name: Python Tests
on: [push, pull_request]
jobs:
format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.8
uses: actions/setup-python@v1
with:
python-version: 3.8
- name: Install dependencies
run: pip install tox
- name: Validate formatting
run: tox -e format
test:
runs-on: ubuntu-latest
strategy:
max-parallel: 4
matrix:
tox_env:
- py36-dj22-wt211
- py36-dj22-wt212
- py36-dj22-wt213
- py37-dj22-wt211
- py37-dj22-wt212
- py37-dj22-wt213
- py38-dj22-wt211
- py38-dj22-wt212
- py38-dj22-wt213
- py37-dj30-wt211
- py37-dj30-wt212
- py37-dj30-wt213
- py38-dj30-wt211
- py38-dj30-wt212
- py38-dj30-wt213
include:
- python-version: 3.6
tox_env: py36-dj22-wt211
- python-version: 3.6
tox_env: py36-dj22-wt212
- python-version: 3.6
tox_env: py36-dj22-wt213
- python-version: 3.7
tox_env: py37-dj22-wt211
- python-version: 3.7
tox_env: py37-dj22-wt212
- python-version: 3.7
tox_env: py37-dj22-wt213
- python-version: 3.8
tox_env: py38-dj22-wt211
- python-version: 3.8
tox_env: py38-dj22-wt212
- python-version: 3.8
tox_env: py38-dj22-wt213
- python-version: 3.7
tox_env: py37-dj30-wt211
- python-version: 3.7
tox_env: py37-dj30-wt212
- python-version: 3.7
tox_env: py37-dj30-wt213
- python-version: 3.8
tox_env: py38-dj30-wt211
- python-version: 3.8
tox_env: py38-dj30-wt212
- python-version: 3.8
tox_env: py38-dj30-wt213
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v1
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install tox tox-gh-actions
- name: Test with tox
run: tox -e ${{ matrix.tox_env }} --index-url=https://pypi.python.org/simple/
- name: Prepare artifacts
run: mkdir -p .coverage-data && mv .coverage.* .coverage-data/
- uses: actions/upload-artifact@master
with:
name: coverage-data
path: .coverage-data/

1
.gitignore vendored
View File

@ -13,7 +13,6 @@
.vscode/
build/
ve/
dist/
htmlcov/
docs/_build

22
.travis.yml Normal file
View File

@ -0,0 +1,22 @@
---
sudo: false
language: python
matrix:
include:
- python: 3.6
env: lint
- python: 3.6
env: TOXENV=py36-django20-wagtail20
- python: 3.6
env: TOXENV=py36-django20-wagtail21
install:
- pip install tox codecov
script:
- tox
after_success:
- tox -e coverage-report
- codecov

55
CHANGES
View File

@ -1,58 +1,3 @@
0.15.3
=================
- Add wagtail >= 2.15 support with get_context_data override instead of get_context
0.15.2
=================
- Replace staticfiles tag with static
0.15.1
=================
- Remove old versions from test matrix
- Fix button support in wagtail admin for newer wagtail versions
0.15.0
=================
- Fix is_authenticated 'bool' object is not callable error
- Add wagtail <=2.11 support
- Use Github Actions to test package instead of Travis CI
0.14.0
=================
- Fix 'bool' object is not callable error
- Fix deleting descendants with variants when deleting a page
- Add wagtail 2.6 support
0.13.0
=================
- Merged Praekelt fork
- Add custom javascript to segment forms
- bugfix:exclude variant returns queryset when params is queryset
- Added RulePanel, a subclass of InlinePanel, for Rules
- Upgrade to Wagtail > 2.0, drop support for Wagtail < 2
0.12.0
==================
- Fix Django version classifier in setup.py
0.12.0
==================
- Merged forks of Torchbox and Praekelt
- Wagtail 2 compatibility
- Makefile adjustments for portability
- Adds simple segment forcing for superusers
- Fix excluding pages without variant
- Fix bug on visiting a segment page in the admin
- Use Wagtail's logic in the page count in the dash
- Prevent corrected summary item from counting the root page
- Delete variants of a page that is being deleted
- Add end user and developer documentation
- Add an option to show a personalised block to everyone
- Add origin country rule (#190)
- Return 404 if variant page is accessed directly (#188)
- Do not generate sitemap entries for variants (#187)
- Remove restrictive wagtail dependency version constraint (#192)
0.11.3
==================
- Bugfix: Handle errors when testing an invalid visit count rule

View File

@ -54,7 +54,7 @@ master_doc = 'index'
# General information about the project.
project = 'wagtail-personalisation'
copyright = '2019, Lab Digital BV'
copyright = '2018, Lab Digital BV'
author = 'Lab Digital BV'
# The version info for the project you're documenting, acts as replacement for
@ -62,10 +62,10 @@ author = 'Lab Digital BV'
# built documents.
#
# The short X.Y version.
version = '0.15.3'
version = '0.12.0'
# The full version, including alpha/beta/rc tags.
release = '0.15.3'
release = '0.12.0'
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.

View File

@ -131,47 +131,3 @@ Is logged in Whether the user is logged in or logged out.
================== ==========================================================
``wagtail_personalisation.rules.UserIsLoggedInRule``
Origin country rule
-------------------
The origin country rule allows you to match visitors based on the origin
country of their request. This rule requires to have set up a way to detect
countries beforehand.
================== ==========================================================
Option Description
================== ==========================================================
Country What country user's request comes from.
================== ==========================================================
You must have one of the following configurations set up in order to
make it work.
- Cloudflare IP Geolocation - ``cf-ipcountry`` HTTP header set with a value of
the alpha-2 country format.
- CloudFront Geo-Targeting - ``cloudfront-viewer-country`` header set with a
value of the alpha-2 country format.
- The last fallback is to use GeoIP2 module that is included with Django. This
requires setting up an IP database beforehand, see the Django's
`GeoIP2 instructions <https://docs.djangoproject.com/en/stable/ref/contrib/gis/geoip2/>`_
for more information. It will use IP of the request, using HTTP header
the ``x-forwarded-for`` HTTP header and ``REMOTE_ADDR`` server value as a
fallback. If you want to use a custom logic when obtaining IP address, please
set the ``WAGTAIL_PERSONALISATION_IP_FUNCTION`` setting to the function that takes a
request as an argument, e.g.
.. code-block:: python
# settings.py
WAGTAIL_PERSONALISATION_IP_FUNCTION = 'yourproject.utils.get_client_ip'
# yourproject/utils.py
def get_client_ip(request):
return request['HTTP_CF_CONNECTING_IP']
``wagtail_personalisation.rules.OriginCountryRule``

View File

@ -25,7 +25,7 @@
"enable_date": "2017-06-02T10:58:39.389Z",
"disable_date": "2017-06-02T10:34:51.722Z",
"visit_count": 0,
"status": "enabled",
"enabled": true,
"persistent": false,
"match_any": false
}
@ -39,7 +39,7 @@
"enable_date": "2017-06-02T10:57:44.497Z",
"disable_date": "2017-06-02T10:57:39.984Z",
"visit_count": 1,
"status": "enabled",
"enabled": true,
"persistent": false,
"match_any": false
}

View File

@ -1,4 +1,4 @@
Django>=2.2,<2.3
wagtail>=2.6,<2.7
django-debug-toolbar==2.0
Django>=2.0,<2.1
wagtail>=2.1,<2.2
django-debug-toolbar==1.9.1
-e .[docs,test]

View File

@ -1,20 +0,0 @@
# Generated by Django 2.1.7 on 2019-03-15 12:54
from django.db import migrations
import sandbox.apps.user.models
class Migration(migrations.Migration):
dependencies = [
('user', '0001_initial'),
]
operations = [
migrations.AlterModelManagers(
name='user',
managers=[
('objects', sandbox.apps.user.models.UserManager()),
],
),
]

View File

@ -1,44 +1,14 @@
from django.contrib.auth.models import (
AbstractBaseUser, PermissionsMixin, BaseUserManager)
AbstractBaseUser, PermissionsMixin, UserManager)
from django.core.mail import send_mail
from django.db import models
from django.db import connections, models
from django.dispatch import receiver
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _
class UserManager(BaseUserManager):
use_in_migrations = True
def _create_user(self, email, password, **extra_fields):
"""
Create and save a user with the given username, email, and password.
"""
if not email:
raise ValueError('The given email address must be set')
email = self.normalize_email(email)
user = self.model(email=email, **extra_fields)
user.set_password(password)
user.save(using=self._db)
return user
def create_user(self, email, password=None, **extra_fields):
extra_fields.setdefault('is_staff', False)
extra_fields.setdefault('is_superuser', False)
return self._create_user(email, password, **extra_fields)
def create_superuser(self, email, password, **extra_fields):
extra_fields.setdefault('is_staff', True)
extra_fields.setdefault('is_superuser', True)
if extra_fields.get('is_staff') is not True:
raise ValueError('Superuser must have is_staff=True.')
if extra_fields.get('is_superuser') is not True:
raise ValueError('Superuser must have is_superuser=True.')
return self._create_user(email, password, **extra_fields)
class User(AbstractBaseUser, PermissionsMixin):
"""Customized version of the default `AbstractUser` from Django.
"""Cusomtized version of the default `AbstractUser` from Django.
"""
first_name = models.CharField(_('first name'), max_length=100, blank=True)

View File

@ -14,7 +14,6 @@ from __future__ import absolute_import, unicode_literals
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
import os
from importlib.util import find_spec
PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
BASE_DIR = os.path.dirname(PROJECT_DIR)
@ -79,14 +78,11 @@ MIDDLEWARE = [
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'wagtail.core.middleware.SiteMiddleware',
'wagtail.contrib.redirects.middleware.RedirectMiddleware',
]
if find_spec('wagtail.contrib.legacy'):
MIDDLEWARE += ('wagtail.contrib.legacy.sitemiddleware.SiteMiddleware',)
else:
MIDDLEWARE += ('wagtail.core.middleware.SiteMiddleware', )
ROOT_URLCONF = 'sandbox.urls'
TEMPLATES = [

View File

@ -11,9 +11,9 @@ from wagtail.documents import urls as wagtaildocs_urls
from sandbox.apps.search import views as search_views
urlpatterns = [
url(r'^django-admin/', admin.site.urls),
url(r'^admin/', admin.site.urls),
url(r'^admin/', include(wagtailadmin_urls)),
url(r'^cms/', include(wagtailadmin_urls)),
url(r'^documents/', include(wagtaildocs_urls)),
url(r'^search/$', search_views.search, name='search'),

View File

@ -1,5 +1,5 @@
[bumpversion]
current_version = 0.15.3
current_version = 0.12.0
commit = true
tag = true
tag_name = {new_version}
@ -15,7 +15,7 @@ python_paths = .
[flake8]
ignore = E731
max-line-length = 120
exclude =
exclude =
src/**/migrations/*.py
[wheel]
@ -28,3 +28,4 @@ omit = src/**/migrations/*.py
[bumpversion:file:setup.py]
[bumpversion:file:docs/conf.py]

View File

@ -2,25 +2,24 @@ import re
from setuptools import find_packages, setup
install_requires = [
'wagtail>=2.0',
'wagtail>=2.0,<2.2',
'user-agents>=1.1.0',
'wagtailfontawesome>=1.1.3',
'pycountry',
]
tests_require = [
'factory_boy==2.8.1',
'flake8-blind-except',
'flake8-debugger',
'flake8-isort',
'flake8-imports',
'flake8',
'freezegun==0.3.8',
'pytest-cov==2.5.1',
'pytest-django==4.1.0',
'pytest-django==3.1.2',
'pytest-pythonpath==0.7.2',
'pytest-sugar==0.9.1',
'pytest==6.1.2',
'wagtail_factories==1.1.0',
'pytest==3.4.2',
'wagtail_factories==1.0.0',
'pytest-mock==1.6.3',
]
@ -35,7 +34,7 @@ with open('README.rst') as fh:
setup(
name='wagtail-personalisation',
version='0.15.3',
version='0.12.0',
description='A Wagtail add-on for showing personalized content',
author='Lab Digital BV and others',
author_email='opensource@labdigital.nl',
@ -52,7 +51,7 @@ setup(
license='MIT',
long_description=long_description,
classifiers=[
'Development Status :: 5 - Production/Stable',
'Development Status :: 2 - Pre-Alpha',
'Environment :: Web Environment',
'Intended Audience :: Developers',
'License :: OSI Approved :: BSD License',
@ -60,7 +59,7 @@ setup(
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.6',
'Framework :: Django',
'Framework :: Django :: 2.0',
'Framework :: Django :: 2',
'Topic :: Internet :: WWW/HTTP :: Site Management',
],
)

View File

@ -1,3 +1,5 @@
from __future__ import absolute_import, unicode_literals
from django.conf import settings
from django.db.models import F
from django.utils.module_loading import import_string
@ -194,10 +196,8 @@ class SessionSegmentsAdapter(BaseSegmentsAdapter):
for segment in enabled_segments:
if segment.is_static and segment.static_users.filter(id=self.request.user.id).exists():
additional_segments.append(segment)
elif any((
segment.excluded_users.filter(id=self.request.user.id).exists(),
segment in excluded_segments
)):
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 = []

View File

@ -1,3 +1,5 @@
from __future__ import absolute_import, unicode_literals
from django.contrib import admin
from wagtail_personalisation import models, rules

View File

@ -1,3 +1,5 @@
from __future__ import absolute_import, unicode_literals
from django.conf.urls import url
from wagtail_personalisation import views

View File

@ -1,3 +1,5 @@
from __future__ import absolute_import, unicode_literals
from django.utils.translation import ugettext_lazy as _
from wagtail.core import blocks
@ -6,7 +8,6 @@ from wagtail_personalisation.models import Segment
def list_segment_choices():
yield -1, ("Show to everyone")
for pk, name in Segment.objects.values_list('pk', 'name'):
yield pk, name
@ -34,19 +35,10 @@ class PersonalisedStructBlock(blocks.StructBlock):
adapter = get_segment_adapter(request)
user_segments = adapter.get_segments()
try:
segment_id = int(value['segment'])
except (ValueError, TypeError):
return ''
if segment_id > 0:
if value['segment']:
for segment in user_segments:
if segment.id == segment_id:
if segment.id == int(value['segment']):
return super(PersonalisedStructBlock, self).render(
value, context)
if segment_id == -1:
return super(PersonalisedStructBlock, self).render(
value, context)
return ''
return ""

View File

@ -1,19 +1,21 @@
from __future__ import absolute_import, unicode_literals
from datetime import datetime
import functools
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.templatetags.static import static
from django.contrib.staticfiles.templatetags.staticfiles import static
from django.test.client import RequestFactory
from django.utils.lru_cache import lru_cache
from django.utils.translation import ugettext_lazy as _
from wagtail.admin.forms import WagtailAdminModelForm
SessionStore = import_module(settings.SESSION_ENGINE).SessionStore
@functools.lru_cache(maxsize=1000)
@lru_cache(maxsize=1000)
def user_from_data(user_id):
User = get_user_model()
try:

View File

@ -1,7 +1,7 @@
# Generated by Django 2.0.7 on 2018-07-04 15:26
import django.db.models.deletion
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):

View File

@ -1,7 +1,7 @@
# Generated by Django 2.0.5 on 2018-07-19 09:57
import django.db.models.deletion
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,45 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.2 on 2017-08-10 13:48
from __future__ import unicode_literals
from django.db import migrations, models
def forward(apps, schema_editor):
Segment = apps.get_model('wagtail_personalisation', 'Segment')
for segment in Segment.objects.all():
segment.enabled = segment.status == 'enabled'
segment.save()
def backward(apps, schema_editor):
Segment = apps.get_model('wagtail_personalisation', 'Segment')
for segment in Segment.objects.all():
if segment.enabled:
segment.status = 'enabled'
else:
segment.status = 'disabled'
segment.save()
class Migration(migrations.Migration):
dependencies = [
('wagtail_personalisation', '0023_personalisablepagemetadata_variant_cascade'),
]
operations = [
migrations.AddField(
model_name='segment',
name='enabled',
field=models.BooleanField(default=True, help_text='Should the segment be active?'),
),
migrations.RunPython(forward, reverse_code=backward),
migrations.RemoveField(
model_name='segment',
name='status',
),
]

File diff suppressed because one or more lines are too long

View File

@ -1,11 +1,11 @@
import random
import wagtail
from django import forms
from django.conf import settings
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models, transaction
from django.template.defaultfilters import slugify
from django.utils.encoding import python_2_unicode_compatible
from django.utils.functional import cached_property
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
@ -20,29 +20,14 @@ from wagtail_personalisation.utils import count_active_days
from .forms import SegmentAdminForm
class RulePanel(InlinePanel):
def on_model_bound(self):
self.relation_name = self.relation_name.replace('_related', 's')
self.db_field = self.model._meta.get_field(self.relation_name)
manager = getattr(self.model, self.relation_name)
self.related = manager.rel
class SegmentQuerySet(models.QuerySet):
def enabled(self):
return self.filter(status=self.model.STATUS_ENABLED)
return self.filter(enabled=True)
@python_2_unicode_compatible
class Segment(ClusterableModel):
"""The segment model."""
STATUS_ENABLED = 'enabled'
STATUS_DISABLED = 'disabled'
STATUS_CHOICES = (
(STATUS_ENABLED, _('Enabled')),
(STATUS_DISABLED, _('Disabled')),
)
TYPE_DYNAMIC = 'dynamic'
TYPE_STATIC = 'static'
@ -57,8 +42,8 @@ class Segment(ClusterableModel):
enable_date = models.DateTimeField(null=True, editable=False)
disable_date = models.DateTimeField(null=True, editable=False)
visit_count = models.PositiveIntegerField(default=0, editable=False)
status = models.CharField(
max_length=20, choices=STATUS_CHOICES, default=STATUS_ENABLED)
enabled = models.BooleanField(
default=True, help_text=_("Should the segment be active?"))
persistent = models.BooleanField(
default=False, help_text=_("Should the segment persist between visits?"))
match_any = models.BooleanField(
@ -118,7 +103,7 @@ class Segment(ClusterableModel):
MultiFieldPanel([
FieldPanel('name', classname="title"),
FieldRowPanel([
FieldPanel('status'),
FieldPanel('enabled'),
FieldPanel('persistent'),
]),
FieldPanel('match_any'),
@ -127,8 +112,8 @@ class Segment(ClusterableModel):
FieldPanel('randomisation_percent', classname='percent_field'),
], heading="Segment"),
MultiFieldPanel([
RulePanel(
"{}_related".format(rule_model._meta.db_table),
InlinePanel(
"{}s".format(rule_model._meta.db_table),
label='{}{}'.format(
rule_model._meta.verbose_name,
' ({})'.format(_('Static compatible')) if rule_model.static else ''
@ -185,9 +170,7 @@ class Segment(ClusterableModel):
return segment_rules
def toggle(self, save=True):
self.status = (
self.STATUS_ENABLED if self.status == self.STATUS_DISABLED
else self.STATUS_DISABLED)
self.enabled = not self.enabled
if save:
self.save()
@ -309,15 +292,3 @@ class PersonalisablePageMixin:
metadata = PersonalisablePageMetadata.objects.create(
canonical_page=self, variant=self)
return metadata
def get_sitemap_urls(self, request=None):
# Do not generate sitemap entries for variants.
if not self.personalisation_metadata.is_canonical:
return []
if wagtail.VERSION >= (2, 2):
# Since Wagtail 2.2 you can pass request to the get_sitemap_urls
# method.
return super(PersonalisablePageMixin, self).get_sitemap_urls(
request=request
)
return super(PersonalisablePageMixin, self).get_sitemap_urls()

View File

@ -7,16 +7,16 @@ from wagtail_personalisation.models import Segment
def check_status_change(sender, instance, *args, **kwargs):
"""Check if the status has changed. Alter dates accordingly."""
try:
original_status = sender.objects.get(pk=instance.id).status
original_status = sender.objects.get(pk=instance.id).enabled
except sender.DoesNotExist:
original_status = ""
original_status = None
if original_status != instance.status:
if instance.status == instance.STATUS_ENABLED:
if original_status != instance.enabled:
if instance.enabled is True:
instance.enable_date = timezone.now()
instance.visit_count = 0
return instance
if instance.status == instance.STATUS_DISABLED:
if instance.enabled is False:
instance.disable_date = timezone.now()

View File

@ -1,8 +1,9 @@
import logging
from __future__ import absolute_import, unicode_literals
import re
from datetime import datetime
from importlib import import_module
import pycountry
from django.apps import apps
from django.conf import settings
from django.contrib.sessions.models import Session
@ -10,36 +11,17 @@ from django.core.exceptions import ObjectDoesNotExist
from django.db import models
from django.template.defaultfilters import slugify
from django.test.client import RequestFactory
from django.utils import timezone
from django.utils.encoding import force_text, python_2_unicode_compatible
from django.utils.translation import ugettext_lazy as _
from modelcluster.fields import ParentalKey
from user_agents import parse
from wagtail.admin.edit_handlers import (
FieldPanel, FieldRowPanel, PageChooserPanel)
from wagtail_personalisation.utils import get_client_ip
SessionStore = import_module(settings.SESSION_ENGINE).SessionStore
logger = logging.getLogger(__name__)
def get_geoip_module():
try:
from django.contrib.gis.geoip2 import GeoIP2
return GeoIP2
except ImportError:
logger.exception(
'GeoIP module is disabled. To use GeoIP for the origin\n'
'country personaliastion rule please set it up as per '
'documentation:\n'
'https://docs.djangoproject.com/en/stable/ref/contrib/gis/'
'geoip2/.\n'
'Wagtail-personalisation also works with Cloudflare and\n'
'CloudFront country detection, so you should not see this\n'
'warning if you use one of those.')
@python_2_unicode_compatible
class AbstractBaseRule(models.Model):
"""Base for creating rules to segment users with."""
icon = 'fa-circle-o'
@ -55,7 +37,7 @@ class AbstractBaseRule(models.Model):
verbose_name = 'Abstract segmentation rule'
def __str__(self):
return str(self._meta.verbose_name)
return force_text(self._meta.verbose_name)
def test_user(self):
"""Test if the user matches this rule."""
@ -63,7 +45,7 @@ class AbstractBaseRule(models.Model):
def encoded_name(self):
"""Return a string with a slug for the rule."""
return slugify(str(self).lower())
return slugify(force_text(self).lower())
def description(self):
"""Return a description explaining the functionality of the rule.
@ -109,7 +91,7 @@ class TimeRule(AbstractBaseRule):
verbose_name = _('Time Rule')
def test_user(self, request=None):
return self.start_time <= timezone.now().time() <= self.end_time
return self.start_time <= datetime.now().time() <= self.end_time
def description(self):
return {
@ -153,7 +135,7 @@ class DayRule(AbstractBaseRule):
def test_user(self, request=None):
return [self.mon, self.tue, self.wed, self.thu,
self.fri, self.sat, self.sun][timezone.now().date().weekday()]
self.fri, self.sat, self.sun][datetime.today().weekday()]
def description(self):
days = (
@ -419,72 +401,10 @@ class UserIsLoggedInRule(AbstractBaseRule):
verbose_name = _('Logged in Rule')
def test_user(self, request=None):
return request.user.is_authenticated == self.is_logged_in
return request.user.is_authenticated() == self.is_logged_in
def description(self):
return {
'title': _('These visitors are'),
'value': _('Logged in') if self.is_logged_in else _('Not logged in'),
}
COUNTRY_CHOICES = [(country.alpha_2.lower(), country.name)
for country in pycountry.countries]
class OriginCountryRule(AbstractBaseRule):
"""
Test user against the country or origin of their request.
Using this rule requires setting up GeoIP2 on Django or using
CloudFlare or CloudFront geolocation detection.
"""
country = models.CharField(
max_length=2, choices=COUNTRY_CHOICES,
help_text=_("Select origin country of the request that this rule will "
"match against. This rule will only work if you use "
"Cloudflare or CloudFront IP geolocation or if GeoIP2 "
"module is configured.")
)
class Meta:
verbose_name = _("origin country rule")
def get_cloudflare_country(self, request):
"""
Get country code that has been detected by Cloudflare.
Guide to the functionality:
https://support.cloudflare.com/hc/en-us/articles/200168236-What-does-Cloudflare-IP-Geolocation-do-
"""
try:
return request.META['HTTP_CF_IPCOUNTRY'].lower()
except KeyError:
pass
def get_cloudfront_country(self, request):
try:
return request.META['HTTP_CLOUDFRONT_VIEWER_COUNTRY'].lower()
except KeyError:
pass
def get_geoip_country(self, request):
GeoIP2 = get_geoip_module()
if GeoIP2 is None:
return False
return GeoIP2().country_code(get_client_ip(request)).lower()
def get_country(self, request):
# Prioritise CloudFlare and CloudFront country detection over GeoIP.
functions = (
self.get_cloudflare_country,
self.get_cloudfront_country,
self.get_geoip_country,
)
for function in functions:
result = function(request)
if result:
return result
def test_user(self, request=None):
return (self.get_country(request) or '') == self.country.lower()

View File

@ -37,6 +37,8 @@
/******/ 3: 0
/******/ };
/******/
/******/ var resolvedPromise = new Promise(function(resolve) { resolve(); });
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/
@ -64,21 +66,20 @@
/******/ // This file contains only the entry chunk.
/******/ // The chunk loading function for additional chunks
/******/ __webpack_require__.e = function requireEnsure(chunkId) {
/******/ var installedChunkData = installedChunks[chunkId];
/******/ if(installedChunkData === 0) {
/******/ return new Promise(function(resolve) { resolve(); });
/******/ if(installedChunks[chunkId] === 0) {
/******/ return resolvedPromise;
/******/ }
/******/
/******/ // a Promise means "currently loading".
/******/ if(installedChunkData) {
/******/ return installedChunkData[2];
/******/ if(installedChunks[chunkId]) {
/******/ return installedChunks[chunkId][2];
/******/ }
/******/
/******/ // setup Promise in chunk cache
/******/ var promise = new Promise(function(resolve, reject) {
/******/ installedChunkData = installedChunks[chunkId] = [resolve, reject];
/******/ installedChunks[chunkId] = [resolve, reject];
/******/ });
/******/ installedChunkData[2] = promise;
/******/ installedChunks[chunkId][2] = promise;
/******/
/******/ // start chunk loading
/******/ var head = document.getElementsByTagName('head')[0];

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,5 @@
{% extends "modeladmin/index.html" %}
{% load i18n l10n static modeladmin_tags %}
{% load i18n l10n staticfiles modeladmin_tags %}
{% block titletag %}{{ view.get_meta_title }}{% endblock %}

View File

@ -1,5 +1,5 @@
{% extends "modeladmin/wagtail_personalisation/segment/base.html" %}
{% load i18n l10n static modeladmin_tags wagtail_personalisation_filters %}
{% load i18n l10n staticfiles modeladmin_tags wagtail_personalisation_filters %}
{% block toggle_view %}to List {% endblock%}
@ -22,7 +22,7 @@
<div class="nice-padding block_container">
{% if all_count %}
{% for segment in object_list %}
<div class="block block--{{ segment.status }}" onclick="location.href = '{% url 'wagtail_personalisation_segment_modeladmin_edit' segment.pk %}'">
<div class="block block--{{ segment.enabled|yesno:"enabled,disabled" }}" onclick="location.href = '{% url 'wagtail_personalisation_segment_modeladmin_edit' segment.pk %}'">
<h2>{{ segment }}</h2>
<div class="inspect_container">
@ -98,10 +98,10 @@
{% if user_can_create %}
<ul class="block_actions">
{% if segment.status == segment.STATUS_DISABLED %}
<li><a href="{% url 'segment:toggle' segment.pk %}" title="{% trans "Enable this segment" %}">enable</a></li>
{% elif segment.status == segment.STATUS_ENABLED %}
{% if segment.enabled %}
<li><a href="{% url 'segment:toggle' segment.pk %}" title="{% trans "Disable this segment" %}">disable</a></li>
{% else %}
<li><a href="{% url 'segment:toggle' segment.pk %}" title="{% trans "Enable this segment" %}">enable</a></li>
{% endif %}
<li><a href="{% url 'wagtail_personalisation_segment_modeladmin_edit' segment.pk %}" title="{% trans "Configure this segment" %}">configure this</a></li>
{% if segment.is_static %}

View File

@ -1,4 +1,5 @@
{% extends "modeladmin/wagtail_personalisation/segment/base.html" %}
{% load i18n l10n static modeladmin_tags wagtail_personalisation_filters %}
{% load i18n l10n staticfiles modeladmin_tags wagtail_personalisation_filters %}
{% block toggle_view %}to Dashboard {% endblock%}

View File

@ -1,10 +1,8 @@
import time
from django.conf import settings
from django.db.models import F
from django.template.base import FilterExpression, kwarg_re
from django.utils import timezone
from django.utils.module_loading import import_string
def impersonate_other_page(page, other_page):
@ -118,17 +116,3 @@ def can_delete_pages(pages, user):
if not variant.permissions_for_user(user).can_delete():
return False
return True
def get_client_ip(request):
try:
func = import_string(settings.WAGTAIL_PERSONALISATION_IP_FUNCTION)
except AttributeError:
pass
else:
return func(request)
try:
x_forwarded_for = request.META['HTTP_X_FORWARDED_FOR']
return x_forwarded_for.split(',')[-1].strip()
except KeyError:
return request.META['REMOTE_ADDR']

View File

@ -1,3 +1,5 @@
from __future__ import absolute_import, unicode_literals
import csv
from django import forms
@ -81,14 +83,11 @@ class SegmentModelAdmin(ModelAdmin):
delete_view_class = SegmentModelDeleteView
menu_icon = 'fa-snowflake-o'
add_to_settings_menu = False
list_display = ('name', 'persistent', 'match_any', 'status',
list_display = ('name', 'persistent', 'match_any', 'enabled',
'page_count', 'variant_count', 'statistics')
index_view_extra_js = ['js/commons.js', 'js/index.js']
index_view_extra_css = ['css/index.css']
form_view_extra_js = ['js/commons.js', 'js/form.js',
'js/segment_form_control.js',
'wagtailadmin/js/page-chooser-modal.js',
'wagtailadmin/js/page-chooser.js']
form_view_extra_js = ['js/commons.js', 'js/form.js']
form_view_extra_css = ['css/form.css']
def index_view(self, request):

View File

@ -1,46 +1,37 @@
from __future__ import absolute_import, unicode_literals
import logging
from django.conf.urls import include, url
from django.db import transaction
from django.db.models import F
from django.http import Http404
from django.shortcuts import redirect, render
from django.template.defaultfilters import pluralize
from django.urls import reverse
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
from wagtail import VERSION as WAGTAIL_VERSION
from wagtail.admin import messages
from wagtail.admin.site_summary import PagesSummaryItem, SummaryItem
try:
from wagtail.admin.views.pages.utils import get_valid_next_url_from_request
except ModuleNotFoundError:
from wagtail.admin.views.pages import get_valid_next_url_from_request # noqa
from wagtail.admin.views.pages import get_valid_next_url_from_request
from wagtail.admin.widgets import Button, ButtonWithDropdownFromHook
from wagtail.core import hooks
from wagtail.core.models import Page
from wagtail_personalisation import admin_urls, models, utils
from wagtail_personalisation.adapters import get_segment_adapter
from wagtail_personalisation.models import PersonalisablePageMetadata
logger = logging.getLogger(__name__)
@hooks.register("register_admin_urls")
@hooks.register('register_admin_urls')
def register_admin_urls():
"""Adds the administration urls for the personalisation apps."""
return [
url(
r"^personalisation/",
include(admin_urls, namespace="wagtail_personalisation"),
)
url(r'^personalisation/', include(
admin_urls, namespace='wagtail_personalisation')),
]
@hooks.register("before_serve_page")
@hooks.register('before_serve_page')
def set_visit_count(page, request, serve_args, serve_kwargs):
"""Tests the provided rules to see if the request still belongs
to a segment.
@ -55,7 +46,7 @@ def set_visit_count(page, request, serve_args, serve_kwargs):
adapter.add_page_visit(page)
@hooks.register("before_serve_page")
@hooks.register('before_serve_page')
def segment_user(page, request, serve_args, serve_kwargs):
"""Apply a segment to a visitor before serving the page.
@ -68,7 +59,7 @@ def segment_user(page, request, serve_args, serve_kwargs):
adapter = get_segment_adapter(request)
adapter.refresh()
forced_segment = request.GET.get("segment", None)
forced_segment = request.GET.get('segment', None)
if request.user.is_superuser and forced_segment is not None:
segment = models.Segment.objects.filter(pk=forced_segment).first()
if segment:
@ -86,14 +77,14 @@ class UserbarSegmentedLinkItem:
Show as segment: {self.segment.name}</a></div>"""
@hooks.register("construct_wagtail_userbar")
@hooks.register('construct_wagtail_userbar')
def add_segment_link_items(request, items):
for item in models.Segment.objects.enabled():
items.append(UserbarSegmentedLinkItem(item))
return items
@hooks.register("before_serve_page")
@hooks.register('before_serve_page')
def serve_variant(page, request, serve_args, serve_kwargs):
"""Apply a segment to a visitor before serving the page.
@ -113,13 +104,9 @@ def serve_variant(page, request, serve_args, serve_kwargs):
adapter = get_segment_adapter(request)
user_segments = adapter.get_segments()
metadata = page.personalisation_metadata
# If page is not canonical, don't serve it.
if not metadata.is_canonical:
raise Http404
if user_segments:
metadata = page.personalisation_metadata
# TODO: This is never more then one page? (fix query count)
metadata = metadata.metadata_for_segments(user_segments)
if metadata:
@ -127,13 +114,13 @@ def serve_variant(page, request, serve_args, serve_kwargs):
return variant.serve(request, *serve_args, **serve_kwargs)
@hooks.register("construct_explorer_page_queryset")
@hooks.register('construct_explorer_page_queryset')
def dont_show_variant(parent_page, pages, request):
return utils.exclude_variants(pages)
@hooks.register("register_page_listing_buttons")
def page_listing_variant_buttons(page, page_perms, is_parent=False, *args):
@hooks.register('register_page_listing_buttons')
def page_listing_variant_buttons(page, page_perms, is_parent=False):
"""Adds page listing buttons to personalisable pages. Shows variants for
the page (if any) and a 'Create a new variant' button.
@ -144,18 +131,17 @@ def page_listing_variant_buttons(page, page_perms, is_parent=False, *args):
metadata = page.personalisation_metadata
if metadata.is_canonical:
yield ButtonWithDropdownFromHook(
_("Variants"),
hook_name="register_page_listing_variant_buttons",
_('Variants'),
hook_name='register_page_listing_variant_buttons',
page=page,
page_perms=page_perms,
is_parent=is_parent,
attrs={"target": "_blank", "title": _("Create or edit a variant")},
priority=100,
)
attrs={'target': '_blank', 'title': _('Create or edit a variant')},
priority=100)
@hooks.register("register_page_listing_variant_buttons")
def page_listing_more_buttons(page, page_perms, is_parent=False, *args):
@hooks.register('register_page_listing_variant_buttons')
def page_listing_more_buttons(page, page_perms, is_parent=False):
"""Adds a 'more' button to personalisable pages allowing users to quickly
create a new variant for the selected segment.
@ -166,63 +152,48 @@ def page_listing_more_buttons(page, page_perms, is_parent=False, *args):
metadata = page.personalisation_metadata
for vm in metadata.variants_metadata:
yield Button(
"%s variant" % (vm.segment.name),
reverse("wagtailadmin_pages:edit", args=[vm.variant_id]),
attrs={"title": _("Edit this variant")},
classes=("icon", "icon-fa-pencil"),
priority=0,
)
yield Button('%s variant' % (vm.segment.name),
reverse('wagtailadmin_pages:edit', args=[vm.variant_id]),
attrs={"title": _('Edit this variant')},
classes=("icon", "icon-fa-pencil"),
priority=0)
for segment in metadata.get_unused_segments():
yield Button(
"%s variant" % (segment.name),
reverse("segment:copy_page", args=[page.pk, segment.pk]),
attrs={"title": _("Create this variant")},
classes=("icon", "icon-fa-plus"),
priority=100,
)
yield Button('%s variant' % (segment.name),
reverse('segment:copy_page', args=[page.pk, segment.pk]),
attrs={"title": _('Create this variant')},
classes=("icon", "icon-fa-plus"),
priority=100)
yield Button(
_("Create a new segment"),
reverse("wagtail_personalisation_segment_modeladmin_create"),
attrs={"title": _("Create a new segment")},
classes=("icon", "icon-fa-snowflake-o"),
priority=200,
)
yield Button(_('Create a new segment'),
reverse('wagtail_personalisation_segment_modeladmin_create'),
attrs={"title": _('Create a new segment')},
classes=("icon", "icon-fa-snowflake-o"),
priority=200)
class CorrectedPagesSummaryItem(PagesSummaryItem):
def get_total_pages(self, context):
def get_context(self):
# Perform the same check as Wagtail to get the correct count.
# Only correct the count when a root page is available to the user.
# The `PagesSummaryItem` will return a page count of 0 otherwise.
# https://github.com/wagtail/wagtail/blob/5c9ff23e229acabad406c42c4e13cbaea32e6c15/wagtail/admin/site_summary.py#L38
root_page = context.get("root_page", None)
context = super().get_context()
root_page = context.get('root_page', None)
if root_page:
pages = utils.exclude_variants(
Page.objects.descendant_of(root_page, inclusive=True)
)
Page.objects.descendant_of(root_page, inclusive=True))
page_count = pages.count()
if root_page.is_root():
page_count -= 1
return page_count
context['total_pages'] = page_count
if WAGTAIL_VERSION >= (2, 15):
def get_context_data(self, parent_context):
context = super().get_context_data(parent_context)
context["total_pages"] = self.get_total_pages(context)
return context
else:
def get_context(self):
context = super().get_context()
context["total_pages"] = self.get_total_pages(context)
return context
return context
@hooks.register("construct_homepage_summary_items")
@hooks.register('construct_homepage_summary_items')
def add_corrected_pages_summary_panel(request, items):
"""Replaces the Pages summary panel to hide variants."""
for index, item in enumerate(items):
@ -235,21 +206,16 @@ class SegmentSummaryPanel(SummaryItem):
site and allowing quick access to the Segment dashboard.
"""
order = 2000
def render(self):
segment_count = models.Segment.objects.count()
target_url = reverse("wagtail_personalisation_segment_modeladmin_index")
target_url = reverse('wagtail_personalisation_segment_modeladmin_index')
title = _("Segments")
return mark_safe(
"""
return mark_safe("""
<li class="icon icon-fa-snowflake-o">
<a href="{}"><span>{}</span>{}</a>
</li>""".format(
target_url, segment_count, title
)
)
</li>""".format(target_url, segment_count, title))
class PersonalisedPagesSummaryPanel(PagesSummaryItem):
@ -257,17 +223,12 @@ class PersonalisedPagesSummaryPanel(PagesSummaryItem):
def render(self):
page_count = models.PersonalisablePageMetadata.objects.filter(
segment__isnull=True
).count()
segment__isnull=True).count()
title = _("Personalised Page")
return mark_safe(
"""
return mark_safe("""
<li class="icon icon-fa-file-o">
<span>{}</span>{}{}
</li>""".format(
page_count, title, pluralize(page_count)
)
)
</li>""".format(page_count, title, pluralize(page_count)))
class VariantPagesSummaryPanel(PagesSummaryItem):
@ -275,20 +236,15 @@ class VariantPagesSummaryPanel(PagesSummaryItem):
def render(self):
page_count = models.PersonalisablePageMetadata.objects.filter(
segment__isnull=False
).count()
segment__isnull=False).count()
title = _("Variant")
return mark_safe(
"""
return mark_safe("""
<li class="icon icon-fa-files-o">
<span>{}</span>{}{}
</li>""".format(
page_count, title, pluralize(page_count)
)
)
</li>""".format(page_count, title, pluralize(page_count)))
@hooks.register("construct_homepage_summary_items")
@hooks.register('construct_homepage_summary_items')
def add_personalisation_summary_panels(request, items):
"""Adds a summary panel to the Wagtail dashboard showing the total amount
of segments on the site and allowing quick access to the Segment
@ -300,61 +256,52 @@ def add_personalisation_summary_panels(request, items):
items.append(VariantPagesSummaryPanel(request))
@hooks.register("before_delete_page")
@hooks.register('before_delete_page')
def delete_related_variants(request, page):
if (
not isinstance(page, models.PersonalisablePageMixin)
or not page.personalisation_metadata.is_canonical
):
if not isinstance(page, models.PersonalisablePageMixin) \
or not page.personalisation_metadata.is_canonical:
return
# Get a list of related personalisation metadata for all the related
# variants.
variants_metadata = page.personalisation_metadata.variants_metadata.select_related(
"variant"
variants_metadata = (
page.personalisation_metadata.variants_metadata
.select_related('variant')
)
next_url = get_valid_next_url_from_request(request)
if request.method == "POST":
if request.method == 'POST':
parent_id = page.get_parent().id
variants_metadata = variants_metadata.select_related('variant')
with transaction.atomic():
# To ensure variants are deleted for all descendants, start with
# the deepest ones, and explicitly delete variants and metadata
# for all of them, including the page itself. Otherwise protected
# foreign key constraints are violated. Only consider canonical
# pages.
for metadata in PersonalisablePageMetadata.objects.filter(
canonical_page__in=page.get_descendants(inclusive=True),
variant=F("canonical_page"),
).order_by("-canonical_page__depth"):
for variant_metadata in metadata.variants_metadata.select_related(
"variant"
):
# Call delete() on objects to trigger any signals or hooks.
variant_metadata.variant.delete()
metadata.delete()
metadata.canonical_page.delete()
for metadata in variants_metadata.iterator():
# Call delete() on objects to trigger any signals or hooks.
metadata.variant.delete()
# Delete the page's main variant and the page itself.
page.personalisation_metadata.delete()
page.delete()
msg = _("Page '{0}' and its variants deleted.")
messages.success(request, msg.format(page.get_admin_display_title()))
messages.success(
request,
msg.format(page.get_admin_display_title())
)
for fn in hooks.get_hooks("after_delete_page"):
for fn in hooks.get_hooks('after_delete_page'):
result = fn(request, page)
if hasattr(result, "status_code"):
if hasattr(result, 'status_code'):
return result
if next_url:
return redirect(next_url)
return redirect("wagtailadmin_explore", parent_id)
return redirect('wagtailadmin_explore', parent_id)
return render(
request,
"wagtailadmin/pages/wagtail_personalisation/confirm_delete.html",
{
"page": page,
"descendant_count": page.get_descendant_count(),
"next": next_url,
"variants": Page.objects.filter(
pk__in=variants_metadata.values_list("variant_id")
),
},
'wagtailadmin/pages/wagtail_personalisation/confirm_delete.html', {
'page': page,
'descendant_count': page.get_descendant_count(),
'next': next_url,
'variants': Page.objects.filter(
pk__in=variants_metadata.values_list('variant_id')
)
}
)

View File

@ -1,3 +1,5 @@
from __future__ import absolute_import, unicode_literals
import pytest
pytest_plugins = [
@ -5,11 +7,6 @@ pytest_plugins = [
]
@pytest.fixture(autouse=True)
def enable_db_access(db):
pass
@pytest.fixture(scope='session')
def django_db_setup(django_db_setup, django_db_blocker):
from wagtail.core.models import Page, Site

View File

@ -5,18 +5,7 @@ from django.utils.text import slugify
from wagtail_factories.factories import PageFactory
from tests.site.pages import models
from wagtail_personalisation.models import PersonalisablePageMetadata
try:
from wagtail.core.models import Locale
class LocaleFactory(factory.DjangoModelFactory):
language_code = "en"
class Meta:
model = Locale
except ImportError:
pass
class ContentPageFactory(PageFactory):
parent = None
@ -33,9 +22,3 @@ class RegularPageFactory(PageFactory):
class Meta:
model = models.RegularPage
class PersonalisablePageMetadataFactory(factory.DjangoModelFactory):
class Meta:
model = PersonalisablePageMetadata

View File

@ -46,8 +46,3 @@ class VisitCountRuleFactory(factory.DjangoModelFactory):
class Meta:
model = rules.VisitCountRule
class OriginCountryRuleFactory(factory.DjangoModelFactory):
class Meta:
model = rules.OriginCountryRule

View File

@ -7,7 +7,7 @@ from wagtail_personalisation import models
class SegmentFactory(factory.DjangoModelFactory):
name = 'TestSegment'
status = models.Segment.STATUS_ENABLED
enabled = True
class Meta:
model = models.Segment

View File

@ -23,7 +23,7 @@ def site():
return site
@pytest.fixture()
@pytest.fixture
def segmented_page(site):
page = ContentPageFactory(parent=site.root_page, slug='personalised')
segment = SegmentFactory()
@ -46,6 +46,6 @@ class RequestFactory(BaseRequestFactory):
return request
@pytest.fixture()
@pytest.fixture
def user(django_user_model):
return django_user_model.objects.create(username='user')

View File

@ -1,5 +1,6 @@
from __future__ import absolute_import, unicode_literals
import os
from importlib.util import find_spec
DATABASES = {
'default': {
@ -51,7 +52,6 @@ TEMPLATES = [
},
]
MIDDLEWARE = (
'django.middleware.common.CommonMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
@ -59,14 +59,10 @@ MIDDLEWARE = (
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'wagtail.core.middleware.SiteMiddleware',
)
if find_spec('wagtail.contrib.legacy'):
MIDDLEWARE += ('wagtail.contrib.legacy.sitemiddleware.SiteMiddleware',)
else:
MIDDLEWARE += ('wagtail.core.middleware.SiteMiddleware', )
INSTALLED_APPS = (
'wagtail_personalisation',

View File

@ -64,7 +64,7 @@ def test_refresh_removes_disabled(rf):
adapter.set_segments([segment_1, segment_2])
adapter = adapters.SessionSegmentsAdapter(request)
segment_1.status = segment_1.STATUS_DISABLED
segment_1.enabled = False
segment_1.save()
adapter.refresh()

View File

@ -15,14 +15,14 @@ from wagtail_personalisation.rules import TimeRule
@pytest.mark.django_db
def test_segment_create():
factoried_segment = SegmentFactory()
segment = Segment(name='TestSegment', status='enabled')
segment = Segment(name='TestSegment', enabled=True)
TimeRule(
start_time=datetime.time(8, 0, 0),
end_time=datetime.time(23, 0, 0),
segment=segment)
assert factoried_segment.name == segment.name
assert factoried_segment.status == segment.status
assert factoried_segment.enabled == segment.enabled
@pytest.mark.django_db

View File

@ -1,3 +1,5 @@
from __future__ import absolute_import, unicode_literals
import datetime
import pytest
@ -6,7 +8,7 @@ from django.db.models import ProtectedError
from tests.factories.page import ContentPageFactory
from tests.factories.segment import SegmentFactory
from tests.site.pages import models
from wagtail_personalisation.models import PersonalisablePageMetadata, Segment
from wagtail_personalisation.models import PersonalisablePageMetadata
from wagtail_personalisation.rules import TimeRule
@ -58,26 +60,3 @@ def test_page_protection_when_deleting_segment(segmented_page):
assert len(segment.get_used_pages())
with pytest.raises(ProtectedError):
segment.delete()
@pytest.mark.django_db
def test_sitemap_generation_for_canonical_pages_is_enabled(segmented_page):
canonical = segmented_page.personalisation_metadata.canonical_page
assert canonical.personalisation_metadata.is_canonical
assert canonical.get_sitemap_urls()
@pytest.mark.django_db
def test_sitemap_generation_for_variants_is_disabled(segmented_page):
assert not segmented_page.personalisation_metadata.is_canonical
assert not segmented_page.get_sitemap_urls()
@pytest.mark.django_db
def test_segment_edit_view(site, client, django_user_model):
test_segment = SegmentFactory()
try:
new_panel = test_segment.panels[1].children[0].bind_to(model=Segment)
except AttributeError:
new_panel = test_segment.panels[1].children[0].bind_to_model(Segment)
assert new_panel.related.name == "wagtail_personalisation_timerules"

View File

@ -1,202 +0,0 @@
from importlib.util import find_spec
from unittest.mock import MagicMock, call, patch
import pytest
from tests.factories.rule import OriginCountryRuleFactory
from tests.factories.segment import SegmentFactory
from wagtail_personalisation.rules import get_geoip_module
skip_if_geoip2_installed = pytest.mark.skipif(
find_spec('geoip2'), reason='requires GeoIP2 to be not installed'
)
skip_if_geoip2_not_installed = pytest.mark.skipif(
not find_spec('geoip2'), reason='requires GeoIP2 to be installed.'
)
@pytest.mark.django_db
def test_get_cloudflare_country_with_header(rf):
segment = SegmentFactory(name='Test segment')
rule = OriginCountryRuleFactory(segment=segment, country='GB')
request = rf.get('/', HTTP_CF_IPCOUNTRY='PL')
assert rule.get_cloudflare_country(request) == 'pl'
@pytest.mark.django_db
def test_get_cloudflare_country_with_no_header(rf):
segment = SegmentFactory(name='Test segment')
rule = OriginCountryRuleFactory(segment=segment, country='GB')
request = rf.get('/')
assert 'HTTP_CF_IPCOUNTRY' not in request.META
assert rule.get_cloudflare_country(request) is None
@pytest.mark.django_db
def test_get_cloudfront_country_with_header(rf):
segment = SegmentFactory(name='Test segment')
rule = OriginCountryRuleFactory(segment=segment, country='GB')
request = rf.get('/', HTTP_CLOUDFRONT_VIEWER_COUNTRY='BY')
assert rule.get_cloudfront_country(request) == 'by'
@pytest.mark.django_db
def test_get_cloudfront_country_with_no_header(rf):
segment = SegmentFactory(name='Test segment')
rule = OriginCountryRuleFactory(segment=segment, country='GB')
request = rf.get('/')
assert 'HTTP_CLOUDFRONT_VIEWER_COUNTRY' not in request.META
assert rule.get_cloudfront_country(request) is None
@pytest.mark.django_db
def test_get_geoip_country_with_remote_addr(rf):
segment = SegmentFactory(name='Test segment')
rule = OriginCountryRuleFactory(segment=segment, country='GB')
request = rf.get('/', REMOTE_ADDR='173.254.89.34')
geoip_mock = MagicMock()
with patch('wagtail_personalisation.rules.get_geoip_module',
return_value=geoip_mock) as geoip_import_mock:
rule.get_geoip_country(request)
geoip_import_mock.assert_called_once()
geoip_mock.assert_called_once()
assert geoip_mock.mock_calls[1] == call().country_code('173.254.89.34')
@pytest.mark.django_db
def test_get_country_calls_all_methods(rf):
segment = SegmentFactory(name='Test segment')
rule = OriginCountryRuleFactory(segment=segment, country='GB')
request = rf.get('/')
@patch.object(rule, 'get_geoip_country', return_value='')
@patch.object(rule, 'get_cloudflare_country', return_value='')
@patch.object(rule, 'get_cloudfront_country', return_value='')
def test_mock(cloudfront_mock, cloudflare_mock, geoip_mock):
country = rule.get_country(request)
cloudflare_mock.assert_called_once_with(request)
cloudfront_mock.assert_called_once_with(request)
geoip_mock.assert_called_once_with(request)
assert country is None
test_mock()
@pytest.mark.django_db
def test_get_country_does_not_use_all_detection_methods_unnecessarily(rf):
segment = SegmentFactory(name='Test segment')
rule = OriginCountryRuleFactory(segment=segment, country='GB')
request = rf.get('/')
@patch.object(rule, 'get_geoip_country', return_value='')
@patch.object(rule, 'get_cloudflare_country', return_value='t1')
@patch.object(rule, 'get_cloudfront_country', return_value='')
def test_mock(cloudfront_mock, cloudflare_mock, geoip_mock):
country = rule.get_country(request)
cloudflare_mock.assert_called_once_with(request)
cloudfront_mock.assert_not_called()
geoip_mock.assert_not_called()
assert country == 't1'
test_mock()
@pytest.mark.django_db
def test_test_user_calls_get_country(rf):
segment = SegmentFactory(name='Test segment')
rule = OriginCountryRuleFactory(segment=segment, country='GB')
request = rf.get('/')
with patch.object(rule, 'get_country') as get_country_mock:
rule.test_user(request)
get_country_mock.assert_called_once_with(request)
@pytest.mark.django_db
def test_test_user_returns_true_if_cloudflare_country_match(rf):
segment = SegmentFactory(name='Test segment')
rule = OriginCountryRuleFactory(segment=segment, country='GB')
request = rf.get('/', HTTP_CF_IPCOUNTRY='GB')
assert rule.test_user(request) is True
@pytest.mark.django_db
def test_test_user_returns_false_if_cloudflare_country_doesnt_match(rf):
segment = SegmentFactory(name='Test segment')
rule = OriginCountryRuleFactory(segment=segment, country='GB')
request = rf.get('/', HTTP_CF_IPCOUNTRY='NL')
assert not rule.test_user(request)
@pytest.mark.django_db
def test_test_user_returns_false_if_cloudfront_country_doesnt_match(rf):
segment = SegmentFactory(name='Test segment')
rule = OriginCountryRuleFactory(segment=segment, country='GB')
request = rf.get('/', HTTP_CLOUDFRONT_VIEWER_COUNTRY='NL')
assert rule.test_user(request) is False
@pytest.mark.django_db
def test_test_user_returns_true_if_cloudfront_country_matches(rf):
segment = SegmentFactory(name='Test segment')
rule = OriginCountryRuleFactory(segment=segment, country='se')
request = rf.get('/', HTTP_CLOUDFRONT_VIEWER_COUNTRY='SE')
assert rule.test_user(request) is True
@skip_if_geoip2_not_installed
@pytest.mark.django_db
def test_test_user_geoip_module_matches(rf):
segment = SegmentFactory(name='Test segment')
rule = OriginCountryRuleFactory(segment=segment, country='se')
request = rf.get('/', REMOTE_ADDR='123.120.0.2')
GeoIP2Mock = MagicMock()
GeoIP2Mock().configure_mock(**{'country_code.return_value': 'SE'})
GeoIP2Mock.reset_mock()
with patch('wagtail_personalisation.rules.get_geoip_module',
return_value=GeoIP2Mock):
assert rule.test_user(request) is True
assert GeoIP2Mock.mock_calls == [
call(),
call().country_code('123.120.0.2'),
]
@skip_if_geoip2_not_installed
@pytest.mark.django_db
def test_test_user_geoip_module_does_not_match(rf):
segment = SegmentFactory(name='Test segment')
rule = OriginCountryRuleFactory(segment=segment, country='nl')
request = rf.get('/', REMOTE_ADDR='123.120.0.2')
GeoIP2Mock = MagicMock()
GeoIP2Mock().configure_mock(**{'country_code.return_value': 'SE'})
GeoIP2Mock.reset_mock()
with patch('wagtail_personalisation.rules.get_geoip_module',
return_value=GeoIP2Mock):
assert rule.test_user(request) is False
assert GeoIP2Mock.mock_calls == [
call(),
call().country_code('123.120.0.2')
]
@skip_if_geoip2_installed
@pytest.mark.django_db
def test_test_user_does_not_use_geoip_module_if_disabled(rf):
segment = SegmentFactory(name='Test segment')
rule = OriginCountryRuleFactory(segment=segment, country='se')
request = rf.get('/', REMOTE_ADDR='123.120.0.2')
assert rule.test_user(request) is False
@skip_if_geoip2_installed
def test_get_geoip_module_disabled():
with pytest.raises(ImportError):
from django.contrib.gis.geoip2 import GeoIP2 # noqa
assert get_geoip_module() is None
@skip_if_geoip2_not_installed
def test_get_geoip_module_enabled():
from django.contrib.gis.geoip2 import GeoIP2
assert get_geoip_module() is GeoIP2

View File

@ -12,7 +12,7 @@ from wagtail_personalisation.rules import TimeRule, VisitCountRule
def form_with_data(segment, *rules):
model_fields = ['type', 'status', 'count', 'name', 'match_any', 'randomisation_percent']
model_fields = ['type', 'enabled', 'count', 'name', 'match_any', 'randomisation_percent']
class TestSegmentAdminForm(SegmentAdminForm):
class Meta:
@ -487,7 +487,7 @@ def test_count_users_matching_static_rules(site, client, mocker, django_user_mod
form = form_with_data(segment, rule)
mocker.patch('wagtail_personalisation.rules.VisitCountRule.test_user', return_value=True)
assert form.count_matching_users([rule], True) == 2
assert form.count_matching_users([rule], True) is 2
@pytest.mark.django_db
@ -500,7 +500,7 @@ def test_count_matching_users_excludes_staff(site, client, mocker, django_user_m
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) == 1
assert form.count_matching_users([rule], True) is 1
assert mock_test_user.call_count == 1
@ -514,7 +514,7 @@ def test_count_matching_users_excludes_inactive(site, client, mocker, django_use
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) == 1
assert form.count_matching_users([rule], True) is 1
assert mock_test_user.call_count == 1
@ -532,7 +532,7 @@ def test_count_matching_users_only_counts_static_rules(site, client, mocker, dja
form = form_with_data(segment, rule)
mock_test_user = mocker.patch('wagtail_personalisation.rules.TimeRule.test_user')
assert form.count_matching_users([rule], True) == 0
assert form.count_matching_users([rule], True) is 0
assert mock_test_user.call_count == 0
@ -551,7 +551,7 @@ def test_count_matching_users_handles_match_any(site, client, mocker, django_use
'wagtail_personalisation.rules.VisitCountRule.test_user',
side_effect=[True, False, True, False])
assert form.count_matching_users([first_rule, second_rule], True) == 2
assert form.count_matching_users([first_rule, second_rule], True) is 2
mock_test_user.call_count == 4
@ -570,5 +570,5 @@ def test_count_matching_users_handles_match_all(site, client, mocker, django_use
'wagtail_personalisation.rules.VisitCountRule.test_user',
side_effect=[True, True, False, True])
assert form.count_matching_users([first_rule, second_rule], False) == 1
assert form.count_matching_users([first_rule, second_rule], False) is 1
mock_test_user.call_count == 4

View File

@ -1,19 +1,8 @@
import pytest
from django.test import override_settings
from wagtail.core.models import Page as WagtailPage
from tests.factories.page import (ContentPageFactory, PersonalisablePageMetadataFactory)
from tests.factories.page import ContentPageFactory
from wagtail_personalisation.utils import (
can_delete_pages, exclude_variants, get_client_ip, impersonate_other_page)
locale_factory = False
try:
from tests.factories.page import LocaleFactory
locale_factory = True
except ImportError:
pass
can_delete_pages, impersonate_other_page)
@pytest.fixture
@ -47,83 +36,3 @@ def test_can_delete_pages_with_superuser(rf, user, segmented_page):
@pytest.mark.django_db
def test_cannot_delete_pages_with_standard_user(user, segmented_page):
assert not can_delete_pages([segmented_page], user)
def test_get_client_ip_with_remote_addr(rf):
request = rf.get('/', REMOTE_ADDR='173.231.235.87')
assert get_client_ip(request) == '173.231.235.87'
def test_get_client_ip_with_x_forwarded_for(rf):
request = rf.get('/', HTTP_X_FORWARDED_FOR='173.231.235.87',
REMOTE_ADDR='10.0.23.24')
assert get_client_ip(request) == '173.231.235.87'
@override_settings(
WAGTAIL_PERSONALISATION_IP_FUNCTION='some.non.existent.path'
)
def test_get_client_ip_custom_get_client_ip_function_does_not_exist(rf):
with pytest.raises(ImportError):
get_client_ip(rf.get('/'))
@override_settings(
WAGTAIL_PERSONALISATION_IP_FUNCTION='tests.utils.get_custom_ip'
)
def test_get_client_ip_custom_get_client_ip_used(rf):
assert get_client_ip(rf.get('/')) == '123.123.123.123'
def test_exclude_variants_with_pages_querysets():
'''
Test that excludes variant works for querysets
'''
for i in range(5):
page = ContentPageFactory(path="/" + str(i), depth=0, url_path="/", title="Hoi " + str(i))
page.save()
pages = WagtailPage.objects.all().specific().order_by('id')
result = exclude_variants(pages)
assert type(result) == type(pages)
assert set(result.values_list('pk', flat=True)) == set(pages.values_list('pk', flat=True))
def test_exclude_variants_with_pages_querysets_not_canonical():
'''
Test that excludes variant works for querysets with
personalisation_metadata canonical False
'''
for i in range(5):
page = ContentPageFactory(path="/" + str(i), depth=0, url_path="/", title="Hoi " + str(i))
page.save()
pages = WagtailPage.objects.all().specific().order_by('id')
# add variants
for page in pages:
variant = ContentPageFactory(title='variant %d' % page.pk)
page.personalisation_metadata = PersonalisablePageMetadataFactory(canonical_page=page, variant=variant)
page.save()
pages = WagtailPage.objects.all().specific()
result = exclude_variants(pages)
assert type(result) == type(pages)
assert result.count() < pages.count()
def test_exclude_variants_with_pages_querysets_meta_none():
'''
Test that excludes variant works for querysets with meta as none
'''
for i in range(5):
page = ContentPageFactory(path="/" + str(i), depth=0, url_path="/", title="Hoi " + str(i))
page.save()
pages = WagtailPage.objects.all().specific().order_by('id')
# add variants
for page in pages:
page.personalisation_metadata = PersonalisablePageMetadataFactory(canonical_page=page, variant=page)
page.save()
pages = WagtailPage.objects.all().specific()
result = exclude_variants(pages)
assert type(result) == type(pages)
assert set(result.values_list('pk', flat=True)) == set(pages.values_list('pk', flat=True))

View File

@ -6,7 +6,7 @@ from wagtail.core.models import Page
from wagtail_personalisation.models import Segment
from wagtail_personalisation.rules import VisitCountRule
from wagtail_personalisation.views import (
SegmentModelAdmin, SegmentModelDeleteView)
SegmentModelDeleteView, SegmentModelAdmin)
@pytest.mark.django_db
@ -106,5 +106,5 @@ def test_segment_delete_view_raises_permission_denied(rf, segmented_page, user):
)
view.request = request
message = 'User have no permission to delete variant page objects.'
with pytest.raises(PermissionDenied):
with pytest.raises(PermissionDenied, message=message):
view.delete_instance()

View File

@ -1,8 +1,6 @@
import pytest
from django.http import Http404
from wagtail.core.models import Page
from tests.factories.page import ContentPageFactory
from tests.factories.segment import SegmentFactory
from wagtail_personalisation import adapters, wagtail_hooks
@ -18,15 +16,6 @@ def test_serve_variant_no_variant(site, rf):
assert result is None
@pytest.mark.django_db
def test_variant_accessed_directly_returns_404(segmented_page, rf):
request = rf.get('/')
args = tuple()
kwargs = {}
with pytest.raises(Http404):
wagtail_hooks.serve_variant(segmented_page, request, args, kwargs)
@pytest.mark.django_db
def test_serve_variant_with_variant_no_segment(site, rf, segmented_page):
request = rf.get('/')
@ -123,21 +112,3 @@ def test_custom_delete_page_view_deletes_variants(rf, segmented_page, user):
# Make sure all the variant pages have been deleted.
assert not len(variants.all())
assert not len(variants_metadata.all())
@pytest.mark.django_db
def test_custom_delete_page_view_deletes_variants_of_child_pages(rf, segmented_page, user):
"""
Regression test for deleting pages that have children with variants
"""
post_request = rf.post('/')
user.is_superuser = True
rf.user = user
canonical_page = segmented_page.personalisation_metadata.canonical_page
# Create a child with a variant
child_page = ContentPageFactory(parent=canonical_page, slug='personalised-child')
child_page.personalisation_metadata.copy_for_segment(segmented_page.personalisation_metadata.segment)
# A ProtectedError would be raised if the bug persists
wagtail_hooks.delete_related_variants(
post_request, canonical_page
)

View File

@ -5,7 +5,3 @@ def render_template(value, **context):
template = engines['django'].from_string(value)
request = context.pop('request', None)
return template.render(context, request)
def get_custom_ip(request):
return '123.123.123.123'

39
tox.ini
View File

@ -1,30 +1,14 @@
[tox]
envlist =
flake8
py{36,37,38}-dj{22}-wt{211,212,213}
py{37,38}-dj{30,31}-wt{211,212,213}
[gh-actions]
python =
3.6: py36
3.7: py37
3.8: py38
envlist = py{36}-django{20}-wagtail{20,21},lint
[testenv]
basepython =
py36: python3.6
py37: python3.7
py38: python3.8
commands = coverage run --parallel -m pytest -rs {posargs}
basepython = python3.6
commands = coverage run --parallel -m pytest {posargs}
extras = test
deps =
dj22: Django>=2.2.8,<2.3
dj30: Django>=3.0,<3.1
dj31: Django>=3.1,<3.2
wt211: wagtail>=2.11,<2.12
wt212: wagtail>=2.12,<2.13
wt213: wagtail>=2.13,<2.14
geoip2: geoip2
django20: django>=2.0,<2.1
wagtail20: wagtail>=2.0,<2.1
wagtail21: wagtail>=2.1,<2.2
[testenv:coverage-report]
basepython = python3.6
@ -36,16 +20,7 @@ commands =
[testenv:lint]
basepython = python3.6
deps = flake8==3.5.0
deps = flake8
commands =
flake8 src tests setup.py
isort -q --recursive --diff src/ tests/
[testenv:format]
basepython = python3.8
deps =
isort
black
skip_install = true
commands =
black --check setup.py src/wagtail_personalisation/ tests/

4773
yarn.lock

File diff suppressed because it is too large Load Diff