├── LICENSE
├── MANIFEST.in
├── README.markdown
├── setup.py
└── splango
├── .gitignore
├── __init__.py
├── admin.py
├── middleware.py
├── models.py
├── templates
└── splango
│ ├── experiment_detail.html
│ ├── experiment_log.html
│ ├── experiment_report.html
│ └── experiments_overview.html
├── templatetags
├── __init__.py
└── splangotags.py
├── tests.py
├── urls.py
└── views.py
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2010-2013 Shimon Rura. All rights reserved.
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy
4 | of this software and associated documentation files (the "Software"), to deal
5 | in the Software without restriction, including without limitation the rights
6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | copies of the Software, and to permit persons to whom the Software is
8 | furnished to do so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in
11 | all copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19 | THE SOFTWARE.
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include README.markdown
2 | recursive-include splango/templates *.html
3 |
--------------------------------------------------------------------------------
/README.markdown:
--------------------------------------------------------------------------------
1 | # Splango: Drop-in Split Testing for Django
2 |
3 | Splango is designed to help you take the first steps with split (A/B)
4 | testing with minimal friction. It allows you to instantly declare and run a
5 | split test experiment in your templates or in python code, and provides an
6 | admin UI for viewing simple funnel reports on the results.
7 |
8 | ## Template Example
9 |
10 | {% load splangotags %}
11 |
12 | {# first declare the experiment and its variants #}
13 | {% experiment "signuptext" variants "control,free,trial" %}
14 |
15 | Welcome to my site! Please
16 |
17 |
18 | {# change what is rendered based on which experimental variant you're in #}
19 | {% hyp "signuptext" "control" %}
20 | sign up
21 | {% endhyp %}
22 |
23 | {% hyp "signuptext" "free" %}
24 | sign up for free
25 | {% endhyp %}
26 |
27 | {% hyp "signuptext" "trial" %}
28 | sign up for a trial
29 | {% endhyp %}
30 |
31 |
32 |
33 | ## Python View Example
34 |
35 | def mypage(request):
36 | exp = request.experiments
37 |
38 | expvariant = exp.declare_and_enroll("call_to_action", ["a","b"])
39 |
40 | if expvariant == "a":
41 | call_to_action_label = "try it"
42 | elif expvariant == "b":
43 | call_to_action_label = "this might not suck"
44 |
45 | if request.method == "POST":
46 | form = PleaseDoThisForm(request.POST)
47 |
48 | if form.is_valid():
49 | exp.log_goal("pleasedoform.completed")
50 | return HttpResponseRedirect(...)
51 |
52 | else:
53 | form = PleaseDoThisForm()
54 | exp.log_goal("pleasedoform.seen")
55 |
56 | return render_to_response("mytemplate.html", {
57 | "call_to_action_label": call_to_action_label },
58 | RequestContext(request))
59 |
60 |
61 | ## Things to Note
62 |
63 | * In order to filter out bots, Splango injects a javascript fragment into
64 | your HTTP response. Only clients that have a Django session and can run
65 | javascript will be tracked in experiments.
66 |
67 | * When a user logs in or registers, any experiment enrollments created while
68 | the user was an anonymous Subject will be merged into a Subject associated
69 | with the User. In case of conflict, enrollments previously associated with
70 | a logged-in Subject will override anonymous enrollments. In other words,
71 | Splango tries to be consistent as to what it presented to a particular
72 | human, as long as we can identify them.
73 |
74 |
75 | ## Installation
76 |
77 | * Ensure you have the dependencies:
78 | * django's session package
79 | * django's admin for viewing results
80 | * jQuery
81 |
82 | * Put the splango directory somewhere in your PYTHON_PATH.
83 |
84 | * In your project's settings.py:
85 |
86 | * add "splango" to INSTALLED_APPS
87 |
88 | * add this to your MIDDLEWARE_CLASSES after the session and auth
89 | middleware:
90 |
91 | 'splango.middleware.ExperimentsMiddleware'
92 |
93 | * optionally, define a goal to be logged when the first visit to your site
94 | is made:
95 |
96 | SPLANGO_FIRST_VISIT_GOAL = "firstvisit"
97 |
98 | If this is defined, splango will automatically log the goal "firstvisit"
99 | as being completed on the user's first request.
100 |
101 | * In your urls.py, include the splango urls and admin_urls modules:
102 |
103 | (r'^splango/', include('splango.urls')),
104 |
105 | * Ensure jQuery is available on all text/html responses. Otherwise splango
106 | will not work. Splango will remind you of this by putting annoying
107 | javascript alert() messages on such pages if settings.DEBUG is true.
108 |
109 | * Finally, go to /splango/admin to create and view experiments.
110 |
111 |
112 | ## Usage Notes
113 |
114 | * The names of experiments and goals are their sole identifier. This keeps
115 | things simple, but also means that typos can mess things up.
116 |
117 | * Hypotheses within an experiment must have unique names, but you can reuse
118 | a hypothesis name (e.g. "control") in multiple experiments if you wish.
119 |
120 | ## License
121 |
122 | As documented in the LICENSE file, Splango is available for free use and modification under an MIT-style license.
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 |
3 | from distutils.core import setup
4 |
5 | setup(name='Splango',
6 | version='0.1',
7 | description='Split (A/B) testing library for Django',
8 | author='Shimon Rura',
9 | author_email='shimon@rura.org',
10 | url='http://github.com/shimon/Splango',
11 | packages=['splango','splango.templatetags'],
12 | package_data={'splango': ['templates/*.html', 'templates/*/*.html']}
13 | )
14 |
--------------------------------------------------------------------------------
/splango/.gitignore:
--------------------------------------------------------------------------------
1 | *~
2 | *.pyc
3 |
--------------------------------------------------------------------------------
/splango/__init__.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 |
3 | import logging
4 | from django.utils.encoding import smart_unicode
5 | from django.core.urlresolvers import reverse, NoReverseMatch
6 |
7 | from splango.models import Subject, Experiment, Enrollment, GoalRecord
8 |
9 | SPLANGO_STATE = "SPLANGO_STATE"
10 | SPLANGO_SUBJECT = "SPLANGO_SUBJECT"
11 | SPLANGO_QUEUED_UPDATES = "SPLANGO_QUEUED_UPDATES"
12 | S_UNKNOWN = "UNKNOWN"
13 | S_HUMAN = "HUMAN"
14 |
15 | # borrowed from debug_toolbar
16 | _HTML_TYPES = ('text/html', 'application/xhtml+xml')
17 |
18 | # borrowed from debug_toolbar
19 | def replace_insensitive(string, target, replacement):
20 | """
21 | Similar to string.replace() but is case insensitive
22 | Code borrowed from: http://forums.devshed.com/python-programming-11/case-insensitive-string-replace-490921.html
23 | """
24 | no_case = string.lower()
25 | index = no_case.rfind(target.lower())
26 | if index >= 0:
27 | return string[:index] + replacement + string[index + len(target):]
28 | else: # no results so return the original string
29 | return string
30 |
31 |
32 |
33 |
34 | class RequestExperimentManager:
35 |
36 | def __init__(self, request):
37 | #logging.debug("REM init")
38 | self.request = request
39 | self.user_at_init = request.user
40 | self.queued_actions = []
41 |
42 | if self.request.session.get(SPLANGO_STATE) is None:
43 | self.request.session[SPLANGO_STATE] = S_UNKNOWN
44 |
45 | if self.is_first_visit():
46 |
47 | logging.info("SPLANGO! First visit!")
48 |
49 | first_visit_goalname = getattr(settings,
50 | "SPLANGO_FIRST_VISIT_GOAL",
51 | None)
52 |
53 | if first_visit_goalname:
54 | self.log_goal(first_visit_goalname)
55 |
56 | def enqueue(self, action, params):
57 | self.queued_actions.append( (action, params) )
58 |
59 |
60 | def process_from_queue(self, action, params):
61 | logging.info("SPLANGO! dequeued: %s (%s)" % (str(action), repr(params)))
62 |
63 | if action == "enroll":
64 | exp = Experiment.objects.get(name=params["exp_name"])
65 | exp.enroll_subject_as_variant(self.get_subject(),
66 | params["variant"])
67 |
68 | elif action == "log_goal":
69 | g = GoalRecord.record(self.get_subject(),
70 | params["goal_name"],
71 | params["request_info"],
72 | extra=params.get("extra"))
73 |
74 | logging.info("SPLANGO! goal! %s" % str(g))
75 |
76 |
77 | else:
78 | raise RuntimeError("Unknown queue action '%s'." % action)
79 |
80 |
81 | def is_first_visit(self):
82 | r = self.request
83 |
84 | if r.user.is_authenticated():
85 | return False
86 |
87 | ref = r.META.get("HTTP_REFERER", "").lower()
88 |
89 | if not ref: # if no referer, then musta just typed it in
90 | return True
91 |
92 | if ref.startswith("http://"):
93 | ref = ref[7:]
94 | elif ref.startswith("https://"):
95 | ref = ref[8:]
96 |
97 | return not(ref.startswith(r.get_host()))
98 |
99 |
100 | def render_js(self):
101 | logging.info("SPLANGO! render_js")
102 |
103 | prejs = ""
104 | postjs = ""
105 |
106 | if settings.DEBUG:
107 | prejs = "try { "
108 | postjs = ' } catch(e) { alert("DEBUG notice: Splango encountered a javascript error when attempting to confirm this user as a human. Is jQuery loaded?\\n\\nYou may notice inconsistent experiment enrollments until this is fixed.\\n\\nDetails:\\n"+e.toString()); }'
109 |
110 | try:
111 | url = reverse("splango-confirm-human")
112 | except NoReverseMatch:
113 | url = "/splango/confirm_human/"
114 |
115 | return """""" % (prejs, url, postjs)
116 |
117 |
118 | def confirm_human(self, reqdata=None):
119 | logging.info("SPLANGO! Human confirmed!")
120 | self.request.session[SPLANGO_STATE] = S_HUMAN
121 |
122 | for (action, params) in self.request.session.get(SPLANGO_QUEUED_UPDATES, []):
123 | self.process_from_queue(action, params)
124 |
125 |
126 | def finish(self, response):
127 | curstate = self.request.session.get(SPLANGO_STATE, S_UNKNOWN)
128 |
129 | #logging.info("SPLANGO! finished... state=%s" % curstate)
130 |
131 | curuser = self.request.user
132 |
133 | if self.user_at_init != curuser:
134 | logging.info("SPLANGO! user status changed over request: %s --> %s" % (str(self.user_at_init), str(curuser)))
135 |
136 | if not(curuser.is_authenticated()):
137 | # User logged out. It's a new session, nothing special.
138 | pass
139 |
140 | else:
141 | # User has just logged in (or registered).
142 | # We'll merge the session's current Subject with
143 | # an existing Subject for this user, if exists,
144 | # or simply set the subject.registered_as field.
145 |
146 | self.request.session[SPLANGO_STATE] = S_HUMAN
147 | # logging in counts as being proved a human
148 |
149 | old_subject = self.request.session.get(SPLANGO_SUBJECT)
150 |
151 | try:
152 | existing_subject = Subject.objects.get(registered_as=curuser)
153 | # there is an existing registered subject!
154 | if old_subject and old_subject.id != existing_subject.id:
155 | # merge old subject's activity into new
156 | old_subject.merge_into(existing_subject)
157 |
158 | # whether we had an old_subject or not, we must
159 | # set session to use our existing_subject
160 | self.request.session[SPLANGO_SUBJECT] = existing_subject
161 |
162 | except Subject.DoesNotExist:
163 | # promote current subject to registered!
164 | sub = self.get_subject()
165 | sub.registered_as = curuser
166 | sub.save()
167 |
168 | if curstate == S_HUMAN:
169 | # run anything in my queue
170 | for (action, params) in self.queued_actions:
171 | self.process_from_queue(action, params)
172 | self.queued_actions = []
173 |
174 | else:
175 | # shove queue into session
176 | self.request.session.setdefault(SPLANGO_QUEUED_UPDATES, []).extend(self.queued_actions)
177 | self.queued_actions = []
178 |
179 | # and include JS if suitable for this response.
180 | if response['Content-Type'].split(';')[0] in _HTML_TYPES:
181 | response.content = replace_insensitive(smart_unicode(response.content), u'