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.wagtailadmin.edit_handlers import (
FieldPanel, FieldRowPanel, InlinePanel, MultiFieldPanel)
from wagtail.wagtailcore.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):
return self.filter(status=self.model.STATUS_ENABLED)
@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'
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)
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)
persistent = models.BooleanField(
default=False, help_text=_("Should the segment persist between visits?"))
match_any = models.BooleanField(
default=False,
help_text=_("Should the segment match all the rules or just one of them?")
)
type = models.CharField(
max_length=20,
choices=TYPE_CHOICES,
default=TYPE_DYNAMIC,
help_text=mark_safe(_("""
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([
FieldPanel('name', classname="title"),
FieldRowPanel([
FieldPanel('status'),
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='{}{}'.format(
rule_model._meta.verbose_name,
' ({})'.format(_('Static compatible')) if rule_model.static else ''
),
) for rule_model in AbstractBaseRule.__subclasses__()
], heading=_("Rules")),
]
super(Segment, self).__init__(*args, **kwargs)
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())
def get_active_days(self):
"""Return the amount of days the segment has been active."""
return count_active_days(self.enable_date, self.disable_date)
def get_used_pages(self):
"""Return the pages that have variants using this segment."""
pages = list(PersonalisablePageMetadata.objects.filter(segment=self))
return pages
def get_created_variants(self):
"""Return the variants using this segment."""
pages = Page.objects.filter(_personalisable_page_metadata__segment=self)
return pages
def get_rules(self):
"""Retrieve all rules in the segment."""
segment_rules = []
for rule_model in AbstractBaseRule.get_descendant_models():
segment_rules.extend(
rule_model._default_manager.filter(segment=self))
return segment_rules
def toggle(self, save=True):
self.status = (
self.STATUS_ENABLED if self.status == self.STATUS_DISABLED
else self.STATUS_DISABLED)
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
segments.
"""
canonical_page = models.ForeignKey(
Page, related_name='personalisable_canonical_metadata',
on_delete=models.SET_NULL,
blank=True, null=True
)
variant = models.OneToOneField(
Page, related_name='_personalisable_page_metadata')
segment = models.ForeignKey(
Segment, related_name='page_metadata', null=True, blank=True)
@cached_property
def has_variants(self):
"""Return a boolean indicating whether or not the personalisable page
has variants.
:returns: A boolean indicating whether or not the personalisable page
has variants.
:rtype: bool
"""
return self.variants_metadata.exists()
@cached_property
def variants_metadata(self):
return (
PersonalisablePageMetadata.objects
.filter(canonical_page_id=self.canonical_page_id)
.exclude(variant_id=self.variant_id)
.exclude(variant_id=self.canonical_page_id))
@cached_property
def is_canonical(self):
"""Return a boolean indicating whether or not the personalisable page
is a canonical page.
:returns: A boolean indicating whether or not the personalisable
page
is a canonical page.
:rtype: bool
"""
return self.canonical_page_id == self.variant_id
def copy_for_segment(self, segment):
page = self.canonical_page
slug = "{}-{}".format(page.slug, segment.encoded_name())
title = "{} ({})".format(page.title, segment.name)
update_attrs = {
'title': title,
'slug': slug,
'live': False,
}
with transaction.atomic():
new_page = self.canonical_page.copy(
update_attrs=update_attrs, copy_revisions=False)
PersonalisablePageMetadata.objects.create(
canonical_page=page,
variant=new_page,
segment=segment)
return new_page
def metadata_for_segments(self, segments):
return (
self.__class__.objects
.filter(
canonical_page_id=self.canonical_page_id,
segment__in=segments))
def get_unused_segments(self):
if self.is_canonical:
return (
Segment.objects
.exclude(page_metadata__canonical_page_id=self.canonical_page_id))
return Segment.objects.none()
class PersonalisablePageMixin(object):
"""The personalisable page model. Allows creation of variants with linked
segments.
"""
@cached_property
def personalisation_metadata(self):
try:
metadata = self._personalisable_page_metadata
except AttributeError:
metadata = PersonalisablePageMetadata.objects.create(
canonical_page=self, variant=self)
return metadata