Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

first commit

  • Loading branch information...
commit 0135f8a47494f0079bd1408d6536fe0adfbf36e4 0 parents
@chrisvxd chrisvxd authored
Showing with 2,855 additions and 0 deletions.
  1. +6 −0 .gitignore
  2. +21 −0 LICENSE
  3. +2 −0  MANIFEST.in
  4. +271 −0 README.rst
  5. 0  example_project/__init__.py
  6. +12 −0 example_project/manage.py
  7. +141 −0 example_project/settings.py
  8. +1 −0  example_project/templates/404.html
  9. +1 −0  example_project/templates/500.html
  10. +11 −0 example_project/templates/goal.html
  11. +24 −0 example_project/templates/test_page.html
  12. +16 −0 example_project/urls.py
  13. +6 −0 experiments/__init__.py
  14. +25 −0 experiments/admin.py
  15. +51 −0 experiments/counters.py
  16. +5 −0 experiments/manager.py
  17. +394 −0 experiments/media/css/experiments.css
  18. BIN  experiments/media/img/button-bg.jpg
  19. BIN  experiments/media/img/delete.png
  20. BIN  experiments/media/img/edit.png
  21. +19 −0 experiments/media/js/experiments.js
  22. +61 −0 experiments/media/js/jquery.cookie.js
  23. +188 −0 experiments/media/js/nexus_experiments.js
  24. +13 −0 experiments/media/js/string_score.min.js
  25. +9 −0 experiments/middleware.py
  26. +155 −0 experiments/models.py
  27. +239 −0 experiments/nexus_modules.py
  28. +63 −0 experiments/signals.py
  29. +76 −0 experiments/significance.py
  30. +275 −0 experiments/stats.py
  31. +5 −0 experiments/templates/experiments/confirm_human.html
  32. +1 −0  experiments/templates/experiments/goal.html
  33. +12 −0 experiments/templates/nexus/experiments/dashboard.html
  34. +205 −0 experiments/templates/nexus/experiments/index.html
  35. +138 −0 experiments/templates/nexus/experiments/results.html
  36. 0  experiments/templatetags/__init__.py
  37. +47 −0 experiments/templatetags/experiment_helpers.py
  38. +61 −0 experiments/templatetags/experiments.py
  39. +7 −0 experiments/urls.py
  40. +212 −0 experiments/utils.py
  41. +37 −0 experiments/views.py
  42. +5 −0 requirements.txt
  43. +40 −0 setup.py
6 .gitignore
@@ -0,0 +1,6 @@
+.idea/*
+*.pyc
+*~
+*conflicted copy*
+*.sublime*
+*experiments.db
21 LICENSE
@@ -0,0 +1,21 @@
+The MIT License
+
+Copyright (c) 2012 Mixcloud
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
2  MANIFEST.in
@@ -0,0 +1,2 @@
+include LICENSE README.md requirements.txt
+recursive-include experiments *.py *.html *.js *.css
271 README.rst
@@ -0,0 +1,271 @@
+Django-Experiments
+==================
+
+Django-Experiments is an AB Testing Framework for Django and Nexus. It is
+completely usable via template tags. It provides support for conditional
+user enrollment via Gargoyle.
+
+If you don't know what AB testing is, check out `wikipedia <http://en.wikipedia.org/wiki/A/B_testing>`_.
+
+Installation
+------------
+
+Django-Experiments is best installed via pip:
+
+::
+
+ pip install django-experiments
+
+This should download django-experiments and any dependencies. If downloading from the repo,
+pip is still the recommended way to install dependencies:
+
+::
+
+ pip install -r requirements.txt
+
+Dependencies
+------------
+- `Django <https://github.com/django/django/>`_
+- `Nexus <https://github.com/dcramer/nexus/>`_
+- `Gargoyle <https://github.com/disqus/gargoyle/>`_
+- `Redis <http://redis.io/>`_
+- `jsonfield <https://github.com/bradjasper/django-jsonfield/>`_
+
+(Detailed list in requirements.txt)
+
+Usage
+-----
+
+The example project is a good place to get started and have a play.
+Results are stored in redis and displayed in the nexus admin. The key
+components of this framework are: the experiments, alternatives and
+goals.
+
+
+Configuration
+~~~~~~~~~~~~~
+
+Before you can start configuring django-experiments, you must ensure
+you have a redis server up and running. See `redis.io <http://redis.io/>`_ for downloads and documentation.
+
+This is a quick guide to configuring your settings file to the bare minimum.
+First, add the relevant settings for your redis server (we run it as localhost):
+
+::
+
+ #Example Redis Settings
+ EXPERIMENTS_REDIS_HOST = 'localhost'
+ EXPERIMENTS_REDIS_PORT = 6379
+ EXPERIMENTS_REDIS_DB = 0
+
+Next, activate the apps by adding them to your INSTALLED_APPS:
+
+::
+
+ #Installed Apps
+ INSTALLED_APPS = [
+ ...
+ 'django.contrib.humanize',
+ 'nexus',
+ 'gargoyle',
+ 'experiments',
+ ]
+
+And add our middleware:
+
+::
+
+ MIDDLEWARE_CLASSES [
+ ...
+ 'experiments.middleware.ExperimentsMiddleware',
+ ]
+
+We haven't configured our goals yet, we'll do that in a bit.
+
+*Note, more configuration options are detailed below.*
+
+
+Experiments and Alternatives
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+The experiment is manually created in your nexus admin.\*
+
+An experiment allows you to test the effect of various design
+alternatives on user interaction. Nexus Experiments is designed to work
+from within django templates, to make it easier for designers. We begin
+by loading our module:
+
+::
+
+ {% load experiments %}
+
+and we then define our first experiment and alternative, using the
+following syntax:
+
+::
+
+ {% experiment EXPERIMENT ALTERNATIVE %}
+
+We are going to run an experiment called “register\_text” to see what
+registration link text causes more users to complete the registration
+process. Our first alternative must always be the “control” alternative.
+This is our fallback if the experiment is disabled.
+
+::
+
+ {% experiment register_text control %}
+ <a href = "register.html">Register now.</a>
+ {% endexperiment %}
+
+So while the experiment is disabled, users will see a register link
+saying “Register now”. Let’s define another, more polite alternative:
+
+::
+
+ {% experiment register_text polite %}
+ <a href = "register.html">Please register!</a>
+ {% endexperiment %}
+
+While experiment is disabled, users will still see the “control”
+alternative, and their registration link will say “Register now”. When
+the experiment is enabled, users will be randomly assigned to each
+alternative. This information is stored in the enrollment, a unique
+combination of the user, the experiment and which alternative they are
+assigned to.
+
+\*\ *Experiments will be dynamically created by default if they are
+defined in a template but not in the admin. This can be overridden in
+settings.*
+
+Goals
+~~~~~
+
+Goals allow us to acknowledge when a user hits a certain page. You
+specify them in the EXPERIMENTS\_GOALS tuple in your settings. Given the
+example above, we would want a goal to be triggered once the user has
+completed the registration process.
+
+Add the goal to our EXPERIMENT_GOALS tuple in setting.py:
+
+::
+
+ EXPERIMENTS_GOALS = ("registration")
+
+Our registration successful page will contain our goal, “registration”:
+
+::
+
+ {% experiment_goal "registration" %}
+
+This will be fired when the user loads the page. There are three ways
+ways of using goals: a server-sided python function, a JavaScript onclick event, or
+cookies.
+
+The python function, somewhere in your django views:
+
+::
+
+ from experiments.utils import record_goal
+
+ record_goal(request, 'registration')
+
+The JavaScript onclick method:
+
+::
+
+ <button onclick="experiments.goal('registration')">Complete Registration</button>
+
+The cookie method:
+
+::
+
+ <span data-experiments-goal="registration">Complete Registration</span>
+
+The goal is independent from the experiment as many experiments can all
+have the same goal. The goals are defined in the settings.py file for
+your project.
+
+Confirming Human
+~~~~~~~~~~~~~~~~
+
+The framework can distinguish between humans and bots. By including
+
+::
+
+ {% include "experiments/confirm_human.html" %}
+
+at some point in your code (we recommend you put it in your base.html
+file), unregistered users will then be confirmed as human. This can be
+quickly overridden in settings, but be careful - bots can really mess up
+your results!
+
+Managing Experiments
+--------------------
+
+Experiments can be managed in the nexus dashboard (/nexus/experiments by
+default).
+
+The States
+~~~~~~~~~~
+
+**Control** - The experiment is essentially disabled. All users will see
+the control alternative, and no data will be collected.
+
+**Enabled** - The experiment is enabled globally, for all users.
+
+**Gargoyle** - If a switch\_key is specified, the experiment will rely
+on the gargoyle switch to determine if the user is included in the
+experiment. More on this below.
+
+Using Gargoyle
+~~~~~~~~~~~~~~
+
+Gargoyle lets you toggle features to selective sets of users based on a
+set of conditions. Connecting an experiment to a gargoyle “switch”
+allows us to run targeted experiments - very useful if we don’t want to
+expose everyone to it. For example, we could specify to run the result
+to 10% of our users, or only to staff.
+
+
+All Settings
+------------
+
+::
+
+ #Experiment Goals
+ EXPERIMENTS_GOALS = ()
+
+ #Auto-create experiment if doesn't exist
+ EXPERIMENTS_AUTO_CREATE = True
+
+ #Auto-create gargoyle switch if switch doesn't exist when added to experiment
+ EXPERIMENTS_SWITCH_AUTO_CREATE = True
+
+ #Auto-delete gargoyle switch that the experiment is linked to on experiment deletion
+ EXPERIMENTS_SWITCH_AUTO_DELETE = True
+
+ #Naming scheme for gargoyle switch name if auto-creating
+ EXPERIMENTS_SWITCH_LABEL = "Experiment: %s"
+
+ #Toggle whether the framework should verify user is human. Be careful.
+ EXPERIMENTS_VERIFY_HUMAN = False
+
+ #Example Redis Settings
+ EXPERIMENTS_REDIS_HOST = 'localhost'
+ EXPERIMENTS_REDIS_PORT = 6379
+ EXPERIMENTS_REDIS_DB = 0
+
+ #Middleware
+ MIDDLEWARE_CLASSES [
+ ...
+ 'experiments.middleware.ExperimentsMiddleware',
+ ]
+
+ #Installed Apps
+ INSTALLED_APPS = [
+ ...
+ 'django.contrib.humanize',
+ 'nexus',
+ 'gargoyle',
+ 'experiments',
+ ]
0  example_project/__init__.py
No changes.
12 example_project/manage.py
@@ -0,0 +1,12 @@
+#!/usr/bin/env python
+from django.core.management import execute_manager
+
+try:
+ import settings # Assumed to be in the same directory.
+except ImportError:
+ import sys
+ sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n(If the file settings.py does indeed exist, it's causing an ImportError somehow.)\n" % __file__)
+ sys.exit(1)
+
+if __name__ == "__main__":
+ execute_manager(settings)
141 example_project/settings.py
@@ -0,0 +1,141 @@
+import os.path
+import sys
+
+# Experiments Settings
+EXPERIMENTS_GOALS = (
+ 'page_goal',
+ 'js_goal',
+ 'cookie_goal',
+)
+
+EXPERIMENTS_AUTO_CREATE = True
+
+EXPERIMENTS_SWITCH_AUTO_CREATE = True
+EXPERIMENTS_SWITCH_AUTO_DELETE = True
+
+EXPERIMENTS_SWITCH_LABEL = "Experiment: %s"
+
+EXPERIMENTS_VERIFY_HUMAN = True #Careful with this setting, if it is toggled then participant counters will not increment accordingly
+
+# Redis Settings
+EXPERIMENTS_REDIS_HOST = 'localhost'
+EXPERIMENTS_REDIS_PORT = 6379
+EXPERIMENTS_REDIS_DB = 0
+
+
+# Other settings
+# Django settings for example_project project.
+NEXUS_MEDIA_PREFIX = '/nexus/media/'
+
+DEBUG = True
+TEMPLATE_DEBUG = True
+
+ADMINS = (
+ # ('Your Name', 'your_email@domain.com'),
+)
+
+INTERNAL_IPS = ('127.0.0.1',)
+
+MANAGERS = ADMINS
+
+PROJECT_ROOT = os.path.dirname(__file__)
+
+sys.path.insert(0, os.path.abspath(os.path.join(PROJECT_ROOT, '..')))
+
+DATABASES = {
+ 'default': {
+ 'ENGINE': 'django.db.backends.sqlite3', # Add 'postgresql_psycopg2', 'postgresql', 'mysql', 'sqlite3' or 'oracle'.
+ 'NAME': 'experiments.db', # Or path to database file if using sqlite3.
+ 'USER': '', # Not used with sqlite3.
+ 'PASSWORD': '', # Not used with sqlite3.
+ 'HOST': '', # Set to empty string for localhost. Not used with sqlite3.
+ 'PORT': '', # Set to empty string for default. Not used with sqlite3.
+ }
+}
+
+# Local time zone for this installation. Choices can be found here:
+# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
+# although not all choices may be available on all operating systems.
+# On Unix systems, a value of None will cause Django to use the same
+# timezone as the operating system.
+# If running in a Windows environment this must be set to the same as your
+# system time zone.
+TIME_ZONE = 'Europe/London'
+
+# Language code for this installation. All choices can be found here:
+# http://www.i18nguy.com/unicode/language-identifiers.html
+LANGUAGE_CODE = 'en-us'
+
+SITE_ID = 1
+
+# If you set this to False, Django will make some optimizations so as not
+# to load the internationalization machinery.
+USE_I18N = True
+
+# If you set this to False, Django will not format dates, numbers and
+# calendars according to the current locale
+USE_L10N = True
+
+# Absolute path to the directory that holds media.
+# Example: "/home/media/media.lawrence.com/"
+MEDIA_ROOT = ''
+
+# URL that handles the media served from MEDIA_ROOT. Make sure to use a
+# trailing slash if there is a path component (optional in other cases).
+# Examples: "http://media.lawrence.com", "http://example.com/media/"
+MEDIA_URL = ''
+
+# URL prefix for admin media -- CSS, JavaScript and images. Make sure to use a
+# trailing slash.
+# Examples: "http://foo.com/media/", "/media/".
+ADMIN_MEDIA_PREFIX = '/admin/media/'
+
+# Make this unique, and don't share it with anybody.
+SECRET_KEY = 'gfjo;2r3l;hjropjf30j3fl;m234nc9p;o2mnpfnpfj'
+
+# List of callables that know how to import templates from various sources.
+TEMPLATE_LOADERS = (
+ 'django.template.loaders.filesystem.Loader',
+ 'django.template.loaders.app_directories.Loader',
+# 'django.template.loaders.eggs.Loader',
+)
+
+TEMPLATE_CONTEXT_PROCESSORS = (
+ "django.contrib.auth.context_processors.auth",
+ "django.core.context_processors.debug",
+ "django.core.context_processors.i18n",
+ "django.core.context_processors.media",
+ "django.core.context_processors.static",
+ "django.core.context_processors.request",
+ "django.contrib.messages.context_processors.messages",
+)
+
+MIDDLEWARE_CLASSES = (
+ 'django.middleware.common.CommonMiddleware',
+ 'django.contrib.sessions.middleware.SessionMiddleware',
+ 'django.middleware.csrf.CsrfViewMiddleware',
+ 'django.contrib.auth.middleware.AuthenticationMiddleware',
+ 'django.contrib.messages.middleware.MessageMiddleware',
+ 'experiments.middleware.ExperimentsMiddleware',
+)
+
+ROOT_URLCONF = 'example_project.urls'
+
+TEMPLATE_DIRS = (
+ # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates".
+ # Always use forward slashes, even on Windows.
+ # Don't forget to use absolute paths, not relative paths.
+ os.path.join(PROJECT_ROOT, 'templates'),
+)
+
+INSTALLED_APPS = (
+ 'django.contrib.auth',
+ 'django.contrib.contenttypes',
+ 'django.contrib.sessions',
+ 'django.contrib.humanize',
+ 'nexus',
+ 'experiments',
+ 'gargoyle',
+ # Uncomment the next line to enable the admin:
+ 'django.contrib.admin',
+)
1  example_project/templates/404.html
@@ -0,0 +1 @@
+404 error
1  example_project/templates/500.html
@@ -0,0 +1 @@
+500 error
11 example_project/templates/goal.html
@@ -0,0 +1,11 @@
+{% load experiments %}
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Goal Page</title>
+</head>
+<body>
+ {% experiment_goal "page_goal" %}
+ <a href="{% url test_page %}">Back</a>
+</body>
+</html>
24 example_project/templates/test_page.html
@@ -0,0 +1,24 @@
+{% load experiments %}
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Experiment Test Page</title>
+ <script src="//ajax.googleapis.com/ajax/libs/jquery/1.5.1/jquery.min.js" type="text/javascript"></script>
+ <script src="{% url nexus:media 'experiments' 'js/jquery.cookie.js' %}"></script>
+ <script src="{% url nexus:media 'experiments' 'js/experiments.js' %}"></script>
+</head>
+<body>
+ {% experiment helloworld control %}
+ <a href="{% url goal %}">Click Me (Control)</a>
+ {% endexperiment %}
+
+ {% experiment helloworld test %}
+ <a href="{% url goal %}">Don't Click Me (test)</a>
+ {% endexperiment %}
+
+ <span onclick="experiments.goal('js_goal')">JS GOAL</span>
+ <span data-experiments-goal="cookie_goal">COOKIE GOAL</span>
+
+ {% include "experiments/confirm_human.html" %}
+</body>
+</html>
16 example_project/urls.py
@@ -0,0 +1,16 @@
+from django.contrib import admin
+from django.conf.urls.defaults import patterns, include, url
+from django.views.generic import TemplateView
+
+import nexus
+
+admin.autodiscover()
+nexus.autodiscover()
+
+urlpatterns = patterns('',
+ url(r'nexus/', include(nexus.site.urls)),
+ url(r'experiments/', include('experiments.urls')),
+ url(r'^$', TemplateView.as_view(template_name="test_page.html"), name="test_page"),
+ url(r'^goal/$', TemplateView.as_view(template_name="goal.html"), name="goal"),
+)
+
6 experiments/__init__.py
@@ -0,0 +1,6 @@
+from django.db.models.signals import post_delete
+from experiments.models import Experiment
+
+from experiments.signals import redis_counter_tidy
+
+post_delete.connect(redis_counter_tidy, sender=Experiment, dispatch_uid="redis_counter_reset")
25 experiments/admin.py
@@ -0,0 +1,25 @@
+from django.contrib import admin
+
+from django import forms
+from experiments.models import Experiment, Enrollment
+
+class ExperimentAdmin(admin.ModelAdmin):
+ list_display = ('name', 'start_date', 'end_date', 'state')
+ list_filter = ('name', 'start_date', 'state')
+ search_fields = ('name', )
+
+class EnrollmentAdmin(admin.ModelAdmin):
+ list_display = ('user', 'experiment', 'alternative')
+ readonly_fields = ('user', 'experiment',)
+ list_filter = ('experiment',)
+ search_fields = ('user',)
+ raw_id_fields = ('user',)
+
+ def get_form(self, request, obj=None, **kwargs):
+ form = super(EnrollmentAdmin, self).get_form(request, obj, **kwargs)
+ alternatives = obj.experiment.alternatives.keys()
+ form.base_fields['alternative'].widget = forms.Select(choices=zip(alternatives, alternatives))
+ return form
+
+admin.site.register(Experiment, ExperimentAdmin)
+admin.site.register(Enrollment, EnrollmentAdmin)
51 experiments/counters.py
@@ -0,0 +1,51 @@
+from django.conf import settings
+
+import redis
+from redis.exceptions import ConnectionError, ResponseError
+
+REDIS_HOST = getattr(settings, 'EXPERIMENTS_REDIS_HOST', 'localhost')
+REDIS_PORT = getattr(settings, 'EXPERIMENTS_REDIS_PORT', 6379)
+REDIS_EXPERIMENTS_DB = getattr(settings, 'EXPERIMENTS_REDIS_DB', 0)
+
+r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, db=REDIS_EXPERIMENTS_DB)
+
+COUNTER_CACHE_KEY = 'experiments:%s'
+
+def counter_increment(key, increment=1):
+ try:
+ cache_key = COUNTER_CACHE_KEY % key
+ r.incr(cache_key, increment)
+ return int(r.get(cache_key))
+ except (ConnectionError, ResponseError):
+ # Handle Redis failures gracefully
+ pass
+
+def counter_get(key):
+ try:
+ cache_key = COUNTER_CACHE_KEY % key
+ count = r.get(cache_key)
+ except (ConnectionError, ResponseError):
+ # Handle Redis failures gracefully
+ return 0
+ if not count:
+ return 0
+ return int(count)
+
+def counter_reset(key):
+ try:
+ cache_key = COUNTER_CACHE_KEY % key
+ return r.delete(cache_key)
+ except (ConnectionError, ResponseError):
+ # Handle Redis failures gracefully
+ return False
+
+def counter_reset_pattern(key):
+ #similar to above, but can pass pattern as arg instead
+ try:
+ cache_key = COUNTER_CACHE_KEY % key
+ for key in r.keys(cache_key):
+ r.delete(key)
+ return True
+ except (ConnectionError, ResponseError):
+ # Handle Redis failures gracefully
+ return False
5 experiments/manager.py
@@ -0,0 +1,5 @@
+from django.conf import settings
+
+from models import ExperimentManager, Experiment
+
+experiment_manager = ExperimentManager(Experiment, key='name', value='value', instances=True, auto_create=getattr(settings, 'EXPERIMENTS_AUTO_CREATE', True))
394 experiments/media/css/experiments.css
@@ -0,0 +1,394 @@
+/*dashboard.html*/
+.experiment-list {
+ width: 100%;
+}
+
+/*index.html*/
+#container div.wrapper > h1 {
+ display: none;
+}
+
+#container div.noExperiments {
+ margin: 20px 0 0 0;
+ text-align: center;
+}
+
+#container div.toolbar {
+ border-bottom: solid 1px #e5e5e5;
+ padding-bottom: 20px;
+}
+
+#container div.toolbar span.search {
+ padding-top: 3px;
+ float: right;
+}
+
+#container div.toolbar ul.sort {
+ float: right;
+ list-style: none;
+ padding: 10px 20px 10px 0;
+ margin: 0 20px 0 0;
+ border-right: 1px solid #EAEAEA;
+}
+
+#container div.toolbar ul.sort li {
+ display: inline-block;
+}
+
+#container div.toolbar ul.sort li a {
+ color: #999;
+ display: block;
+}
+
+#container div.toolbar ul.sort li + li {
+ margin-left: 10px;
+}
+
+#container div.toolbar[data-sort=-name] ul.sort li.name a,
+#container div.toolbar[data-sort=-start_date] ul.sort li.start_date a,
+#container div.toolbar[data-sort=-end_date] ul.sort li.end_date a,
+#container div.toolbar[data-sort=name] ul.sort li.name a,
+#container div.toolbar[data-sort=start_date] ul.sort li.start_date a,
+#container div.toolbar[data-sort=end_date] ul.sort li.end_date a{
+
+ color: #000;
+ font-weight: bold;
+}
+
+#container div.toolbar[data-sort=-name] ul.sort li.name a:after,
+#container div.toolbar[data-sort=-start_date] ul.sort li.start_date a:after,
+#container div.toolbar[data-sort=-end_date] ul.sort li.end_date a:after{
+ content: "";
+}
+
+#container div.toolbar[data-sort=name] ul.sort li.name a:after,
+#container div.toolbar[data-sort=start_date] ul.sort li.start_date a:after,
+#container div.toolbar[data-sort=end_date] ul.sort li.end_date a:after{
+ content: "";
+}
+
+
+#container div.toolbar span.search input {
+ width: 200px;
+ padding: 6px;
+ font-size: 13px;
+}
+
+#container table.experiments {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+#container table.experiments tr:hover {
+ background: #f9f9f9;
+ cursor: pointer;
+}
+
+#container table.experiments tr.hidden {
+ display: none;
+}
+
+#container table.experiments tr.collapsed .inner {
+ display: none;
+}
+
+#container table.experiments td {
+ vertical-align: top;
+ border-bottom: solid 1px #efefef;
+ padding: 20px 10px;
+}
+
+#container table.experiments td.name h4 {
+ font-size: 100%;
+ font-weight: bold;
+ margin: 0;
+}
+
+#container div.results.header a,
+#container table.experiments td.name a {
+ font-weight:normal;
+ font-size:.8em;
+ color:blue;
+ text-decoration:none;
+}
+
+#container div.results.header a,
+#container table.experiments td.name p a {
+ font-size:1em;
+}
+
+#container table.experiments td.name a:hover {
+ text-decoration:underline;
+}
+
+#container table.experiments td.name h4 small {
+ color: #999;
+ font-weight: normal;
+ margin-left: 8px;
+ font-family: "Inconsolata", arial;
+}
+
+#container table.experiments td.name h4 small {
+ color: #999;
+ font-weight: normal;
+ margin-left: 8px;
+ font-family: "Inconsolata", arial;
+}
+
+#container table.experiments td.name h5 {
+ color: #777;
+ font-size: 50%;
+ font-weight: normal;
+ margin-bottom: 0;
+}
+
+#container table.experiments td.name p {
+ color: #666;
+ margin: 10px 0 0 0;
+}
+
+#container table.experiments td.state {
+ width: 250px;
+ text-align: center;
+}
+
+#container table.experiments td.state p {
+ color: #999;
+ margin-top: 15px;
+ font-size: 85%;
+ text-transform: lowercase;
+ margin-bottom: 0;
+}
+
+#container table.experiments td.actions {
+ text-align: right;
+ width: 70px;
+}
+
+#container table.experiments td.state button[disabled],
+#container div.results button[disabled] {
+ color:#AAA;
+ border-color:#AAA;
+}
+
+
+#container table.experiments td.actions button.edit,
+#container table.experiments td.actions button.edit span {
+ height: 18px;
+ width: 18px;
+}
+
+#container table.experiments td.actions button.edit span {
+ display: block;
+ background: transparent url("../img/edit.png") no-repeat left 35%;
+}
+
+#container table.experiments td.actions button.delete,
+#container table.experiments td.actions button.delete span {
+ height: 18px;
+ width: 18px;
+}
+
+#container table.experiments td.actions button.delete span {
+ display: block;
+ background: transparent url("../img/delete.png") no-repeat left 35%;
+}
+
+#container table.empty {
+ display: none;
+}
+
+/*
+Facebox css
+*/
+
+#facebox h2 {
+ font-size: 100%;
+ font-weight: bold;
+ text-align: center;
+ margin-bottom: 20px;
+}
+
+#facebox table.experimentForm {
+ width: 100%;
+ border-collapse: collapse;
+ margin: 0;
+}
+
+#facebox table.experimentForm th, #facebox table.experimentForm td {
+ vertical-align: top;
+}
+
+#facebox table.experimentForm th {
+ width: 100px;
+ font-weight: normal;
+ text-align: right;
+ padding-right: 10px;
+}
+
+#facebox table.experimentForm td input,
+#facebox table.experimentForm td textarea {
+ width: 230px;
+ padding: 2px 3px;
+}
+
+#facebox table.experimentForm td input[disabled],
+#facebox table.experimentForm td textarea[disabled] {
+ background-color:#eeeeee;
+}
+
+#facebox table.experimentForm td p {
+ margin-top: 5px;
+ font-size: 80%;
+ color: #666;
+}
+
+#facebox table.experimentForm td textarea {
+ height: 30px;
+}
+
+#facebox table.experimentForm td.buttons {
+ text-align: right;
+}
+
+/*
+Results page header
+*/
+#container div.results.header{
+ padding-top:10px;
+ padding-bottom:10px;
+ border-bottom:1px solid #DDD;
+}
+
+#container div.results.header.top{
+ min-height:30px;
+}
+
+#container h2.experiment{
+ color:#2457A9;
+ float:left;
+}
+
+#container div.results.state{
+ float:right;
+}
+
+#container div.results.header.bottom{
+ color:#666;
+}
+
+/*Results table*/
+#container table.goals{
+ border-spacing:0px;
+}
+
+#container table.goals td{
+ padding:8px;
+ width:100px;
+ border-bottom:1px solid #DDD;
+}
+
+
+#container table.goals td.goal{
+ min-width:200px;
+ max-width:200px;
+ overflow:hidden;
+}
+
+#container table.goals tbody td{
+ color:#344259;
+}
+
+#container table.goals thead{
+ font-weight:bold;
+ color:#2457A9;
+}
+
+#container table.goals thead td{
+ border-left:solid 1px #EEE;
+}
+
+#container table.goals thead td.goal{
+ border-left:none;
+}
+
+#container table.goals tbody td.conversion2{
+ border-left:solid 1px #DDD;
+}
+
+
+#container table.goals tbody td.even{
+ background:#EEE;
+}
+
+#container table.goals tbody td.even.shine{
+ background:#DDD;
+}
+
+#container table.goals td.filler{
+ padding:0px;
+ width:0px;
+ border:none;
+}
+
+#container table.goals tbody tr{
+ border-top:1px solid #000;
+}
+s
+#container table.goals tbody tr:hover,
+#container table.goals tbody tr:focus{
+ background:#E4E9F1;
+}
+
+#container table.goals tbody tr:hover td,
+#container table.goals tbody tr:focus td{
+ background:#C6D0E0 !important;
+}
+
+#container table.goals tbody tr.hiddengoal{
+ display:none;
+}
+
+#container #ToggleGoals{
+ padding-left:5px;
+ font-weight:normal;
+ font-size:.8em;
+ color:blue;
+ text-decoration:none;
+}
+
+#container #ToggleGoals {
+ text-decoration:underline;
+}
+
+/*Statistical CSS*/
+.positive_improvement {
+ font-weight: bold;
+ color: green;
+}
+.negative_improvement {
+ font-weight: bold;
+ color: red;
+}
+
+.low_confidence {
+ color: #333;
+}
+.medium_confidence {
+ color: #ff9900;
+ font-weight: bold;
+}
+.high_confidence {
+ color: green;
+ font-weight: bold;
+}
+
+.low_confidence:after {
+ content: '';
+}
+.medium_confidence:after {
+ content: '';
+}
+.high_confidence:after {
+ content: '';
+}
BIN  experiments/media/img/button-bg.jpg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
BIN  experiments/media/img/delete.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
BIN  experiments/media/img/edit.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
19 experiments/media/js/experiments.js
@@ -0,0 +1,19 @@
+experiments = function() {
+ return {
+ confirm_human: function() {
+ $.get("/experiments/confirm_human/");
+ },
+ goal: function(goal_name) {
+ $.get("/experiments/goal/" + goal_name);
+ }
+ };
+}();
+
+$(function(){
+ $('[data-experiments-goal]').each(function() {
+ $(this).bind('click', function() {
+ $.cookie("experiments_goal", $(this).data('experiments-goal'), { path: '/' });
+ });
+ });
+});
+
61 experiments/media/js/jquery.cookie.js
@@ -0,0 +1,61 @@
+/*jshint eqnull:true */
+/*!
+ * jQuery Cookie Plugin v1.1
+ * https://github.com/carhartl/jquery-cookie
+ *
+ * Copyright 2011, Klaus Hartl
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://www.opensource.org/licenses/mit-license.php
+ * http://www.opensource.org/licenses/GPL-2.0
+ */
+(function($, document) {
+
+ var pluses = /\+/g;
+ function raw(s) {
+ return s;
+ }
+ function decoded(s) {
+ return decodeURIComponent(s.replace(pluses, ' '));
+ }
+
+ $.cookie = function(key, value, options) {
+
+ // key and at least value given, set cookie...
+ if (arguments.length > 1 && (!/Object/.test(Object.prototype.toString.call(value)) || value == null)) {
+ options = $.extend({}, $.cookie.defaults, options);
+
+ if (value == null) {
+ options.expires = -1;
+ }
+
+ if (typeof options.expires === 'number') {
+ var days = options.expires, t = options.expires = new Date();
+ t.setDate(t.getDate() + days);
+ }
+
+ value = String(value);
+
+ return (document.cookie = [
+ encodeURIComponent(key), '=', options.raw ? value : encodeURIComponent(value),
+ options.expires ? '; expires=' + options.expires.toUTCString() : '', // use expires attribute, max-age is not supported by IE
+ options.path ? '; path=' + options.path : '',
+ options.domain ? '; domain=' + options.domain : '',
+ options.secure ? '; secure' : ''
+ ].join(''));
+ }
+
+ // key and possibly options given, get cookie...
+ options = value || $.cookie.defaults || {};
+ var decode = options.raw ? raw : decoded;
+ var cookies = document.cookie.split('; ');
+ for (var i = 0, parts; (parts = cookies[i] && cookies[i].split('=')); i++) {
+ if (decode(parts.shift()) === key) {
+ return decode(parts.join('='));
+ }
+ }
+ return null;
+ };
+
+ $.cookie.defaults = {};
+
+})(jQuery, document);
188 experiments/media/js/nexus_experiments.js
@@ -0,0 +1,188 @@
+$(document).ready(function () {
+
+ var api = function (url, params, succ) {
+ $('#status').show();
+ $.ajax({
+ url: url,
+ type: "POST",
+ data: params,
+ dataType: "json",
+ success: function (resp) {
+ $('#status').hide();
+
+ if (resp.success) {
+ succ(resp.data);
+ } else {
+ alert(resp.data);
+ }
+ },
+ failure: function() {
+ $('#status').hide();
+ alert('There was an internal error. Data probably wasn\'t saved');
+ }
+ });
+ };
+
+ // Events
+ $(".experiments tr").live("click", function (ev) {
+ if (ev.target.tagName == 'A' || ev.target.tagName == 'INPUT' || ev.target.tagName == 'LABEL') {
+ return;
+ }
+
+ activated = $(this).get(0)
+
+ $(".experiments tr").each(function (_, el) {
+ if (el == activated) {
+ $(el).removeClass("collapsed");
+ } else {
+ $(el).addClass("collapsed");
+ }
+ });
+ });
+
+
+ $(".experiments .delete").live("click", function () {
+ var row = $(this).parents("tr:first");
+ var table = row.parents("table:first");
+
+ api(EXPERIMENT.deleteExperiment, { name: row.attr("data-experiment-name") },
+ function () {
+ row.remove();
+ if (!table.find("tr").length) {
+ $("div.noExperiments").show();
+ }
+ }
+ );
+ });
+
+
+ //Change state of experiment
+ $("#container div.state button").live("click", function () {
+ var el = $(this)
+ var row = $(this).parent()
+ var state = el.attr("data-state");
+
+ api(EXPERIMENT.updateState,
+ {
+ name: row.attr("data-experiment-name"),
+ state: state
+ },
+ function (experiment) {
+ experiment = JSON.parse(experiment);
+ if (experiment.state == state) {
+ row.find(".toggled").removeClass("toggled");
+ el.addClass("toggled");
+
+ //Hide or show end_date if disabled toggled
+ end_date = row.parent().parent().find("#" + experiment.name + "_end_date")
+
+ if (experiment.state == 0) {
+ end_date.html("To: " + experiment.end_date);
+ end_date.show();
+ } else {
+ end_date.hide();
+ }
+
+ //Hide or show conditions if selective toggled
+ conditions = row.parent().parent().find(".conditions")
+
+ if (experiment.state == 1) {
+ conditions.show();
+ } else {
+ conditions.hide();
+ }
+ }
+ });
+ });
+
+ $(".addExperiment").click(function (ev) {
+ ev.preventDefault();
+ $.facebox($("#experimentForm").tmpl({ add: true }));
+ });
+
+ $(".experiments .edit").live("click", function () {
+ var row = $(this).parents("tr:first");
+ $.facebox($("#experimentForm").tmpl({
+ add: false,
+ curname: row.attr("data-experiment-name"),
+ name: row.attr("data-experiment-name"),
+ switch_key: row.attr("data-experiment-switch"),
+ desc: row.attr("data-experiment-desc"),
+ relevant_goals: row.attr("data-experiment-goals"),
+ }))
+ });
+
+ $("#facebox .closeFacebox").live("click", function (ev) {
+ ev.preventDefault();
+ $.facebox.close();
+ });
+
+ $("#facebox .submitExperiment").live("click", function () {
+ var action = $(this).attr("data-action");
+ var curname = $(this).attr("data-curname");
+
+ api(action == "add" ? EXPERIMENT.addExperiment : EXPERIMENT.updateExperiment,
+ {
+ curname: curname,
+ name: $("#facebox input[name=name]").val(),
+ switch_key: $("#facebox input[name=switch_key]").val(),
+ desc: $("#facebox textarea[name=desc]").val(),
+ goals: $("#facebox textarea[name=relevant_goals]").val()
+ },
+
+ function (experiment) {
+ experiment = JSON.parse(experiment);
+ var result = $("#experimentData").tmpl(experiment);
+
+ if (action == "add") {
+ if ($("table.experiments tr").length == 0) {
+ $("table.experiments").html(result);
+ $("table.experiments").removeClass("empty");
+ $("div.noExperiments").hide();
+ } else {
+ $("table.experiments tr:last").after(result);
+ }
+
+ $.facebox.close();
+ } else {
+ $("table.experiments tr[data-experiment-name=" + curname + "]").replaceWith(result);
+ $.facebox.close();
+ }
+ result.click()
+
+ });
+ });
+
+ $('.search input').keyup(function () {
+ var query = $(this).val();
+
+ $('.experiments tr').removeClass('hidden');
+
+ if (!query) {
+ return;
+ }
+ $('.experiments tr').each(function (_, el) {
+ var score = 0;
+
+ score += $(el).attr('data-experiment-name').score(query);
+ score += $(el).attr('data-experiment-desc').score(query);
+
+ if (score === 0) {
+ $(el).addClass('hidden');
+ }
+ });
+
+ });
+
+ $('#ToggleGoals').live("click", function () {
+
+ obj = $('#container table.goals tbody tr.hiddengoal')
+
+ if (obj.css('display') == 'none') {
+ obj.show();
+ } else {
+ obj.hide()
+ }
+ });
+
+});
13 experiments/media/js/string_score.min.js
@@ -0,0 +1,13 @@
+/*!
+ * string_score.js: String Scoring Algorithm 0.1.10
+ *
+ * http://joshaven.com/string_score
+ * https://github.com/joshaven/string_score
+ *
+ * Copyright (C) 2009-2011 Joshaven Potter <yourtech@gmail.com>
+ * Special thanks to all of the contributors listed here https://github.com/joshaven/string_score
+ * MIT license: http://www.opensource.org/licenses/mit-license.php
+ *
+ * Date: Tue Mar 1 2011
+*/
+String.prototype.score=function(m,s){if(this==m){return 1}if(m==""){return 0}var f=0,q=m.length,g=this,p=g.length,o,k,e=1,j;for(var d=0,r,n,h,a,b,l;d<q;++d){h=m.charAt(d);a=g.indexOf(h.toLowerCase());b=g.indexOf(h.toUpperCase());l=Math.min(a,b);n=(l>-1)?l:Math.max(a,b);if(n===-1){if(s){e+=1-s;continue}else{return 0}}else{r=0.1}if(g[n]===h){r+=0.1}if(n===0){r+=0.6;if(d===0){o=1}}else{if(g.charAt(n-1)===" "){r+=0.8}}g=g.substring(n+1,p);f+=r}k=f/q;j=((k*(q/p))+k)/2;j=j/e;if(o&&(j+0.15<1)){j+=0.15}return j};
9 experiments/middleware.py
@@ -0,0 +1,9 @@
+from experiments.utils import record_goal
+
+class ExperimentsMiddleware(object):
+ def process_response(self, request, response):
+ experiments_goal = request.COOKIES.get('experiments_goal', None)
+ if experiments_goal:
+ record_goal(request, experiments_goal)
+ response.delete_cookie('experiments_goal')
+ return response
155 experiments/models.py
@@ -0,0 +1,155 @@
+from django.contrib.auth.models import User
+from django.core.urlresolvers import reverse
+from django.db import models
+from django.utils import simplejson
+from django.core.serializers.json import DjangoJSONEncoder
+from django.conf import settings
+
+from jsonfield import JSONField
+from modeldict import ModelDict
+
+from gargoyle.manager import gargoyle
+from gargoyle.models import Switch
+
+import datetime
+
+import random
+
+CONTROL_GROUP = 'control'
+
+CONTROL_STATE = 0
+ENABLED_STATE = 1
+GARGOYLE_STATE = 2
+
+STATES = (
+ (CONTROL_STATE, 'Control'),
+ (ENABLED_STATE, 'Enabled'),
+ (GARGOYLE_STATE, 'Gargoyle'),
+)
+
+class Experiment(models.Model):
+ name = models.CharField(primary_key=True, max_length=128)
+ description = models.TextField(default="", blank=True, null=True)
+ alternatives = JSONField(default="{}", blank=True)
+ relevant_goals = models.TextField(default = "", null=True, blank=True)
+ switch_key = models.CharField(default = "", max_length=50, null=True, blank=True)
+
+ state = models.IntegerField(default=CONTROL_STATE, choices=STATES)
+
+ start_date = models.DateTimeField(default=datetime.datetime.now, blank=True, null=True, db_index=True)
+ end_date = models.DateTimeField(blank=True, null=True)
+
+ def __unicode__(self):
+ return self.name
+
+ @classmethod
+ def show_alternative(self, experiment_name, experiment_user, alternative, experiment_manager):
+ """ does the real work """
+ try:
+ experiment = experiment_manager[experiment_name] # use cache where possible
+ except KeyError:
+ return alternative == CONTROL_GROUP
+
+ if experiment.state == CONTROL_STATE:
+ return alternative == CONTROL_GROUP
+
+ if experiment.state == GARGOYLE_STATE:
+ if not gargoyle.is_active(experiment.switch_key, experiment_user.request):
+ return alternative == CONTROL_GROUP
+
+ if experiment.state != ENABLED_STATE and experiment.state != GARGOYLE_STATE:
+ raise Exception("Invalid experiment state %s!" % experiment.state)
+
+ # Add new alternatives to experiment model
+ if alternative not in experiment.alternatives:
+ experiment.alternatives[alternative] = {}
+ experiment.alternatives[alternative]['enabled'] = True
+ experiment.save()
+
+ # Lookup User alternative
+ assigned_alternative = experiment_user.get_enrollment(experiment)
+
+ # No alternative so assign one
+ if assigned_alternative is None:
+ assigned_alternative = random.choice(experiment.alternatives.keys())
+ experiment_user.set_enrollment(experiment, assigned_alternative)
+
+ return alternative == assigned_alternative
+
+ def to_dict(self):
+ data = {
+ 'name': self.name,
+ 'edit_url': reverse('experiments:results', kwargs={'name': self.name}),
+ 'start_date': self.start_date,
+ 'end_date': self.end_date,
+ 'state': self.state,
+ 'switch_key': self.switch_key,
+ 'description': self.description,
+ 'relevant_goals': self.relevant_goals,
+ }
+ return data
+
+ def to_dict_serialized(self):
+ return simplejson.dumps(self.to_dict(), cls=DjangoJSONEncoder)
+
+ def save(self, *args, **kwargs):
+ # Delete existing switch
+ if getattr(settings, 'EXPERIMENTS_SWITCH_AUTO_DELETE', True):
+ try:
+ Switch.objects.get(key=Experiment.objects.get(name=self.name).switch_key).delete()
+ except (Switch.DoesNotExist, Experiment.DoesNotExist):
+ pass
+
+ # Create new switch
+ if self.switch_key and getattr(settings, 'EXPERIMENTS_SWITCH_AUTO_CREATE', True):
+ try:
+ Switch.objects.get(key=self.switch_key)
+ except Switch.DoesNotExist:
+ Switch.objects.create(key=self.switch_key, label=getattr(settings, 'EXPERIMENTS_SWITCH_LABEL', "Experiment: %s") % self.name, description=self.description)
+
+ if not self.switch_key and self.state == 2:
+ self.state = 0
+
+ super(Experiment, self).save(*args, **kwargs)
+
+ def delete(self, *args, **kwargs):
+ # Delete existing switch
+ if getattr(settings, 'EXPERIMENTS_SWITCH_AUTO_DELETE', True):
+ try:
+ Switch.objects.get(key=Experiment.objects.get(name=self.name).switch_key).delete()
+ except Switch.DoesNotExist:
+ pass
+
+ super(Experiment, self).delete(*args, **kwargs)
+
+
+
+class ExperimentManager(ModelDict):
+ def __init__(self, *args, **kwargs):
+ self._registry = {}
+ super(ExperimentManager, self).__init__(*args, **kwargs)
+
+ def __getitem__(self, key):
+ experiment = super(ExperimentManager, self).__getitem__(key)
+ return experiment
+
+class Enrollment(models.Model):
+ """ A participant in a split testing experiment """
+ user = models.ForeignKey(User, null=True)
+ experiment = models.ForeignKey(Experiment)
+ enrollment_date = models.DateField(db_index=True, auto_now_add=True)
+ alternative = models.CharField(max_length=50)
+ goals = JSONField(default="[]", blank=True)
+
+ class Meta:
+ unique_together = ('user', 'experiment')
+
+ def to_dict(self):
+ data = {
+ 'user': self.user,
+ 'experiment': self.experiment,
+ 'enrollment_date': self.enrollment_date,
+ 'alternative': self.alternative,
+ 'goals': self.goals,
+ }
+ return data
239 experiments/nexus_modules.py
@@ -0,0 +1,239 @@
+from django.conf.urls.defaults import patterns, url
+
+from functools import wraps
+
+from django.conf import settings
+from django.http import HttpResponse
+from django.utils import simplejson
+
+from experiments.models import Experiment, ENABLED_STATE, GARGOYLE_STATE, CONTROL_GROUP
+from experiments.manager import experiment_manager
+from experiments.utils import PARTICIPANT_KEY, GOAL_KEY
+from experiments.counters import counter_get
+from experiments.significance import chi_square_p_value
+from experiments import signals
+
+import nexus
+
+def rate(a, b):
+ if not b or a == None:
+ return None
+ return 100. * a / b
+
+def improvement(a, b):
+ if not b or not a:
+ return None
+ return (a - b) * 100. / b
+
+def confidence(a_count, a_conversion, b_count, b_conversion):
+ contingency_table = [[a_count - a_conversion, a_conversion],
+ [b_count - b_conversion, b_conversion]]
+
+ chi_square, p_value = chi_square_p_value(contingency_table)
+ if p_value:
+ return (1 - p_value) * 100
+ else:
+ return None
+
+class ExperimentException(Exception):
+ pass
+
+def json(func):
+ "Decorator to make JSON views simpler"
+ def wrapper(self, request, *args, **kwargs):
+ try:
+ response = {
+ "success": True,
+ "data": func(self, request, *args, **kwargs)
+ }
+ except ExperimentException, exc:
+ response = {
+ "success": False,
+ "data": exc.message
+ }
+ except Experiment.DoesNotExist:
+ response = {
+ "success": False,
+ "data": "Experiment cannot be found"
+ }
+ except ValidationError, e:
+ response = {
+ "success": False,
+ "data": u','.join(map(unicode, e.messages)),
+ }
+ except Exception:
+ if settings.DEBUG:
+ import traceback
+ traceback.print_exc()
+ raise
+ return HttpResponse(simplejson.dumps(response), mimetype="application/json")
+ wrapper = wraps(func)(wrapper)
+ return wrapper
+
+class ExperimentsModule(nexus.NexusModule):
+ home_url = 'index'
+ name = 'experiments'
+
+ def get_title(self):
+ return 'Experiments'
+
+ def get_urls(self):
+ urlpatterns = patterns('',
+ url(r'^$', self.as_view(self.index), name='index'),
+ url(r'^add/$', self.as_view(self.add), name='add'),
+ url(r'^update/$', self.as_view(self.update), name='update'),
+ url(r'^delete/$', self.as_view(self.delete), name='delete'),
+ url(r'^state/$', self.as_view(self.state), name='state'),
+ url(r'^results/(?P<name>[a-zA-Z0-9-_]+)/$', self.as_view(self.results), name='results'),
+ )
+ return urlpatterns
+
+ def render_on_dashboard(self, request):
+ enabled_experiments_count = Experiment.objects.filter(state__in=[ENABLED_STATE, GARGOYLE_STATE]).count()
+ enabled_experiments = list(Experiment.objects.filter(state__in=[ENABLED_STATE, GARGOYLE_STATE]).order_by("start_date")[:5])
+ return self.render_to_string('nexus/experiments/dashboard.html', {
+ 'enabled_experiments': enabled_experiments,
+ 'enabled_experiments_count': enabled_experiments_count,
+ }, request)
+
+ def index(self, request):
+ sort_by = request.GET.get('by', '-start_date')
+ experiments = Experiment.objects.all().order_by(sort_by)
+
+ return self.render_to_response("nexus/experiments/index.html", {
+ "experiments": [e.to_dict() for e in experiments],
+ "sorted_by": sort_by
+ }, request)
+
+ def results(self, request, name):
+ experiment = Experiment.objects.get(name=name)
+
+ try:
+ relevant_goals = experiment.relevant_goals.replace(" ", "").split(",")
+ except AttributeError:
+ relevant_goals = [u'']
+
+ alternatives = {}
+ for alternative_name in experiment.alternatives.keys():
+ alternatives[alternative_name] = counter_get(PARTICIPANT_KEY % (name, alternative_name))
+
+ control_participants = counter_get(PARTICIPANT_KEY % (name, CONTROL_GROUP))
+
+ results = {}
+
+ for goal in getattr(settings, 'EXPERIMENTS_GOALS', []):
+ alternatives_conversions = {}
+ control_conversions = counter_get(GOAL_KEY % (name, CONTROL_GROUP, goal ))
+ control_conversion_rate = rate(control_conversions, control_participants)
+ for alternative_name in experiment.alternatives.keys():
+ if not alternative_name == CONTROL_GROUP:
+ alternative_conversions = counter_get(GOAL_KEY % (name, alternative_name, goal))
+ alternative_participants = counter_get(PARTICIPANT_KEY % (name, alternative_name))
+ alternative_conversion_rate = rate(alternative_conversions, alternative_participants)
+ alternative_confidence = confidence(alternative_participants, alternative_conversions, control_participants, control_conversions)
+ alternative = {
+ 'conversions': alternative_conversions,
+ 'conversion_rate': alternative_conversion_rate,
+ 'improvement': improvement(alternative_conversion_rate, control_conversion_rate),
+ 'confidence': alternative_confidence,
+ }
+ alternatives_conversions[alternative_name] = alternative
+
+ control = {'conversions':control_conversions, 'conversion_rate':control_conversion_rate}
+
+ results[goal] = {"control": control, "alternatives": alternatives_conversions, 'relevant': goal in relevant_goals or relevant_goals == [u'']}
+
+ return self.render_to_response("nexus/experiments/results.html", {
+ 'experiment': experiment.to_dict(),
+ 'alternatives': alternatives,
+ 'control_participants': control_participants,
+ 'results': results,
+ }, request)
+
+ def state(self, request):
+ experiment = Experiment.objects.get(name=request.POST.get("name"))
+ try:
+ state = int(request.POST.get("state"))
+ except ValueError:
+ raise ExperimentException("State must be integer")
+
+ experiment.state = state
+
+ if state == 0:
+ import datetime
+ experiment.end_date = datetime.datetime.now()
+ else:
+ experiment.end_date = None
+
+ experiment.save()
+
+ signals.experiment_state_updated.send(
+ sender=self,
+ request=request,
+ experiment=experiment,
+ state=state,
+ )
+
+ return experiment.to_dict_serialized()
+ state = json(state)
+
+ def add(self, request):
+ name = request.POST.get("name")
+
+ if not name:
+ raise ExperimentException("Name cannot be empty")
+
+ if len(name) > 128:
+ raise ExperimentException("Name must be less than or equal to 128 characters in length")
+
+ experiment, created = Experiment.objects.get_or_create(
+ name = name,
+ defaults = dict(
+ switch_key = request.POST.get("switch_key"),
+ description = request.POST.get("desc"),
+ relevant_goals = request.POST.get("goals"),
+ ),
+ )
+
+ if not created:
+ raise ExperimentException("Experiment with name %s already exists" % name)
+
+ signals.experiment_added.send(
+ sender=self,
+ request=request,
+ experiment=experiment,
+ )
+
+ return experiment.to_dict_serialized()
+ add = json(add)
+
+ def update(self, request):
+ experiment = Experiment.objects.get(name=request.POST.get("curname"))
+
+ experiment.switch_key = request.POST.get("switch_key")
+ experiment.description = request.POST.get("desc")
+ experiment.relevant_goals = request.POST.get("goals")
+ experiment.save()
+
+ signals.experiment_updated.send(
+ sender=self,
+ request=request,
+ experiment=experiment,
+ )
+
+ return experiment.to_dict_serialized()
+ update = json(update)
+
+ def delete(self, request):
+ experiment = Experiment.objects.get(name=request.POST.get("name"))
+ signals.experiment_deleted.send(
+ sender=self,
+ request=request,
+ experiment=experiment,
+ )
+ experiment.enrollment_set.all().delete()
+ experiment.delete()
+ return {}
+ delete = json(delete)
+
+nexus.site.register(ExperimentsModule, 'experiments')
63 experiments/signals.py
@@ -0,0 +1,63 @@
+import django.dispatch
+
+experiment_added = django.dispatch.Signal(providing_args=["request", "experiment"])
+
+experiment_deleted = django.dispatch.Signal(providing_args=["request", "experiment"])
+
+experiment_updated = django.dispatch.Signal(providing_args=["request", "experiment"])
+
+experiment_state_updated = django.dispatch.Signal(providing_args=["request", "experiment", "state"])
+
+experiment_user_added = django.dispatch.Signal(providing_args=["request", "experiment", "user", "alternative"])
+
+experiment_incr_participant = django.dispatch.Signal(providing_args=["request", "experiment", "alternative", "participants", "user"])
+
+goal_hit = django.dispatch.Signal(providing_args=["request", "experiment", "alternative", "goal", "hits", "user"])
+
+user_confirmed_human = django.dispatch.Signal(providing_args=["request", "user"])
+
+# Reset redis counters when deleting an experiment
+from counters import counter_reset_pattern
+def redis_counter_tidy(instance, **kwargs):
+ counter_reset_pattern(instance.name + "*")
+
+#: Other, standard django signal handlers of interest include:
+#: pre_save
+#: post_save
+#: pre_delete
+#: post_delete
+#: For usage and examples, see https://docs.djangoproject.com/en/dev/topics/signals/
+
+#: Test/Example Callbacks
+
+#: def experiment_added_callback(sender, request, **kwargs):
+#: print kwargs
+#: experiment_added.connect(experiment_added_callback)
+
+#: def experiment_deleted_callback(sender, request, **kwargs):
+#: print kwargs
+#: experiment_deleted.connect(experiment_deleted_callback)
+
+#: def experiment_updated_callback(sender, request, **kwargs):
+#: print kwargs
+#: experiment_updated.connect(experiment_updated_callback)
+
+#: def experiment_state_updated_callback(sender, request, **kwargs):
+#: print kwargs
+#: experiment_state_updated.connect(experiment_state_updated_callback)
+
+#: def experiment_user_added_callback(sender, request, **kwargs):
+#: print kwargs
+#: experiment_user_added.connect(experiment_user_added_callback)
+
+#: experiment_incr_participant_callback(sender, request, **kwargs):
+#: print kwargs
+#: experiment_incr_participant.connect(experiment_incr_participant_callback)
+
+#: def goal_hit_callback(sender, request, **kwargs):
+#: print kwargs
+#: goal_hit.connect(goal_hit_callback)
+
+#: def user_confirmed_human_callback(sender, request, **kwargs):
+#: print kwargs
+#: user_confirmed_human.connect(user_confirmed_human_callback)
76 experiments/significance.py
@@ -0,0 +1,76 @@
+def chi_square_p_value(matrix):
+ """
+ Accepts a matrix (an array of arrays, where each child array represents a row)
+
+ Example from http://math.hws.edu/javamath/ryan/ChiSquare.html:
+
+ Suppose you conducted a drug trial on a group of animals and you
+ hypothesized that the animals receiving the drug would survive better than
+ those that did not receive the drug. You conduct the study and collect the
+ following data:
+
+ Ho: The survival of the animals is independent of drug treatment.
+
+ Ha: The survival of the animals is associated with drug treatment.
+
+ In that case, your matrix should be:
+ [
+ [ Survivors in Test, Dead in Test ],
+ [ Survivors in Control, Dead in Control ]
+ ]
+
+ Code adapted from http://codecomments.wordpress.com/2008/02/13/computing-chi-squared-p-value-from-contingency-table-in-python/
+ """
+ try:
+ from scipy.stats import chisqprob
+ except ImportError:
+ from experiments.stats import chisqprob
+ num_rows = len(matrix)
+ num_columns = len(matrix[0])
+
+ # Sanity checking
+ if num_rows == 0:
+ return None
+ for row in matrix:
+ if len(row) != num_columns:
+ return None
+
+ row_sums = []
+ # for each row
+ for row in matrix:
+ # add up all the values in the row
+ row_sums.append(sum(row))
+
+ column_sums = []
+ # for each column i
+ for i in range(num_columns):
+ column_sum = 0.0
+ # get the i'th value from each row
+ for row in matrix:
+ column_sum += row[i]
+ column_sums.append(column_sum)
+
+ # the total sum could be calculated from either the rows or the columns
+ # coerce to float to make subsequent division generate float results
+ grand_total = float(sum(row_sums))
+
+ if grand_total <= 0:
+ return None, None
+
+ observed_test_statistic = 0.0
+ for i in range(num_rows):
+ for j in range(num_columns):
+ expected_value = (row_sums[i]/grand_total)*(column_sums[j]/grand_total)*grand_total
+ if expected_value <= 0:
+ return None, None
+ observed_value = matrix[i][j]
+ observed_test_statistic += ((observed_value - expected_value)**2) / expected_value
+ # See https://bitbucket.org/akoha/django-lean/issue/16/g_test-formula-is-incorrect
+ #observed_test_statistic += 2 * (observed_value*log(observed_value/expected_value))
+
+
+ degrees_freedom = (num_columns - 1) * (num_rows - 1)
+
+ p_value = chisqprob(observed_test_statistic, degrees_freedom)
+
+ return observed_test_statistic, p_value
275 experiments/stats.py
@@ -0,0 +1,275 @@
+from math import fabs, exp, sqrt, log, pi
+
+def flatten(iterable):
+ for el in iterable:
+ if isinstance(el, (list, tuple)):
+ yield flatten(el)
+ else:
+ yield el
+
+def mean(scores):
+ scores = list(flatten(scores))
+ try:
+ return float(sum(scores)) / float(len(scores))
+ except ZeroDivisionError:
+ return float('NaN')
+
+def isnan(value):
+ try:
+ from math import isnan
+ return isnan(value)
+ except ImportError:
+ return isinstance(value, float) and value != value
+
+def ss(inlist):
+ """
+ Squares each value in the passed list, adds up these squares and
+ returns the result.
+
+ Originally written by Gary Strangman.
+
+ Usage: lss(inlist)
+ """
+ ss = 0
+ for item in inlist:
+ ss = ss + item*item
+ return ss
+
+def var(inlist):
+ """
+ Returns the variance of the values in the passed list using N-1
+ for the denominator (i.e., for estimating population variance).
+
+ Originally written by Gary Strangman.
+
+ Usage: lvar(inlist)
+ """
+ n = len(inlist)
+ if n <= 1:
+ return 0.0
+ mn = mean(inlist)
+ deviations = [0]*len(inlist)
+ for i in range(len(inlist)):
+ deviations[i] = inlist[i] - mn
+ return ss(deviations)/float(n-1)
+
+def stdev(inlist):
+ """
+ Returns the standard deviation of the values in the passed list
+ using N-1 in the denominator (i.e., to estimate population stdev).
+
+ Originally written by Gary Strangman.
+
+ Usage: lstdev(inlist)
+ """
+ return sqrt(var(inlist))
+
+def gammln(xx):
+ """
+ Returns the gamma function of xx.
+ Gamma(z) = Integral(0,infinity) of t^(z-1)exp(-t) dt.
+ (Adapted from: Numerical Recipies in C.)
+
+ Originally written by Gary Strangman.
+
+ Usage: lgammln(xx)
+ """
+ coeff = [76.18009173, -86.50532033, 24.01409822, -1.231739516,
+ 0.120858003e-2, -0.536382e-5]
+ x = xx - 1.0
+ tmp = x + 5.5
+ tmp = tmp - (x+0.5)*log(tmp)
+ ser = 1.0
+ for j in range(len(coeff)):
+ x = x + 1
+ ser = ser + coeff[j]/x
+ return -tmp + log(2.50662827465*ser)
+
+def betacf(a,b,x):
+ """
+ This function evaluates the continued fraction form of the incomplete
+ Beta function, betai. (Adapted from: Numerical Recipies in C.)
+
+ Originally written by Gary Strangman.
+
+ Usage: lbetacf(a,b,x)
+ """
+ ITMAX = 200
+ EPS = 3.0e-7
+
+ bm = az = am = 1.0
+ qab = a+b
+ qap = a+1.0
+ qam = a-1.0
+ bz = 1.0-qab*x/qap
+ for i in range(ITMAX+1):
+ em = float(i+1)
+ tem = em + em
+ d = em*(b-em)*x/((qam+tem)*(a+tem))
+ ap = az + d*am
+ bp = bz+d*bm
+ d = -(a+em)*(qab+em)*x/((qap+tem)*(a+tem))
+ app = ap+d*az
+ bpp = bp+d*bz
+ aold = az
+ am = ap/bpp
+ bm = bp/bpp
+ az = app/bpp
+ bz = 1.0
+ if (abs(az-aold)<(EPS*abs(az))):
+ return az
+ print 'a or b too big, or ITMAX too small in Betacf.'
+
+def betai(a,b,x):
+ """
+ Returns the incomplete beta function:
+
+ I-sub-x(a,b) = 1/B(a,b)*(Integral(0,x) of t^(a-1)(1-t)^(b-1) dt)
+
+ where a,b>0 and B(a,b) = G(a)*G(b)/(G(a+b)) where G(a) is the gamma
+ function of a. The continued fraction formulation is implemented here,
+ using the betacf function. (Adapted from: Numerical Recipies in C.)
+
+ Originally written by Gary Strangman.
+
+ Usage: lbetai(a,b,x)
+ """
+ if (x<0.0 or x>1.0):
+ raise ValueError, 'Bad x in lbetai'
+ if (x==0.0 or x==1.0):
+ bt = 0.0
+ else:
+ bt = exp(gammln(a+b)-gammln(a)-gammln(b)+a*log(x)+b*
+ log(1.0-x))
+ if (x<(a+1.0)/(a+b+2.0)):
+ return bt*betacf(a,b,x)/float(a)
+ else:
+ return 1.0-bt*betacf(b,a,1.0-x)/float(b)
+
+def ttest_ind(a, b):
+ """
+ Calculates the t-obtained T-test on TWO INDEPENDENT samples of
+ scores a, and b. Returns t-value, and prob.
+
+ Originally written by Gary Strangman.
+
+ Usage: lttest_ind(a,b)
+ Returns: t-value, two-tailed prob
+ """
+ x1, x2 = mean(a), mean(b)
+ v1, v2 = stdev(a)**2, stdev(b)**2
+ n1, n2 = len(a), len(b)
+ df = n1+n2-2
+ try:
+ svar = ((n1-1)*v1+(n2-1)*v2)/float(df)
+ except ZeroDivisionError:
+ return float('nan'), float('nan')
+ try:
+ t = (x1-x2)/sqrt(svar*(1.0/n1 + 1.0/n2))
+ except ZeroDivisionError:
+ t = 1.0
+ prob = betai(0.5*df,0.5,df/(df+t*t))
+ return t, prob
+
+def zprob(z):
+ """
+ Returns the area under the normal curve 'to the left of' the given z value.
+ Thus,
+ for z<0, zprob(z) = 1-tail probability
+ for z>0, 1.0-zprob(z) = 1-tail probability
+ for any z, 2.0*(1.0-zprob(abs(z))) = 2-tail probability
+ Originally adapted from Gary Perlman code by Gary Strangman.
+
+ Usage: zprob(z)
+ """
+ Z_MAX = 6.0 # maximum meaningful z-value
+ if z == 0.0:
+ x = 0.0
+ else:
+ y = 0.5 * fabs(z)
+ if y >= (Z_MAX*0.5):
+ x = 1.0
+ elif (y < 1.0):
+ w = y*y
+ x = ((((((((0.000124818987 * w
+ -0.001075204047) * w +0.005198775019) * w
+ -0.019198292004) * w +0.059054035642) * w
+ -0.151968751364) * w +0.319152932694) * w
+ -0.531923007300) * w +0.797884560593) * y * 2.0
+ else:
+ y = y - 2.0
+ x = (((((((((((((-0.000045255659 * y
+ +0.000152529290) * y -0.000019538132) * y
+ -0.000676904986) * y +0.001390604284) * y
+ -0.000794620820) * y -0.002034254874) * y
+ +0.006549791214) * y -0.010557625006) * y
+ +0.011630447319) * y -0.009279453341) * y
+ +0.005353579108) * y -0.002141268741) * y
+ +0.000535310849) * y +0.999936657524
+ if z > 0.0:
+ prob = ((x+1.0)*0.5)
+ else:
+ prob = ((1.0-x)*0.5)
+ return prob
+
+def chisqprob(chisq, df):
+ """
+ Returns the (1-tailed) probability value associated with the provided
+ chi-square value and df.
+
+ Originally adapted from Gary Perlman code by Gary Strangman.
+
+ Usage: chisqprob(chisq,df)
+ """
+ BIG = 20.0
+ def ex(x):
+ BIG = 20.0
+ if x < -BIG:
+ return 0.0
+ else:
+ return exp(x)
+
+ if chisq <= 0 or df < 1:
+ return 1.0
+
+ a = 0.5 * chisq
+ if df%2 == 0:
+ even = 1
+ else:
+ even = 0
+ if df > 1:
+ y = ex(-a)
+ if even:
+ s = y
+ else:
+ s = 2.0 * zprob(-sqrt(chisq))
+ if (df > 2):
+ chisq = 0.5 * (df - 1.0)
+ if even:
+ z = 1.0
+ else:
+ z = 0.5
+ if a > BIG:
+ if even:
+ e = 0.0
+ else:
+ e = log(sqrt(pi))
+ c = log(a)
+ while (z <= chisq):
+ e = log(z) + e
+ s = s + ex(c*z-a-e)
+ z = z + 1.0
+ return s
+ else:
+ if even:
+ e = 1.0
+ else:
+ e = 1.0 / sqrt(pi) / sqrt(a)
+ c = 0.0
+ while (z <= chisq):
+ e = e * (a/float(z))
+ c = c + e
+ z = z + 1.0
+ return (c*y+s)
+ else:
+ return s
5 experiments/templates/experiments/confirm_human.html
@@ -0,0 +1,5 @@
+{% if not request.session.experiments_verified_human %}
+ <script type="text/javascript" charset="utf-8">
+ experiments.confirm_human();
+ </script>
+{% endif %}
1  experiments/templates/experiments/goal.html
@@ -0,0 +1 @@
+<img src="{% url experiment_goal goal_name %}" height="1" width="1" />
12 experiments/templates/nexus/experiments/dashboard.html
@@ -0,0 +1,12 @@
+{% if not enabled_experiments %}
+ <p>There are no active experiments.</p>
+{% else %}
+ <p>There are <b>{{ enabled_experiments_count }} active</b> experiments.</p>
+ <table class="experiment-list">
+ {% for experiment in enabled_experiments %}
+ <tr>
+ <td><a href="{% url experiments:results experiment.name %}">{{experiment.name}}</a></td>
+ </tr>
+ {% endfor %}
+ </table>
+{% endif %}
205 experiments/templates/nexus/experiments/index.html
@@ -0,0 +1,205 @@
+{% extends "nexus/module.html" %}
+
+{% load experiment_helpers %}
+
+{% block head %}
+ {{ block.super }}
+ <link rel="stylesheet" href="{% url nexus:media 'experiments' 'css/experiments.css' %}">
+ <script>
+ var EXPERIMENT = {
+ addExperiment: "{% url experiments:add %}",
+ updateExperiment: "{% url experiments:update %}",
+ deleteExperiment: "{% url experiments:delete %}",
+ updateState: "{% url experiments:state %}",
+
+ deleteImage: "{% url nexus:media 'experiments' 'img/delete.png' %}"
+ };
+ </script>
+
+ <script src="{% url nexus:media 'experiments' 'js/string_score.min.js' %}"></script>
+ <script src="{% url nexus:media 'experiments' 'js/nexus_experiments.js' %}"></script>
+{% endblock %}
+
+{% block content %}
+ <div class="toolbar" data-sort="{{ sorted_by }}">
+ <button class="button addExperiment">Add an Experiment</button>
+
+ <span class="search">
+ <input type="text" placeholder="search">
+ </span>
+
+ <ul class="sort">
+ <li class="start_date">
+ <a href="?by={{'start_date'|sort_by_key:sorted_by}}">Start Date</a>
+ </li>
+ <li class="end_date">
+ <a href="?by={{'end_date'|sort_by_key:sorted_by}}">End Date</a>
+ </li>
+ <li class="name">
+ <a href="?by={{'name'|sort_by_key:sorted_by}}">Name</a>
+ </li>
+ </ul>
+
+ </div>
+
+ <div class="noExperiments" {% if experiments %}style="display:none;"{% endif %}>
+ You do not have any experiments.
+ </div>
+
+ <table class="experiments {% if not experiments %}empty{% endif %}">
+ {% for experiment in experiments %}
+ <tr data-experiment-name="{{ experiment.name }}" data-experiment-switch="{{ experiment.switch_key }}" data-experiment-desc="{{ experiment.description }}" data-experiment-goals="{{ experiment.relevant_goals }}" class="collapsed">
+ <td class="name">
+ <h4>{{ experiment.name }}
+ <a href="{% url experiments:results experiment.name %}">(View Results)</a>
+ <small>
+ <span id = "{{ experiment.name }}_start_date"
+ {% if experiment.start_date %}
+ >Started: {{ experiment.start_date }}
+ {% else %}
+ style="display:none;">Started:
+ {% endif %}
+ </span>
+ <span id = "{{ experiment.name }}_end_date"
+ {% if experiment.end_date %}
+ >Ended: {{ experiment.end_date }}
+ {% else %}
+ style="display:none;">Ended:
+ {% endif %}
+ </span>
+ </small>
+ </h4>
+
+ <div class="inner">
+ {% if experiment.description %}
+ <p>{{ experiment.description }}</p>
+ {% endif %}
+
+ {% if experiment.switch_key %}
+ <p>Connected to Gargoyle switch <a href="{% url gargoyle:index %}#id_{{ experiment.switch_key }}">{{ experiment.switch_key }}</a>.</p>
+ {% endif %}
+ </div>
+
+ </td>
+
+ <td class="state">
+ <div data-experiment-name="{{experiment.name}}" class="state">
+ <button class="xtrasmall button {% if experiment.state == 2 %}toggled{% endif %}" data-state="2" {% if not experiment.switch_key %}disabled="disabled"s{% endif %}>
+ Gargoyle
+ </button>
+
+ <button class="xtrasmall button {% if experiment.state == 1 %}toggled{% endif %}" data-state="1">
+ Enabled
+ </button>
+
+ <button class="xtrasmall button {% if experiment.state == 0 %}toggled{% endif %}" data-state="0">
+ Control
+ </button>
+ </div>
+ </td>
+
+ <td class="actions">
+ <button class="edit xtrasmall button"><span></span></button>
+ <button class="delete xtrasmall button"><span></span></button>
+ </td>
+ </tr>
+ {% endfor %}
+ </table>
+ {% raw %}
+ <script type="text/x-jquery-tmpl" id="experimentForm">
+ {{ if add }}
+ <h2>Add an Experiment</h2>
+ {{ else }}
+ <h2>Update an Experiment</h2>
+ {{ /if }}
+
+ <table class="experimentForm">
+ <tr>
+ <th>Name:</th>
+ <td>
+ <input name="name" type="text" value="{{ if name }}${name}{{ /if }}" {{ if add }}{{ else }}disabled{{ /if }}>
+ <p>The experiment name.</p>
+ </td>
+ </tr>
+ <tr>
+ <th>Switch Key:</th>
+ <td>
+ <input name="switch_key" type="text" value="{{ if switch_key }}${switch_key}{{ /if }}">
+ <p>Connected gargoyle switch. (Optional)</p>
+ </td>
+ </tr>
+ <tr>
+ <th>Description:</th>
+ <td>
+ <textarea name="desc">{{ if desc }}${desc}{{ /if }}</textarea>
+ <p>A brief description of this experiment.</p>
+ </td>
+ </tr>