diff --git a/sandbox/exampledata/personalisation.json b/sandbox/exampledata/personalisation.json index 93205e5..a4baeb4 100644 --- a/sandbox/exampledata/personalisation.json +++ b/sandbox/exampledata/personalisation.json @@ -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 @@ -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 diff --git a/src/wagtail_personalisation/adapters.py b/src/wagtail_personalisation/adapters.py index 933f744..fcac147 100644 --- a/src/wagtail_personalisation/adapters.py +++ b/src/wagtail_personalisation/adapters.py @@ -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 @@ -36,7 +35,7 @@ class BaseSegmentsAdapter(object): 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 @@ -50,9 +49,20 @@ class BaseSegmentsAdapter(object): """ 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.pk) + 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 @@ -150,17 +160,6 @@ class SessionSegmentsAdapter(BaseSegmentsAdapter): 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. @@ -178,14 +177,13 @@ class SessionSegmentsAdapter(BaseSegmentsAdapter): 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( diff --git a/src/wagtail_personalisation/migrations/0013_auto_20170824_1503.py b/src/wagtail_personalisation/migrations/0013_auto_20170824_1503.py new file mode 100644 index 0000000..d0761cc --- /dev/null +++ b/src/wagtail_personalisation/migrations/0013_auto_20170824_1503.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-08-24 15:03 +from __future__ import unicode_literals + +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import re + + +class Migration(migrations.Migration): + + dependencies = [ + ('wagtailcore', '0033_remove_golive_expiry_help_text'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('wagtail_personalisation', '0012_remove_personalisablepagemetadata_is_segmented'), + ] + + operations = [ + 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=255, validators=[django.core.validators.RegexValidator(re.compile('^\\d+(?:\\,\\d+)*\\Z', 32), code='invalid', message='Enter only digits separated by commas.')])), + ], + ), + 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), + ), + ] diff --git a/src/wagtail_personalisation/models.py b/src/wagtail_personalisation/models.py index 82719d7..4034f80 100644 --- a/src/wagtail_personalisation/models.py +++ b/src/wagtail_personalisation/models.py @@ -1,5 +1,7 @@ from __future__ import absolute_import, unicode_literals +from django.core.validators import validate_comma_separated_integer_list +from django.conf import settings from django.db import models, transaction from django.template.defaultfilters import slugify from django.utils.encoding import python_2_unicode_compatible @@ -35,7 +37,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( @@ -74,10 +75,27 @@ class Segment(ClusterableModel): """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)) @@ -107,6 +125,84 @@ class Segment(ClusterableModel): 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=255, validators=[validate_comma_separated_integer_list]) + + +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) + + @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 + + 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.pk in request.matched_rules] + + SegmentVisitMetadata.objects.create( + visit=visit, + segment=segment, + matched_rules=','.join(str(rule.pk) for rule in rules) + ) + + return visit + + @classmethod + def reverse_match(cls, user): + # TODO: Find a way to automate this, preferably without celery. + user_visits = cls.objects.filter(user=user) + + for visit in user_visits: + cls.objects.filter( + session=visit.session, + user__isnull=True + ).update(user=user) + + class PersonalisablePageMetadata(ClusterableModel): """The personalisable page model. Allows creation of variants with linked segments. diff --git a/src/wagtail_personalisation/receivers.py b/src/wagtail_personalisation/receivers.py index 722f09d..12c5254 100644 --- a/src/wagtail_personalisation/receivers.py +++ b/src/wagtail_personalisation/receivers.py @@ -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() diff --git a/src/wagtail_personalisation/templates/modeladmin/wagtail_personalisation/segment/dashboard.html b/src/wagtail_personalisation/templates/modeladmin/wagtail_personalisation/segment/dashboard.html index 7b9fa47..51ff2ad 100644 --- a/src/wagtail_personalisation/templates/modeladmin/wagtail_personalisation/segment/dashboard.html +++ b/src/wagtail_personalisation/templates/modeladmin/wagtail_personalisation/segment/dashboard.html @@ -28,7 +28,7 @@