7

Handles both Python 2 & 3, and multiple optimisations & simplifications.

This commit is contained in:
Bertrand Bordage
2017-05-31 17:13:33 +02:00
parent 4deaaa985f
commit 4f2dc3a304
7 changed files with 106 additions and 163 deletions

View File

@ -4,9 +4,9 @@ from django.conf import settings
from django.db.models import F from django.db.models import F
from django.utils.module_loading import import_string from django.utils.module_loading import import_string
from wagtail_personalisation.models import Segment from .models import Segment
from wagtail_personalisation.rules import AbstractBaseRule from .rules import AbstractBaseRule
from wagtail_personalisation.utils import create_segment_dictionary from .utils import create_segment_dictionary
class BaseSegmentsAdapter(object): class BaseSegmentsAdapter(object):
@ -23,28 +23,22 @@ class BaseSegmentsAdapter(object):
def setup(self): def setup(self):
"""Prepare the adapter for segment storage.""" """Prepare the adapter for segment storage."""
return None
def get_segments(self): def get_segments(self):
"""Return the segments stored in the adapter storage.""" """Return the segments stored in the adapter storage."""
return None
def get_segment_by_id(self): def get_segment_by_id(self):
"""Return a single segment stored in the adapter storage.""" """Return a single segment stored in the adapter storage."""
return None
def add(self): def add(self):
"""Add a new segment to the adapter storage.""" """Add a new segment to the adapter storage."""
return None
def refresh(self): def refresh(self):
"""Refresh the segments stored in the adapter storage.""" """Refresh the segments stored in the adapter storage."""
return None
def _test_rules(self, rules, request, match_any=False): def _test_rules(self, rules, request, match_any=False):
"""Tests the provided rules to see if the request still belongs """Tests the provided rules to see if the request still belongs
to a segment. to a segment.
:param rules: The rules to test for :param rules: The rules to test for
:type rules: list of wagtail_personalisation.rules :type rules: list of wagtail_personalisation.rules
:param request: The http request :param request: The http request
@ -53,20 +47,12 @@ class BaseSegmentsAdapter(object):
:type match_any: bool :type match_any: bool
:returns: A boolean indicating the segment matches the request :returns: A boolean indicating the segment matches the request
:rtype: bool :rtype: bool
""" """
if len(rules) > 0: if not rules:
for rule in rules: return False
result = rule.test_user(request) if match_any:
if match_any: return any(rule.test_user(request) for rule in rules)
if result is True: return all(rule.test_user(request) for rule in rules)
return result
elif result is False:
return False
if not match_any:
return True
return False
class Meta: class Meta:
abstract = True abstract = True
@ -134,8 +120,9 @@ class SessionSegmentsAdapter(BaseSegmentsAdapter):
:rtype: wagtail_personalisation.models.Segment or None :rtype: wagtail_personalisation.models.Segment or None
""" """
segments = self.get_segments() for segment in self.get_segments():
return next((s for s in segments if s.pk == segment_id), None) if segment.pk == segment_id:
return segment
def add_page_visit(self, page): def add_page_visit(self, page):
"""Mark the page as visited by the user""" """Mark the page as visited by the user"""
@ -179,20 +166,20 @@ class SessionSegmentsAdapter(BaseSegmentsAdapter):
still apply to the requesting visitor. still apply to the requesting visitor.
""" """
all_segments = Segment.objects.filter(status=Segment.STATUS_ENABLED) enabled_segments = Segment.objects.filter(status=Segment.STATUS_ENABLED)
rule_models = AbstractBaseRule.get_descendant_models()
current_segments = self.get_segments() current_segments = self.get_segments()
rules = AbstractBaseRule.__subclasses__()
# Run tests on all remaining enabled segments to verify applicability. # Run tests on all remaining enabled segments to verify applicability.
additional_segments = [] additional_segments = []
for segment in all_segments: for segment in enabled_segments:
segment_rules = [] segment_rules = []
for rule in rules: for rule_model in rule_models:
segment_rules += rule.objects.filter(segment=segment) segment_rules.extend(rule_model.objects.filter(segment=segment))
result = self._test_rules( result = self._test_rules(segment_rules, self.request,
segment_rules, self.request, match_any=segment.match_any) match_any=segment.match_any)
if result: if result:
additional_segments.append(segment) additional_segments.append(segment)

View File

@ -2,7 +2,7 @@ from __future__ import absolute_import, unicode_literals
from django.contrib import admin from django.contrib import admin
from wagtail_personalisation import models, rules from . import models, rules
class UserIsLoggedInRuleAdminInline(admin.TabularInline): class UserIsLoggedInRuleAdminInline(admin.TabularInline):

View File

@ -2,7 +2,7 @@ from __future__ import absolute_import, unicode_literals
from django.conf.urls import url from django.conf.urls import url
from wagtail_personalisation import views from . import views
app_name = 'segment' app_name = 'segment'

View File

@ -3,13 +3,13 @@ from __future__ import absolute_import, unicode_literals
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from wagtail.wagtailcore import blocks from wagtail.wagtailcore import blocks
from wagtail_personalisation.adapters import get_segment_adapter from .adapters import get_segment_adapter
from wagtail_personalisation.models import Segment from .models import Segment
def list_segment_choices(): def list_segment_choices():
for segment in Segment.objects.all(): for pk, name in Segment.objects.values_list('pk', 'name'):
yield (segment.pk, segment.name) yield pk, name
class PersonalisedStructBlock(blocks.StructBlock): class PersonalisedStructBlock(blocks.StructBlock):

View File

@ -11,9 +11,9 @@ from wagtail.wagtailadmin.edit_handlers import (
FieldPanel, FieldRowPanel, InlinePanel, MultiFieldPanel, ObjectList, FieldPanel, FieldRowPanel, InlinePanel, MultiFieldPanel, ObjectList,
PageChooserPanel, TabbedInterface) PageChooserPanel, TabbedInterface)
from wagtail_personalisation.forms import AdminPersonalisablePageForm from .forms import AdminPersonalisablePageForm
from wagtail_personalisation.rules import AbstractBaseRule from .rules import AbstractBaseRule
from wagtail_personalisation.utils import count_active_days from .utils import count_active_days
class SegmentQuerySet(models.QuerySet): class SegmentQuerySet(models.QuerySet):
@ -61,9 +61,9 @@ class Segment(ClusterableModel):
], heading="Segment"), ], heading="Segment"),
MultiFieldPanel([ MultiFieldPanel([
InlinePanel( InlinePanel(
"{}_related".format(rule._meta.db_table), "{}_related".format(rule_model._meta.db_table),
label=rule.__str__, label=rule_model._meta.verbose_name,
) for rule in AbstractBaseRule.__subclasses__() ) for rule_model in AbstractBaseRule.__subclasses__()
], heading="Rules"), ], heading="Rules"),
] ]
@ -82,10 +82,10 @@ class Segment(ClusterableModel):
def get_rules(self): def get_rules(self):
"""Retrieve all rules in the segment.""" """Retrieve all rules in the segment."""
rules = AbstractBaseRule.__subclasses__()
segment_rules = [] segment_rules = []
for rule in rules: for rule_model in AbstractBaseRule.get_descendant_models():
segment_rules += rule.objects.filter(segment=self) segment_rules.extend(
rule_model._default_manager.filter(segment=self))
return segment_rules return segment_rules
@ -103,7 +103,7 @@ class PersonalisablePageMixin(models.Model):
blank=True, null=True blank=True, null=True
) )
segment = models.ForeignKey( segment = models.ForeignKey(
Segment, related_name='segments', on_delete=models.PROTECT, Segment, related_name='pages', on_delete=models.PROTECT,
blank=True, null=True blank=True, null=True
) )
is_segmented = models.BooleanField(default=False) is_segmented = models.BooleanField(default=False)
@ -117,9 +117,6 @@ class PersonalisablePageMixin(models.Model):
base_form_class = AdminPersonalisablePageForm base_form_class = AdminPersonalisablePageForm
def __str__(self):
return "{}".format(self.title)
@cached_property @cached_property
def has_variations(self): def has_variations(self):
"""Return a boolean indicating whether or not the personalisable page """Return a boolean indicating whether or not the personalisable page
@ -137,12 +134,20 @@ class PersonalisablePageMixin(models.Model):
"""Return a boolean indicating whether or not the personalisable page """Return a boolean indicating whether or not the personalisable page
is a canonical page. is a canonical page.
:returns: A boolean indicating whether or not the personalisable page :returns: A boolean indicating whether or not the personalisable
page
is a canonical page. is a canonical page.
:rtype: bool :rtype: bool
""" """
return not self.canonical_page and self.has_variations return self.canonical_page_id is None
def get_unused_segments(self):
if not hasattr(self, '_unused_segments'):
self._unused_segments = (
Segment.objects.exclude(pages__canonical_page=self)
if self.is_canonical else Segment.objects.none())
return self._unused_segments
def copy_for_segment(self, segment): def copy_for_segment(self, segment):
slug = "{}-{}".format(self.slug, segment.encoded_name()) slug = "{}-{}".format(self.slug, segment.encoded_name())

View File

@ -3,9 +3,10 @@ from __future__ import absolute_import, unicode_literals
import re import re
from datetime import datetime from datetime import datetime
from django.apps import apps
from django.db import models from django.db import models
from django.template.defaultfilters import slugify from django.template.defaultfilters import slugify
from django.utils.encoding import python_2_unicode_compatible from django.utils.encoding import python_2_unicode_compatible, force_text
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from modelcluster.fields import ParentalKey from modelcluster.fields import ParentalKey
from user_agents import parse from user_agents import parse
@ -26,7 +27,7 @@ class AbstractBaseRule(models.Model):
abstract = True abstract = True
def __str__(self): def __str__(self):
return _('Abstract segmentation rule') return force_text(self._meta.verbose_name)
def test_user(self): def test_user(self):
"""Test if the user matches this rule.""" """Test if the user matches this rule."""
@ -34,7 +35,7 @@ class AbstractBaseRule(models.Model):
def encoded_name(self): def encoded_name(self):
"""Return a string with a slug for the rule.""" """Return a string with a slug for the rule."""
return slugify(self.__str__().lower()) return slugify(force_text(self).lower())
def description(self): def description(self):
"""Return a description explaining the functionality of the rule. """Return a description explaining the functionality of the rule.
@ -51,8 +52,16 @@ class AbstractBaseRule(models.Model):
return description return description
@classmethod
def get_descendant_models(cls):
return [model for model in apps.get_models()
if issubclass(model, AbstractBaseRule)]
class Meta:
abstract = True
verbose_name = 'Abstract segmentation rule'
@python_2_unicode_compatible
class TimeRule(AbstractBaseRule): class TimeRule(AbstractBaseRule):
"""Time rule to segment users based on a start and end time. """Time rule to segment users based on a start and end time.
@ -70,18 +79,14 @@ class TimeRule(AbstractBaseRule):
]), ]),
] ]
def __str__(self): class Meta:
return _('Time Rule') verbose_name = _('Time Rule')
def test_user(self, request=None): def test_user(self, request=None):
current_time = datetime.now().time() return self.start_time <= datetime.now().time() <= self.end_time
starting_time = self.start_time
ending_time = self.end_time
return starting_time <= current_time <= ending_time
def description(self): def description(self):
description = { return {
'title': _('These users visit between'), 'title': _('These users visit between'),
'value': _('{} and {}').format( 'value': _('{} and {}').format(
self.start_time.strftime("%H:%M"), self.start_time.strftime("%H:%M"),
@ -89,10 +94,7 @@ class TimeRule(AbstractBaseRule):
), ),
} }
return description
@python_2_unicode_compatible
class DayRule(AbstractBaseRule): class DayRule(AbstractBaseRule):
"""Day rule to segment users based on the day(s) of a visit. """Day rule to segment users based on the day(s) of a visit.
@ -118,39 +120,28 @@ class DayRule(AbstractBaseRule):
FieldPanel('sun'), FieldPanel('sun'),
] ]
def __str__(self): class Meta:
return _('Day Rule') verbose_name = _('Day Rule')
def test_user(self, request=None): def test_user(self, request=None):
current_day = datetime.today().weekday() return [self.mon, self.tue, self.wed, self.thu,
self.fri, self.sat, self.sun][datetime.today().weekday()]
days = [self.mon, self.tue, self.wed, self.thu,
self.fri, self.sat, self.sun]
return days[current_day]
def description(self): def description(self):
days = { days = (
'mon': self.mon, 'tue': self.tue, 'wed': self.wed, ('mon', self.mon), ('tue', self.tue), ('wed', self.wed),
'thu': self.thu, 'fri': self.fri, 'sat': self.sat, ('thu', self.thu), ('fri', self.fri), ('sat', self.sat),
'sun': self.sun ('sun', self.sun),
} )
chosen_days = [] chosen_days = [day_name for day_name, chosen in days if chosen]
for key, value in days.items(): return {
if days[key]:
chosen_days.append(key)
description = {
'title': _('These users visit on'), 'title': _('These users visit on'),
'value': (', '.join(_(day) for day in chosen_days)).title() 'value': ", ".join([day for day in chosen_days]).title(),
} }
return description
@python_2_unicode_compatible
class ReferralRule(AbstractBaseRule): class ReferralRule(AbstractBaseRule):
"""Referral rule to segment users based on a regex test. """Referral rule to segment users based on a regex test.
@ -165,8 +156,8 @@ class ReferralRule(AbstractBaseRule):
FieldPanel('regex_string'), FieldPanel('regex_string'),
] ]
def __str__(self): class Meta:
return _('Referral Rule') verbose_name = _('Referral Rule')
def test_user(self, request): def test_user(self, request):
pattern = re.compile(self.regex_string) pattern = re.compile(self.regex_string)
@ -178,18 +169,13 @@ class ReferralRule(AbstractBaseRule):
return False return False
def description(self): def description(self):
description = { return {
'title': _('These visits originate from'), 'title': _('These visits originate from'),
'value': _('{}').format( 'value': self.regex_string,
self.regex_string
),
'code': True 'code': True
} }
return description
@python_2_unicode_compatible
class VisitCountRule(AbstractBaseRule): class VisitCountRule(AbstractBaseRule):
"""Visit count rule to segment users based on amount of visits to a """Visit count rule to segment users based on amount of visits to a
specified page. specified page.
@ -222,6 +208,9 @@ class VisitCountRule(AbstractBaseRule):
]), ]),
] ]
class Meta:
verbose_name = _('Visit count Rule')
def test_user(self, request): def test_user(self, request):
operator = self.operator operator = self.operator
segment_count = self.count segment_count = self.count
@ -243,11 +232,8 @@ class VisitCountRule(AbstractBaseRule):
return True return True
return False return False
def __str__(self):
return _('Visit count Rule')
def description(self): def description(self):
description = { return {
'title': _('These users visited {}').format( 'title': _('These users visited {}').format(
self.counted_page self.counted_page
), ),
@ -257,10 +243,7 @@ class VisitCountRule(AbstractBaseRule):
), ),
} }
return description
@python_2_unicode_compatible
class QueryRule(AbstractBaseRule): class QueryRule(AbstractBaseRule):
"""Query rule to segment users based on matching queries. """Query rule to segment users based on matching queries.
@ -278,18 +261,14 @@ class QueryRule(AbstractBaseRule):
FieldPanel('value'), FieldPanel('value'),
] ]
def __str__(self): class Meta:
return _('Query Rule') verbose_name = _('Query Rule')
def test_user(self, request): def test_user(self, request):
parameter = self.parameter return request.GET.get(self.parameter, '') == self.value
value = self.value
req_value = request.GET.get(parameter, '')
return req_value == value
def description(self): def description(self):
description = { return {
'title': _('These users used a url with the query'), 'title': _('These users used a url with the query'),
'value': _('?{}={}').format( 'value': _('?{}={}').format(
self.parameter, self.parameter,
@ -298,10 +277,7 @@ class QueryRule(AbstractBaseRule):
'code': True 'code': True
} }
return description
@python_2_unicode_compatible
class DeviceRule(AbstractBaseRule): class DeviceRule(AbstractBaseRule):
"""Device rule to segment users based on matching devices. """Device rule to segment users based on matching devices.
@ -319,8 +295,8 @@ class DeviceRule(AbstractBaseRule):
FieldPanel('desktop'), FieldPanel('desktop'),
] ]
def __str__(self): class Meta:
return _('Device Rule') verbose_name = _('Device Rule')
def test_user(self, request=None): def test_user(self, request=None):
ua_header = request.META['HTTP_USER_AGENT'] ua_header = request.META['HTTP_USER_AGENT']
@ -328,15 +304,13 @@ class DeviceRule(AbstractBaseRule):
if user_agent.is_mobile: if user_agent.is_mobile:
return self.mobile return self.mobile
elif user_agent.is_tablet: if user_agent.is_tablet:
return self.tablet return self.tablet
elif user_agent.is_pc: if user_agent.is_pc:
return self.desktop return self.desktop
else: return False
return False
@python_2_unicode_compatible
class UserIsLoggedInRule(AbstractBaseRule): class UserIsLoggedInRule(AbstractBaseRule):
"""User is logged in rule to segment users based on their authentication """User is logged in rule to segment users based on their authentication
status. status.
@ -350,22 +324,14 @@ class UserIsLoggedInRule(AbstractBaseRule):
FieldPanel('is_logged_in'), FieldPanel('is_logged_in'),
] ]
def __str__(self): class Meta:
return _('Logged In Rule') verbose_name = _('Logged In Rule')
def test_user(self, request=None): 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): def description(self):
status = _('Logged in') return {
if self.is_logged_in is False:
status = _('Not logged in')
description = {
'title': _('These visitors are'), 'title': _('These visitors are'),
'value': _('{}').format( 'value': _('Logged in') if self.is_logged_in else _('Not logged in'),
status
),
} }
return description

View File

@ -3,18 +3,17 @@ from __future__ import absolute_import, unicode_literals
import logging import logging
from django.conf.urls import include, url from django.conf.urls import include, url
from django.shortcuts import reverse from django.core.urlresolvers import reverse
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from wagtail.wagtailadmin.site_summary import SummaryItem from wagtail.wagtailadmin.site_summary import SummaryItem
from wagtail.wagtailadmin.widgets import Button, ButtonWithDropdownFromHook from wagtail.wagtailadmin.widgets import Button, ButtonWithDropdownFromHook
from wagtail.wagtailcore import hooks from wagtail.wagtailcore import hooks
from wagtail.wagtailcore.models import Page
from wagtail_personalisation import admin_urls from . import admin_urls
from wagtail_personalisation.adapters import get_segment_adapter from .adapters import get_segment_adapter
from wagtail_personalisation.models import PersonalisablePageMixin, Segment from .models import PersonalisablePageMixin, Segment
from wagtail_personalisation.utils import impersonate_other_page from .utils import impersonate_other_page
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -72,11 +71,10 @@ def serve_variation(page, request, serve_args, serve_kwargs):
:rtype: wagtail.wagtailcore.models.Page :rtype: wagtail.wagtailcore.models.Page
""" """
user_segments = []
adapter = get_segment_adapter(request) adapter = get_segment_adapter(request)
user_segments = adapter.get_segments() user_segments = adapter.get_segments()
if len(user_segments) > 0: if user_segments:
variations = _check_for_variations(user_segments, page) variations = _check_for_variations(user_segments, page)
if variations: if variations:
@ -109,14 +107,7 @@ def page_listing_variant_buttons(page, page_perms, is_parent=False):
the page (if any) and a 'Create a new variant' button. the page (if any) and a 'Create a new variant' button.
""" """
if isinstance(page, PersonalisablePageMixin) and page.get_unused_segments():
if not hasattr(page, 'segment'):
return
pages = page.__class__.objects.filter(pk=page.pk)
segments = Segment.objects.all()
if pages and len(segments) > 0 and not (
any(item.segment for item in pages)):
yield ButtonWithDropdownFromHook( yield ButtonWithDropdownFromHook(
_('Variants'), _('Variants'),
hook_name='register_page_listing_variant_buttons', hook_name='register_page_listing_variant_buttons',
@ -133,16 +124,9 @@ def page_listing_more_buttons(page, page_perms, is_parent=False):
create a new variant for the selected segment. create a new variant for the selected segment.
""" """
model = page.__class__ for segment in page.get_unused_segments():
segments = Segment.objects.all()
available_segments = [
item for item in segments
if not model.objects.filter(segment=item, pk=page.pk)
]
for segment in available_segments:
yield Button(segment.name, yield Button(segment.name,
reverse('segment:copy_page', args=[page.id, segment.id]), reverse('segment:copy_page', args=[page.pk, segment.pk]),
attrs={"title": _('Create this variant')}) attrs={"title": _('Create this variant')})
@ -166,7 +150,8 @@ class SegmentSummaryPanel(SummaryItem):
@hooks.register('construct_homepage_summary_items') @hooks.register('construct_homepage_summary_items')
def add_segment_summary_panel(request, items): def add_segment_summary_panel(request, items):
"""Adds a summary panel to the Wagtail dashboard showing the total amount """Adds a summary panel to the Wagtail dashboard showing the total amount
of segments on the site and allowing quick access to the Segment dashboard. of segments on the site and allowing quick access to the Segment
dashboard.
""" """
return items.append(SegmentSummaryPanel(request)) items.append(SegmentSummaryPanel(request))