Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/detailed segment visits #174

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,16 @@ To install the package with pip::

pip install wagtail-personalisation

Next, include the ``wagtail_personalisation``, ``wagtail.contrib.modeladmin``
and ``wagtailfontawesome`` apps in your project's ``INSTALLED_APPS``:
Next, include the ``wagtail_personalisation``, ``wagtail.contrib.modeladmin``,
``'wagtail.contrib.settings'`` and ``wagtailfontawesome`` apps in your project's
``INSTALLED_APPS``:

.. code-block:: python

INSTALLED_APPS = [
# ...
'wagtail.contrib.modeladmin',
'wagtail.contrib.settings',
'wagtail_personalisation',
'wagtailfontawesome',
# ...
Expand Down
2 changes: 0 additions & 2 deletions sandbox/exampledata/personalisation.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
"edit_date": "2017-06-02T10:58:39.399Z",
"enable_date": "2017-06-02T10:58:39.389Z",
"disable_date": "2017-06-02T10:34:51.722Z",
"visit_count": 0,
"status": "enabled",
"persistent": false,
"match_any": false
Expand All @@ -38,7 +37,6 @@
"edit_date": "2017-06-02T10:57:44.504Z",
"enable_date": "2017-06-02T10:57:44.497Z",
"disable_date": "2017-06-02T10:57:39.984Z",
"visit_count": 1,
"status": "enabled",
"persistent": false,
"match_any": false
Expand Down
1 change: 1 addition & 0 deletions sandbox/sandbox/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
'wagtail.wagtailadmin',
'wagtail.wagtailcore',
'wagtail.contrib.modeladmin',
'wagtail.contrib.settings',

'wagtailfontawesome',
'modelcluster',
Expand Down
34 changes: 16 additions & 18 deletions src/wagtail_personalisation/adapters.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
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

from wagtail_personalisation.models import Segment
Expand Down Expand Up @@ -36,7 +35,7 @@ def add(self):
def refresh(self):
"""Refresh the segments stored in the adapter storage."""

def _test_rules(self, rules, request, match_any=False):
def _test_rules(self, rules, match_any=False):
"""Tests the provided rules to see if the request still belongs
to a segment.
:param rules: The rules to test for
Expand All @@ -50,9 +49,20 @@ def _test_rules(self, rules, request, match_any=False):
"""
if not rules:
return False

if not hasattr(self.request, 'matched_rules'):
self.request.matched_rules = []

results = []
for rule in rules:
validation = rule.test_user(self.request)
if validation:
self.request.matched_rules.append(rule.unique_encoded_name)
results.append(validation)

if match_any:
return any(rule.test_user(request) for rule in rules)
return all(rule.test_user(request) for rule in rules)
return any(results)
return all(results)

class Meta:
abstract = True
Expand Down Expand Up @@ -150,17 +160,6 @@ def get_visit_count(self, page=None):
return visit['count']
return 0

def update_visit_count(self):
"""Update the visit count for all segments in the request session."""
segments = self.request.session['segments']
segment_pks = [s['id'] for s in segments]

# Update counts
(Segment.objects
.enabled()
.filter(pk__in=segment_pks)
.update(visit_count=F('visit_count') + 1))

def refresh(self):
"""Retrieve the request session segments and verify whether or not they
still apply to the requesting visitor.
Expand All @@ -178,14 +177,13 @@ def refresh(self):
for rule_model in rule_models:
segment_rules.extend(rule_model.objects.filter(segment=segment))

result = self._test_rules(segment_rules, self.request,
match_any=segment.match_any)
result = self._test_rules(
segment_rules, match_any=segment.match_any)

if result:
additional_segments.append(segment)

self.set_segments(current_segments + additional_segments)
self.update_visit_count()


SEGMENT_ADAPTER_CLASS = import_string(getattr(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.4 on 2017-08-25 07:02
from __future__ import unicode_literals

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('wagtail_personalisation', '0012_remove_personalisablepagemetadata_is_segmented'),
]

operations = [
migrations.CreateModel(
name='PersonalisationSettings',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('detailed_visits', models.BooleanField(default=False, help_text='Enable to gather more detailed metadata about the visits to your segments and the rules that matched. Please note that this will create additional load on your database. Usage of caching is recommended.')),
('reverse_match', models.BooleanField(default=False, help_text='Enable to reverse match past visits with users as soon as a user logs in. This will ensure your data is as complete as possible.')),
('site', models.OneToOneField(editable=False, on_delete=django.db.models.deletion.CASCADE, to='wagtailcore.Site')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='SegmentVisit',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('session', models.CharField(db_index=True, editable=False, max_length=64, null=True)),
('visit_date', models.DateTimeField(auto_now_add=True)),
('page', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='wagtailcore.Page')),
],
),
migrations.CreateModel(
name='SegmentVisitMetadata',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('matched_rules', models.CharField(max_length=2048)),
],
),
migrations.RemoveField(
model_name='segment',
name='visit_count',
),
migrations.AddField(
model_name='segmentvisitmetadata',
name='segment',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='wagtail_personalisation.Segment'),
),
migrations.AddField(
model_name='segmentvisitmetadata',
name='visit',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='wagtail_personalisation.SegmentVisit'),
),
migrations.AddField(
model_name='segmentvisit',
name='segments',
field=models.ManyToManyField(through='wagtail_personalisation.SegmentVisitMetadata', to='wagtail_personalisation.Segment'),
),
migrations.AddField(
model_name='segmentvisit',
name='served_segment',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='served_segment', to='wagtail_personalisation.Segment'),
),
migrations.AddField(
model_name='segmentvisit',
name='served_variant',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='served_variant', to='wagtailcore.Page'),
),
migrations.AddField(
model_name='segmentvisit',
name='user',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL),
),
]
138 changes: 136 additions & 2 deletions src/wagtail_personalisation/models.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
from __future__ import absolute_import, unicode_literals

from django.conf import settings
from django.contrib.auth.signals import user_logged_in
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.translation import ugettext_lazy as _
from modelcluster.models import ClusterableModel
from wagtail.contrib.settings.models import BaseSetting, register_setting
from wagtail.wagtailadmin.edit_handlers import (
FieldPanel, FieldRowPanel, InlinePanel, MultiFieldPanel)
from wagtail.wagtailcore.models import Page
Expand All @@ -14,6 +17,29 @@
from wagtail_personalisation.utils import count_active_days


@register_setting(icon='fa-magic')
class PersonalisationSettings(BaseSetting):
detailed_visits = models.BooleanField(
default=False,
help_text=_('Enable to gather more detailed metadata about the visits '
'to your segments and the rules that matched. '
'Please note that this will create additional load on your '
'database. Usage of caching is recommended.'))
reverse_match = models.BooleanField(
default=False,
help_text=_('Enable to reverse match past visits with users as soon as '
'a user logs in. This will ensure your data is as complete '
'as possible.'))

panels = [
MultiFieldPanel([
FieldPanel('detailed_visits'),
FieldPanel('reverse_match'),
], heading='Analytics'
)
]


class SegmentQuerySet(models.QuerySet):
def enabled(self):
return self.filter(status=self.model.STATUS_ENABLED)
Expand All @@ -35,7 +61,6 @@ class Segment(ClusterableModel):
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(
Expand Down Expand Up @@ -74,10 +99,27 @@ def encoded_name(self):
"""Return a string with a slug for the segment."""
return slugify(self.name.lower())

def get_active_days(self):
@property
def 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_visits(self):
"""Return the segment visits."""
return SegmentVisit.objects.filter(segments=self)

@property
def visit_count(self):
"""Returns the total amount of segment visits."""
return self.get_visits().count()

def get_serves(self):
return SegmentVisit.objects.filter(served_segment=self)

@property
def serve_count(self):
return self.get_serves().count()

def get_used_pages(self):
"""Return the pages that have variants using this segment."""
pages = list(PersonalisablePageMetadata.objects.filter(segment=self))
Expand Down Expand Up @@ -107,6 +149,98 @@ def toggle(self, save=True):
self.save()


class SegmentVisitMetadata(models.Model):
visit = models.ForeignKey(
'wagtail_personalisation.SegmentVisit', on_delete=models.CASCADE)
segment = models.ForeignKey(
'wagtail_personalisation.Segment', on_delete=models.SET_NULL, null=True)
matched_rules = models.CharField(max_length=2048)


class SegmentVisit(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True)
page = models.ForeignKey(Page, on_delete=models.SET_NULL, null=True)
segments = models.ManyToManyField(Segment, through=SegmentVisitMetadata)
served_segment = models.ForeignKey(
Segment, on_delete=models.CASCADE,
related_name='served_segment', null=True)
served_variant = models.ForeignKey(
Page, on_delete=models.SET_NULL,
related_name='served_variant', null=True)
session = models.CharField(
max_length=64, editable=False, null=True, db_index=True)
visit_date = models.DateTimeField(auto_now_add=True)

class Meta:
ordering = ['-visit_date']

@classmethod
def create_segment_visit(cls, page, request, metadata=None):
"""Create a segment visit object.
:param page: The page being visited
:type page: wagtail.wagtailcore.models.Page
:param request: The http request
:type request: django.http.HttpRequest
:param metadata: A list of personalisable page metadata
:type page: wagtail_personalisation.models.PersonalisablePageMetadata
:returns: A committed Segment Visit object
:rtype: wagtail_personalisation.models.SegmentVisit
"""
from wagtail_personalisation.adapters import get_segment_adapter
wxp_settings = PersonalisationSettings.for_site(request.site)

if wxp_settings.detailed_visits:
adapter = get_segment_adapter(request)
user_segments = adapter.get_segments()

if not metadata:
metadata = page.personalisation_metadata
metadata = metadata.metadata_for_segments(user_segments)

user = request.user if request.user.is_authenticated else None
visit = cls.objects.create(
user=user,
page=page,
served_segment=metadata.first().segment,
served_variant=metadata.first().variant,
session=request.session.session_key
)

for segment in user_segments:
rules = [
rule for rule in segment.get_rules() if rule.unique_encoded_name
in request.matched_rules
]

SegmentVisitMetadata.objects.create(
visit=visit,
segment=segment,
matched_rules=','.join(
rule.unique_encoded_name for rule in rules)
)

return visit

@classmethod
def reverse_match(cls, user):
user_visits = cls.objects.filter(user=user)

for visit in user_visits:
cls.objects.filter(
session=visit.session,
user__isnull=True
).update(user=user)


def reverse_match(sender, request, user, **kwargs):
wxp_settings = PersonalisationSettings.for_site(request.site)
if wxp_settings.detailed_visits and wxp_settings.reverse_match:
SegmentVisit.reverse_match(user)

user_logged_in.connect(reverse_match)


class PersonalisablePageMetadata(ClusterableModel):
"""The personalisable page model. Allows creation of variants with linked
segments.
Expand Down
1 change: 0 additions & 1 deletion src/wagtail_personalisation/receivers.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ def check_status_change(sender, instance, *args, **kwargs):
if original_status != instance.status:
if instance.status == instance.STATUS_ENABLED:
instance.enable_date = timezone.now()
instance.visit_count = 0
return instance
if instance.status == instance.STATUS_DISABLED:
instance.disable_date = timezone.now()
Expand Down
Loading