├── 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'', smart_unicode(self.render_js() + u'')) 182 | 183 | return response 184 | 185 | 186 | 187 | def get_subject(self): 188 | assert self.request.session[SPLANGO_STATE] == S_HUMAN, "Hey, you can't call get_subject until you know the subject is a human!" 189 | 190 | sub = self.request.session.get(SPLANGO_SUBJECT) 191 | 192 | if not sub: 193 | sub = self.request.session[SPLANGO_SUBJECT] = Subject() 194 | sub.save() 195 | logging.info("SPLANGO! created subject: %s" % str(sub)) 196 | 197 | return sub 198 | 199 | 200 | def declare_and_enroll(self, exp_name, variants): 201 | e = Experiment.declare(exp_name, variants) 202 | 203 | if self.request.session[SPLANGO_STATE] != S_HUMAN: 204 | logging.info("SPLANGO! choosing new random variant for non-human") 205 | v = e.get_random_variant() 206 | self.enqueue("enroll", { "exp_name": e.name, "variant": v }) 207 | 208 | else: 209 | sub = self.get_subject() 210 | sv = e.get_variant_for(sub) 211 | v = sv.variant 212 | logging.info("SPLANGO! got variant %s for subject %s" % (str(v),str(sub))) 213 | 214 | return v 215 | 216 | 217 | def log_goal(self, goal_name, extra=None): 218 | 219 | request_info = GoalRecord.extract_request_info(self.request) 220 | 221 | self.enqueue("log_goal", { "goal_name": goal_name, 222 | "request_info": request_info, 223 | "extra": extra }) 224 | 225 | -------------------------------------------------------------------------------- /splango/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from splango.models import Subject, Goal, GoalRecord, Enrollment, Experiment, ExperimentReport 4 | 5 | admin.site.register(Subject) 6 | 7 | class GoalAdmin(admin.ModelAdmin): 8 | list_display = ("name","created") 9 | admin.site.register(Goal, GoalAdmin) 10 | 11 | class GoalRecordAdmin(admin.ModelAdmin): 12 | list_display = ("goal","subject","created","req_HTTP_REFERER") 13 | admin.site.register(GoalRecord, GoalRecordAdmin) 14 | 15 | class EnrollmentAdmin(admin.ModelAdmin): 16 | list_display = ("subject","experiment","variant","created") 17 | admin.site.register(Enrollment, EnrollmentAdmin) 18 | 19 | class ExperimentAdmin(admin.ModelAdmin): 20 | list_display = ("name","variants_commasep","created") 21 | admin.site.register(Experiment, ExperimentAdmin) 22 | 23 | class ExperimentReportAdmin(admin.ModelAdmin): 24 | list_display = ("title", "experiment") 25 | admin.site.register(ExperimentReport, ExperimentReportAdmin) 26 | 27 | -------------------------------------------------------------------------------- /splango/middleware.py: -------------------------------------------------------------------------------- 1 | from splango import RequestExperimentManager 2 | 3 | class ExperimentsMiddleware: 4 | 5 | def process_request(self, request): 6 | request.experiments = RequestExperimentManager(request) 7 | return None 8 | 9 | def process_response(self, request, response): 10 | if getattr(request, "experiments", None): 11 | request.experiments.finish(response) 12 | return response 13 | -------------------------------------------------------------------------------- /splango/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.contrib.auth.models import User 3 | 4 | import logging 5 | 6 | #from django.db.models import Avg, Max, Min, Count 7 | 8 | import random 9 | 10 | _NAME_LENGTH=30 11 | 12 | class Goal(models.Model): 13 | name = models.CharField(max_length=_NAME_LENGTH, primary_key=True) 14 | created = models.DateTimeField(auto_now_add=True) 15 | 16 | def __unicode__(self): 17 | return self.name 18 | 19 | 20 | class Subject(models.Model): 21 | """An experimental subject, possibly also a registered user (at creation 22 | or later on.""" 23 | 24 | created = models.DateTimeField(auto_now_add=True, db_index=True) 25 | registered_as = models.ForeignKey(User, null=True, editable=False, unique=True) 26 | 27 | goals = models.ManyToManyField(Goal, through='GoalRecord') 28 | 29 | def __unicode__(self): 30 | if self.registered_as: 31 | prefix = "registered" 32 | else: 33 | prefix = "anonymous" 34 | 35 | return u"%s subject #%d" % (prefix, self.id) 36 | 37 | def merge_into(self, othersubject): 38 | """Move the enrollments and goalrecords associated with this subject 39 | into the given othersubject, preserving the othersubject's 40 | enrollments in case of conflict.""" 41 | 42 | other_gs = dict(((g.name, 1) for g in othersubject.goals.all())) 43 | 44 | for gr in self.goalrecord_set.all().select_related("goal"): 45 | if gr.goal.name not in other_gs: 46 | gr.subject = othersubject 47 | gr.save() 48 | else: 49 | gr.delete() 50 | 51 | 52 | other_exps = dict(( (e.experiment_id,1) for e in othersubject.enrollment_set.all() )) 53 | 54 | for e in self.enrollment_set.all(): 55 | if e.experiment_id not in other_exps: 56 | e.subject = othersubject 57 | e.save() 58 | else: 59 | e.delete() 60 | 61 | self.delete() 62 | 63 | 64 | 65 | class GoalRecord(models.Model): 66 | goal = models.ForeignKey(Goal) 67 | subject = models.ForeignKey(Subject) 68 | 69 | created = models.DateTimeField(auto_now_add=True, db_index=True) 70 | req_HTTP_REFERER = models.CharField(max_length=255, null=True, blank=True) 71 | req_REMOTE_ADDR = models.IPAddressField(null=True, blank=True) 72 | req_path = models.CharField(max_length=255, null=True, blank=True) 73 | 74 | extra = models.CharField(max_length=255, null=True, blank=True) 75 | 76 | class Meta: 77 | unique_together= (('subject', 'goal'),) 78 | # never record the same goal twice for a given subject 79 | 80 | @staticmethod 81 | def extract_request_info(request): 82 | return dict( 83 | req_HTTP_REFERER=request.META.get("HTTP_REFERER","")[:255], 84 | req_REMOTE_ADDR=request.META["REMOTE_ADDR"], 85 | req_path=request.path[:255]) 86 | 87 | @classmethod 88 | def record(cls, subject, goalname, request_info, extra=None): 89 | logging.warn("Splango:goalrecord %r" % [subject, goalname, request_info, extra]) 90 | goal, created = Goal.objects.get_or_create(name=goalname) 91 | 92 | gr,created = cls.objects.get_or_create(subject=subject, 93 | goal=goal, 94 | defaults=request_info) 95 | 96 | if not(created) and not(gr.extra) and extra: 97 | # add my extra info to the existing goal record 98 | gr.extra = extra 99 | gr.save() 100 | 101 | return gr 102 | 103 | @classmethod 104 | def record_user_goal(cls, user, goalname): 105 | sub, created = Subject.objects.get_or_create(registered_as=user) 106 | cls.record(sub, goalname, {}) 107 | 108 | def __unicode__(self): 109 | return u"%s by subject #%d" % (self.goal, self.subject_id) 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | class Enrollment(models.Model): 118 | """Identifies which variant a subject is assigned to in a given 119 | experiment.""" 120 | subject = models.ForeignKey('splango.Subject', editable=False) 121 | experiment = models.ForeignKey('splango.Experiment', editable=False) 122 | created = models.DateTimeField(auto_now_add=True, db_index=True) 123 | variant = models.CharField(max_length=_NAME_LENGTH) 124 | 125 | class Meta: 126 | unique_together= (('subject', 'experiment'),) 127 | 128 | def __unicode__(self): 129 | return u"experiment '%s' subject #%d -- variant %s" % (self.experiment.name, self.subject_id, self.variant) 130 | 131 | 132 | 133 | 134 | 135 | class Experiment(models.Model): 136 | """A named experiment.""" 137 | name = models.CharField(max_length=_NAME_LENGTH, primary_key=True) 138 | variants = models.TextField() # one per line... lame and simple 139 | created = models.DateTimeField(auto_now_add=True, db_index=True) 140 | 141 | subjects = models.ManyToManyField(Subject, through=Enrollment) 142 | 143 | def __unicode__(self): 144 | return self.name 145 | 146 | def set_variants(self, variantlist): 147 | self.variants = "\n".join(variantlist) 148 | 149 | def get_variants(self): 150 | return [ x for x in self.variants.split("\n") if x ] 151 | 152 | def get_random_variant(self): 153 | return random.choice(self.get_variants()) 154 | 155 | def variants_commasep(self): 156 | return ",".join(self.get_variants()) 157 | 158 | def get_variant_for(self, subject): 159 | sv, created = Enrollment.objects.get_or_create( 160 | subject=subject, 161 | experiment=self, 162 | defaults={ 163 | "variant": self.get_random_variant() 164 | }) 165 | return sv 166 | 167 | def enroll_subject_as_variant(self, subject, variant): 168 | sv, created = Enrollment.objects.get_or_create( 169 | subject=subject, 170 | experiment=self, 171 | defaults={ 172 | "variant": variant 173 | }) 174 | return sv 175 | 176 | 177 | 178 | @classmethod 179 | def declare(cls, name, variants): 180 | e,created = cls.objects.get_or_create(name=name, 181 | defaults={ 182 | "variants":"\n".join(variants) }) 183 | return e 184 | 185 | 186 | class ExperimentReport(models.Model): 187 | """A report on the results of an experiment.""" 188 | experiment = models.ForeignKey(Experiment) 189 | title = models.CharField(max_length=100, blank=True) 190 | funnel = models.TextField(help_text="List the goals, in order and one per line, that constitute this report's funnel.") 191 | 192 | def __unicode__(self): 193 | return u"%s - %s" % (self.title, self.experiment.name) 194 | 195 | def get_funnel_goals(self): 196 | return [ x.strip() for x in self.funnel.split("\n") if x ] 197 | 198 | def generate(self): 199 | result = [] 200 | exp = self.experiment 201 | 202 | variants = self.experiment.get_variants() 203 | goals = self.get_funnel_goals() 204 | 205 | # count initial participation 206 | variant_counts = [] 207 | 208 | for v in variants: 209 | #variant_counts.append(exp.subjectvariant_set.filter(variant=v).aggregate(ct=Count("variant")).get("ct",0)) 210 | variant_counts.append( 211 | dict(val=Enrollment.objects.filter(experiment=exp, variant=v).count(), 212 | variant_name=v, 213 | pct=None, 214 | pct_cumulative=1, 215 | pct_cumulative_round=100)) 216 | 217 | 218 | 219 | result.append({ "goal": None, 220 | "variant_names": variants, 221 | "variant_counts": variant_counts }) 222 | 223 | for previ, goal in enumerate(goals): 224 | try: 225 | g = Goal.objects.get(name=goal) 226 | except Goal.DoesNotExist: 227 | logging.warn("Splango: No such goal <<%s>>." % goal) 228 | g = None 229 | 230 | variant_counts = [] 231 | 232 | 233 | for vi, v in enumerate(variants): 234 | 235 | if g: 236 | vcount = Enrollment.objects.filter(experiment=exp, 237 | variant=v, 238 | subject__goals=g 239 | ).count() 240 | 241 | prev_count = result[previ]["variant_counts"][vi]["val"] 242 | 243 | if prev_count == 0: 244 | pct = 0 245 | else: 246 | pct = float(vcount) / float(prev_count) 247 | 248 | else: 249 | vcount = 0 250 | pct = 0 251 | 252 | pct_cumulative = pct*result[previ]["variant_counts"][vi]["pct_cumulative"] 253 | 254 | variant_counts.append(dict(val=vcount, 255 | variant_name=variants[vi], 256 | pct=pct, 257 | pct_round=( "%0.2f" % (100*pct) ), 258 | pct_cumulative=pct_cumulative, 259 | pct_cumulative_round=( "%0.2f" % (100*pct_cumulative) ), 260 | ) 261 | ) 262 | 263 | 264 | result.append({ "goal": goal, "variant_counts": variant_counts }) 265 | 266 | 267 | return result 268 | -------------------------------------------------------------------------------- /splango/templates/splango/experiment_detail.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/base.html' %} 2 | {% load i18n %} 3 | 4 | {% block title %}Splango: Experiment {{title}}{% endblock %} 5 | 6 | {% block breadcrumbs %}{% endblock %} 10 | 11 | 12 | {% block content %} 13 |

Reports

14 | {% if repts %} 15 | 20 | {% else %} 21 | No reports yet. 22 | {% endif %} 23 | 24 |

add a report

25 | 26 | {% endblock %} 27 | 28 | -------------------------------------------------------------------------------- /splango/templates/splango/experiment_log.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/base.html' %} 2 | {% load i18n %} 3 | 4 | {% block title %}Splango: {{title}}{% endblock %} 5 | 6 | {% block breadcrumbs %}{% endblock %} 12 | 13 | 14 | {% block content %} 15 | 16 | {% if activities %} 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | {% for act in activities %} 27 | 28 | {% if act.variant %} 29 | {# activity is an enrollment #} 30 | 31 | 32 | 33 | 34 | 35 | {% else %} 36 | {# activity is a goal record #} 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | {% endif %} 45 | 46 | {% endfor %} 47 | 48 |
TimeSubjectTypeData
{{act.created}}{{act.subject}}Enrollment {{act.created}}{{act.subject}}{{act.goal}}{{act.req_REMOTE_ADDR}}{{act.req_path}}{{act.req_HTTP_REFERER|default:"-"}}
49 | 50 | {% else %} 51 | 52 | There is no activity logged for this experiment/variant/goal yet. 53 | 54 | {% endif %} 55 | 56 | {% endblock %} 57 | 58 | 59 | -------------------------------------------------------------------------------- /splango/templates/splango/experiment_report.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/base.html' %} 2 | {% load i18n %} 3 | 4 | {% block title %}Splango: Report {{title}}{% endblock %} 5 | 6 | {% block breadcrumbs %}{% endblock %} 12 | 13 | 14 | {% block content %} 15 | 16 | {% if report_rows %} 17 | 18 | 19 | 20 | 21 | {% for variantname in report_rows.0.variant_names %} 22 | 23 | {% endfor %} 24 | 25 | 26 | {% for row in report_rows %} 27 | 28 | 30 | {% for variantct in row.variant_counts %} 31 | 34 | 39 | 40 | 46 | {% endfor %} 47 | 48 | {% endfor %} 49 | 50 |
Goal“{{variantname}}”
{{row.goal|default_if_none:"enrolled"}} 29 | 32 | {{variantct.val}} 33 | 35 | {% if variantct.pct %}({{variantct.pct_round}}%) 36 | {% else %}   37 | {% endif %} 38 | 41 | {% if variantct.pct_cumulative_round %} 42 | 43 | {% else %}   44 | {% endif %} 45 |
51 | 52 | {% else %} 53 | 54 | This report has no data yet. 55 | 56 | {% endif %} 57 | 58 | {% endblock %} 59 | 60 | 61 | -------------------------------------------------------------------------------- /splango/templates/splango/experiments_overview.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/base.html' %} 2 | {% load i18n %} 3 | 4 | {% block title %}Splango: Overview{% endblock %} 5 | 6 | {% block breadcrumbs %}{% endblock %} 12 | 13 | 14 | {% block content %} 15 |

Active Experiments

16 | {% if exps %} 17 | 30 | {% else %} 31 | No experiments yet. 32 | {% endif %} 33 | 34 | {% endblock %} 35 | -------------------------------------------------------------------------------- /splango/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shimon/Splango/a89408b8b62d631763d42e049dd1b7d6ef72b02f/splango/templatetags/__init__.py -------------------------------------------------------------------------------- /splango/templatetags/splangotags.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.utils.html import escape 3 | from django.utils.safestring import mark_safe 4 | 5 | import logging 6 | 7 | register = template.Library() 8 | 9 | 10 | from splango.models import Goal, Subject, GoalRecord, Enrollment, Experiment, ExperimentReport 11 | 12 | CTX_PREFIX = "__splango__experiment__" 13 | 14 | class ExperimentNode(template.Node): 15 | def __init__(self, exp_name, variants): 16 | self.exp_name = exp_name 17 | self.variants = variants 18 | 19 | def render(self, context): 20 | if "request" not in context: 21 | raise template.TemplateSyntaxError("Use of splangotags requires the request context processor. Please add django.core.context_processors.request to your settings.TEMPLATE_CONTEXT_PROCESSORS.") 22 | 23 | request = context["request"] 24 | exp = request.experiments 25 | 26 | if not exp: 27 | raise template.TemplateSyntaxError("Use of splangotags requires the splango middleware. Please add splango.middleware.ExperimentsMiddleware to your settings.MIDDLEWARE_CLASSES.") 28 | 29 | expvariant = exp.declare_and_enroll(self.exp_name, self.variants) 30 | context[CTX_PREFIX + self.exp_name] = expvariant 31 | 32 | return "" # "exp: %s - you are %s" % (self.exp_name, expvariant) 33 | 34 | class HypNode(template.Node): 35 | def __init__(self, exp_name, exp_variant, nodelist): 36 | self.exp_name = exp_name 37 | self.exp_variant = exp_variant 38 | self.nodelist = nodelist 39 | 40 | # print ' ++ instantiated HypNode (%s, %s)' % (self.exp_name, 41 | # self.exp_variant) 42 | 43 | def render(self, context): 44 | # print ' == rendering HypNode (%s, %s)' % (self.exp_name, 45 | # self.exp_variant) 46 | 47 | ctxvar = CTX_PREFIX + self.exp_name 48 | 49 | if ctxvar not in context: 50 | raise template.TemplateSyntaxError("Experiment %s has not yet been declared. Please declare it and supply variant names using an experiment tag before using hyp tags.") 51 | 52 | if self.exp_variant == context[ctxvar]: 53 | return self.nodelist.render(context) 54 | else: 55 | return "" 56 | 57 | return "[%s==%s]"%(self.exp_variant, context[ctxvar])+self.nodelist.render(context)+"[/%s]"%self.exp_variant 58 | 59 | return "HypNode: exp_name=%s, exp_variant=%s" % (self.exp_name, 60 | self.exp_variant) 61 | 62 | 63 | 64 | @register.tag 65 | def experiment(parser, token): 66 | try: 67 | tag_name, exp_name, variants_label, variantstring = token.split_contents() 68 | except ValueError: 69 | raise template.TemplateSyntaxError, '%r tag requires exactly three arguments, e.g. {% experiment "signuptext" variants "control,free,trial" %}' % token.contents.split()[0] 70 | 71 | return ExperimentNode(exp_name.strip("\"'"), variantstring.strip("\"'").split(",")) 72 | 73 | 74 | 75 | @register.tag 76 | def hyp(parser, token): 77 | try: 78 | tag_name, exp_name, exp_variant = token.split_contents() 79 | except ValueError: 80 | raise template.TemplateSyntaxError, "%r tag requires exactly two arguments" % token.contents.split()[0] 81 | 82 | 83 | # print "*** hyp looking for next tag" 84 | #print "parser.tokens = %r" % [ t.contents for t in parser.tokens ] 85 | 86 | nodelist = parser.parse(("endhyp",)) 87 | token = parser.next_token() 88 | 89 | # print " * hyp FOUND TOKEN %s" % token.contents 90 | parser.delete_first_token() 91 | #print "parser.tokens = %r" % [ t.contents for t in parser.tokens ] 92 | 93 | 94 | return HypNode(exp_name.strip("\"'"), exp_variant.strip("\"'"), nodelist) 95 | 96 | 97 | # I couldn't make this work well. Probably needs much more thought to work like 98 | # a switch statement. See: 99 | # http://djangosnippets.org/snippets/967/ 100 | # 101 | # @register.tag 102 | # def elsehyp(parser, token): 103 | # try: 104 | # tag_name, exp_variant = token.split_contents() 105 | # except ValueError: 106 | # raise template.TemplateSyntaxError, "%r tag requires exactly one argument" % token.contents.split()[0] 107 | 108 | # #import pdb;pdb.set_trace() 109 | 110 | # print "*** elsehyp looking for next tag" 111 | # #print "parser.tokens = %r" % [ t.contents for t in parser.tokens ] 112 | 113 | # nodelist = parser.parse(("elsehyp","endhyp")) 114 | 115 | # return HypNode(None, exp_variant, nodelist) 116 | -------------------------------------------------------------------------------- /splango/tests.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file demonstrates two different styles of tests (one doctest and one 3 | unittest). These will both pass when you run "manage.py test". 4 | 5 | Replace these with more appropriate tests for your application. 6 | """ 7 | 8 | from django.test import TestCase 9 | 10 | class SimpleTest(TestCase): 11 | def test_basic_addition(self): 12 | """ 13 | Tests that 1 + 1 always equals 2. 14 | """ 15 | self.failUnlessEqual(1 + 1, 2) 16 | 17 | __test__ = {"doctest": """ 18 | Another way to test that 1 + 1 is equal to 2. 19 | 20 | >>> 1 + 1 == 2 21 | True 22 | """} 23 | 24 | -------------------------------------------------------------------------------- /splango/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls.defaults import * 2 | 3 | urlpatterns = patterns( 4 | 'splango.views', 5 | url(r'^confirm_human/$', 'confirm_human', name="splango-confirm-human"), 6 | 7 | url(r'^admin/$', 'experiments_overview', name="splango-admin"), 8 | url(r'^admin/exp/(?P[^/]+)/$', 'experiment_detail', name="splango-experiment-detail"), 9 | 10 | url(r'^admin/exp/(?P[^/]+)/(?P\d+)/$', 'experiment_report', name="splango-experiment-report"), 11 | 12 | url(r'^admin/exp/(?P[^/]+)/(?P[^/]+)/(?P[^/]+)/$', 'experiment_log', name="splango-experiment-log"), 13 | 14 | ) 15 | -------------------------------------------------------------------------------- /splango/views.py: -------------------------------------------------------------------------------- 1 | from django.template import RequestContext 2 | from django.views.decorators.cache import never_cache 3 | from django.contrib.admin.views.decorators import staff_member_required 4 | from django.shortcuts import render_to_response, get_object_or_404 5 | 6 | from django.http import HttpResponse 7 | 8 | from splango.models import * 9 | 10 | @never_cache 11 | def confirm_human(request): 12 | request.experiments.confirm_human() 13 | return HttpResponse(status=204) 14 | 15 | 16 | @staff_member_required 17 | def experiments_overview(request): 18 | exps = Experiment.objects.all() 19 | 20 | repts = ExperimentReport.objects.all() 21 | 22 | repts_by_id = dict() 23 | 24 | for r in repts: 25 | repts_by_id.setdefault(r.experiment_id, []).append(r) 26 | 27 | for exp in exps: 28 | exp.reports = repts_by_id.get(exp.name, []) 29 | 30 | return render_to_response("splango/experiments_overview.html", 31 | {"title":"Experiments", 32 | "exps": exps }, 33 | RequestContext(request)) 34 | 35 | @staff_member_required 36 | def experiment_detail(request, expname): 37 | exp = get_object_or_404(Experiment, name=expname) 38 | 39 | repts = ExperimentReport.objects.filter(experiment=exp) 40 | 41 | return render_to_response("splango/experiment_detail.html", 42 | {"title":exp.name, 43 | "exp": exp, 44 | "repts": repts 45 | }, 46 | RequestContext(request)) 47 | 48 | @staff_member_required 49 | def experiment_report(request, expname, report_id): 50 | 51 | rept = get_object_or_404(ExperimentReport, id=report_id, 52 | experiment__name=expname) 53 | 54 | report_rows = rept.generate() 55 | 56 | return render_to_response("splango/experiment_report.html", 57 | { "title": rept.title, 58 | "exp": rept.experiment, 59 | "rept": rept, 60 | "report_rows": report_rows, 61 | }, 62 | RequestContext(request)) 63 | 64 | 65 | @staff_member_required 66 | def experiment_log(request, expname, variant, goal): 67 | exp = get_object_or_404(Experiment, name=expname) 68 | goal = get_object_or_404(Goal, name=goal) 69 | 70 | enrollments = Enrollment.objects.filter(experiment=exp, 71 | variant=variant, 72 | subject__goals=goal).select_related("subject")[:1000] 73 | # 1000 limit is just there to keep this page sane 74 | 75 | goalrecords = GoalRecord.objects.filter( 76 | goal=goal, 77 | subject__in=[ e.subject for e in enrollments ]).select_related("goal","subject") 78 | 79 | title = "Experiment Log: variant %s, goal %s" % (variant, goal) 80 | 81 | activities = list(enrollments)+list(goalrecords) 82 | 83 | activities.sort(key=lambda x: x.created) 84 | 85 | return render_to_response("splango/experiment_log.html", 86 | { "exp": exp, 87 | "activities": activities, 88 | "title": title }, 89 | RequestContext(request)) 90 | 91 | 92 | --------------------------------------------------------------------------------