Skip to content
This repository has been archived by the owner on May 14, 2024. It is now read-only.

local sixpack #6

Closed
wants to merge 27 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Expand Up @@ -5,3 +5,5 @@ local_settings.py
build
dist
*.egg-info
*.egg
*.eggs
1 change: 1 addition & 0 deletions AUTHORS
@@ -1,3 +1,4 @@
Dan Langer <dan@waveapps.com>
Joseph Kahn <jkahn@waveapps.com>
Robin Edwards <https://github.com/robinedwards>
Adrian Maurer <amaurer@waveapps.com>
21 changes: 21 additions & 0 deletions README.rst
Expand Up @@ -87,6 +87,27 @@ If something ever goes wrong - a request times out, the ``sixpack`` server disap
calls will return the control alternative, and all ``convert`` calls will seem successful (and we'll note this happend
in the log).

-----------------
Tracking Locally
-----------------

By passing in the argument `local` in the SixpackTest constructor you can tell django-sixpack to create a convertible db record for each participant.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add docs about why you'd want to do this/use cases for this? I get that it's creating a record in the DB, but not why I'd like to do that.


This feature is particullary useful when you want to pull information on an existing participant, a feature unavailable through Sixpack API.

.. code:: python

experiment = ButtonColorTest(local=True)

You may also choose to track only locally by passing in `sixpack=False` to the test constructor.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This description doesn't match the sample code on line 106.


.. code:: python

experiment = ButtonColorTest(local=True, server=False)

*Make sure you run a migration for the django-sixpack's `SixpackParticipant` model.*


Suported Versions
-----------------

Expand Down
2 changes: 1 addition & 1 deletion djsixpack/__init__.py
@@ -1 +1 @@
__version__ = "1.0.5"
__version__ = "1.1.3-wave"
55 changes: 47 additions & 8 deletions djsixpack/djsixpack.py
Expand Up @@ -5,6 +5,8 @@
from django.conf import settings
from requests.exceptions import RequestException

from models import SixpackParticipant

RE_FIRST_CAP = re.compile('(.)([A-Z][a-z]+)')
RE_ALL_CAP = re.compile('([a-z0-9])([A-Z])')
RE_TEST_NAME = re.compile('_test$')
Expand All @@ -27,11 +29,17 @@ class SixpackTest(object):
timeout = None
control = None
alternatives = None
local = False
server = True

def __init__(self, instance):
def __init__(self, instance, local=None, server=None):
self._instance = instance
self.host = self.host or getattr(settings, 'SIXPACK_HOST', sixpack.SIXPACK_HOST)
self.timeout = self.timeout or getattr(settings, 'SIXPACK_TIMEOUT', sixpack.SIXPACK_TIMEOUT)
if local is not None:
self.local = local
if server is not None:
self.server = server

@property
def client_id(self):
Expand Down Expand Up @@ -62,8 +70,15 @@ def get_client_id(self, instance):

return client_id

def participate(self, force=None, user_agent=None, ip_address=None):
if not self.host:
def get_participant_bucket(self):
try:
participant = SixpackParticipant.objects.get(unique_attr=self.client_id, experiment_name=self._get_experiment_name())
except SixpackParticipant.DoesNotExist:
return None
return participant.bucket

def participate(self, force=None, user_agent=None, ip_address=None, prefetch=False, bucket=None):
if not self.host and not self.local:
try:
if force in self.alternatives:
return force
Expand All @@ -73,16 +88,32 @@ def participate(self, force=None, user_agent=None, ip_address=None):

session = self._get_session(user_agent, ip_address)
experiment_name = self._get_experiment_name()
chosen_alternative = bucket

if self.local and not self.server:
prefetch = True

try:
resp = session.participate(experiment_name, self.alternatives, force)
resp = session.participate(experiment_name, self.alternatives, force=force, prefetch=prefetch, bucket=bucket)
except RequestException:
logger.exception("Error while trying to .participate")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While you're in here, please make the quotation use consistent.

if force in self.alternatives:
return force
return self.alternatives[0]
chosen_alternative = force
else:
chosen_alternative = self.alternatives[0]
else:
return resp['alternative']['name']
chosen_alternative = resp['alternative']['name']
finally:
if self.local and chosen_alternative:
try:
SixpackParticipant.objects.get_or_create(unique_attr=self.client_id, experiment_name=experiment_name, bucket=chosen_alternative)
except SixpackParticipant.MultipleObjectsReturned:
# clean up duplicate entries
duplicates = SixpackParticipant.objects.filter(unique_attr=self.client_id, experiment_name=experiment_name, bucket=chosen_alternative)
for dup in duplicates[1:]:
dup.delete()

return chosen_alternative

def convert(self, kpi=None):
if not self.host:
Expand All @@ -92,8 +123,16 @@ def convert(self, kpi=None):
experiment_name = self._get_experiment_name()
try:
resp = session.convert(experiment_name)

if self.local:
participant_exists = SixpackParticipant.objects.filter(unique_attr=self.client_id, experiment_name=experiment_name).exists()
if participant_exists:
participant = SixpackParticipant.objects.filter(unique_attr=self.client_id, experiment_name=experiment_name)[0]
participant.converted = True
participant.save()

except RequestException as e:
logger.exception("Error while trying to .convert: {err}".format(err=e))
logger.exception("Error while trying to .convert: %s", e)
return False
else:
return resp['status'] == 'ok'
28 changes: 28 additions & 0 deletions djsixpack/migrations/0001_initial.py
@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals

from django.db import models, migrations


class Migration(migrations.Migration):

dependencies = [
]

operations = [
migrations.CreateModel(
name='SixpackParticipant',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('experiment_name', models.CharField(max_length=255, db_index=True)),
('unique_attr', models.CharField(max_length=255, db_index=True)),
('converted', models.BooleanField(default=False)),
('bucket', models.CharField(max_length=255)),
('date_added', models.DateTimeField(auto_now_add=True)),
('date_modified', models.DateTimeField(auto_now=True)),
],
options={
},
bases=(models.Model,),
),
]
Empty file.
11 changes: 10 additions & 1 deletion djsixpack/models.py
@@ -1 +1,10 @@
# No models
from django.db import models


class SixpackParticipant(models.Model):
experiment_name = models.CharField(max_length=255, db_index=True)
unique_attr = models.CharField(max_length=255, db_index=True)
converted = models.BooleanField(default=False)
bucket = models.CharField(max_length=255)
date_added = models.DateTimeField(auto_now_add=True)
date_modified = models.DateTimeField(auto_now=True)
42 changes: 42 additions & 0 deletions djsixpack/south_migrations/0001_initial.py
@@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
from south.utils import datetime_utils as datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models


class Migration(SchemaMigration):

def forwards(self, orm):
# Adding model 'SixpackParticipant'
db.create_table(u'djsixpack_sixpackparticipant', (
(u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('experiment_name', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
('unique_attr', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
('converted', self.gf('django.db.models.fields.BooleanField')(default=False)),
('bucket', self.gf('django.db.models.fields.CharField')(max_length=255)),
('date_added', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)),
('date_modified', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True)),
))
db.send_create_signal(u'djsixpack', ['SixpackParticipant'])


def backwards(self, orm):
# Deleting model 'SixpackParticipant'
db.delete_table(u'djsixpack_sixpackparticipant')


models = {
u'djsixpack.sixpackparticipant': {
'Meta': {'object_name': 'SixpackParticipant'},
'bucket': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'converted': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'date_added': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'date_modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
'experiment_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'unique_attr': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'})
}
}

complete_apps = ['djsixpack']
Empty file.
54 changes: 53 additions & 1 deletion djsixpack/tests/class_tests.py
Expand Up @@ -86,6 +86,58 @@ class DefaultTest(SixpackTest):
self.assertEqual(alternative, 'FIRST')
self.assertFalse(sp_mock.participate.called)

@patch('djsixpack.djsixpack.SixpackTest._get_session')
@patch('djsixpack.djsixpack.SixpackParticipant.objects.get_or_create')
def test_participate_returns_and_saves_local(self, mock_get_or_create, mock_get_session):
mock_user = Mock(pk=10)

class DefaultTest(SixpackTest):
alternatives = ('FIRST', 'SECOND')

class MockSession(object):
def participate(self, experiment_name, alternatives, force, prefetch):
return {
'alternative': {'name': 'SECOND'}
}

mock_session = MockSession()
mock_get_session.return_value = mock_session

with patch.object(MockSession, 'participate') as mock_participate:
expt = DefaultTest(mock_user, local=True)
expt.participate(force='SECOND')
self.assertEqual(mock_participate.call_args[1]['force'], 'SECOND')
self.assertFalse(mock_participate.call_args[1]['prefetch'])

self.assertTrue(mock_get_or_create.called)

@patch('djsixpack.djsixpack.SixpackTest._get_session')
@patch('djsixpack.djsixpack.SixpackParticipant.objects.get_or_create')
def test_participate_returns_and_saves_local_no_sixpack(self, mock_get_or_create, mock_get_session):
mock_user = Mock(pk=10)

class DefaultTest(SixpackTest):
alternatives = ('FIRST', 'SECOND')

class MockSession(object):
def participate(self, experiment_name, alternatives, force, prefetch):
return {
'alternative': {'name': 'SECOND'}
}

mock_session = MockSession()
mock_get_session.return_value = mock_session

with patch.object(MockSession, 'participate') as mock_participate:
expt = DefaultTest(mock_user, local=True, server=False)
expt.participate(force='SECOND')
self.assertEqual(mock_participate.call_args[0][0], expt._get_experiment_name())
self.assertEqual(mock_participate.call_args[0][1], expt.alternatives)
self.assertEqual(mock_participate.call_args[1]['force'], 'SECOND')
self.assertTrue(mock_participate.call_args[1]['prefetch'])

self.assertFalse(mock_get_or_create.objects.get_or_create.called)

def test_participate_calls_library(self):
mock_user = Mock(pk=10)

Expand All @@ -106,7 +158,7 @@ class DefaultTest(SixpackTest):
)
self.assertEqual(
sp_mock.Session.return_value.participate.call_args_list,
[call('default', ('FIRST', 'SECOND'), None)]
[call('default', ('FIRST', 'SECOND'), force=None, prefetch=False)]
)

class ConvertTest(TestCase):
Expand Down
Empty file modified runtests.py 100644 → 100755
Empty file.
2 changes: 1 addition & 1 deletion setup.py
@@ -1,6 +1,6 @@
from setuptools import setup, find_packages

install_reqs = ["Django>=1.4", "sixpack-client==1.0.0"]
install_reqs = ["Django>=1.4", "sixpack-client-wave==1.1.2-wave"]

setup(
name='django-sixpack',
Expand Down