├── test_app ├── __init__.py ├── templates │ ├── django │ │ ├── django_email.html │ │ └── django.html │ └── jingo │ │ └── jingo.html ├── urls.py └── views.py ├── waffle ├── tests │ ├── __init__.py │ ├── base.py │ ├── test_utils.py │ ├── test_middleware.py │ ├── test_templates.py │ ├── test_views.py │ ├── test_decorators.py │ ├── test_testutils.py │ └── test_waffle.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── waffle_switch.py │ │ ├── waffle_sample.py │ │ └── waffle_flag.py ├── migrations │ ├── __init__.py │ └── 0001_initial.py ├── templatetags │ ├── __init__.py │ └── waffle_tags.py ├── south_migrations │ ├── __init__.py │ ├── 0002_auto__add_sample.py │ ├── 0004_auto__add_field_flag_testing.py │ ├── 0003_auto__add_field_flag_note__add_field_switch_note__add_field_sample_not.py │ ├── 0008_auto__add_field_flag_languages.py │ ├── 0005_auto__add_field_flag_created__add_field_flag_modified.py │ ├── 0001_initial.py │ ├── 0006_auto__add_field_switch_created__add_field_switch_modified__add_field_s.py │ └── 0007_auto__chg_field_flag_created__chg_field_flag_modified__chg_field_switc.py ├── urls.py ├── utils.py ├── defaults.py ├── compat.py ├── jinja.py ├── middleware.py ├── templates │ └── waffle │ │ └── waffle.js ├── views.py ├── admin.py ├── decorators.py ├── testutils.py ├── __init__.py └── models.py ├── MANIFEST.in ├── setup.cfg ├── .gitignore ├── requirements.txt ├── travis.txt ├── docs ├── types │ ├── index.rst │ ├── switch.rst │ ├── sample.rst │ └── flag.rst ├── starting │ ├── index.rst │ ├── upgrading.rst │ ├── requirements.rst │ ├── configuring.rst │ └── installation.rst ├── usage │ ├── index.rst │ ├── views.rst │ ├── decorators.rst │ ├── javascript.rst │ ├── cli.rst │ └── templates.rst ├── about │ ├── goals.rst │ ├── why-waffle.rst │ ├── contributing.rst │ └── roadmap.rst ├── index.rst ├── testing │ ├── index.rst │ ├── automated.rst │ └── user.rst ├── Makefile └── conf.py ├── README.rst ├── run.sh ├── .travis.yml ├── setup.py ├── fabfile.py ├── LICENSE ├── CONTRIBUTING.rst ├── tox.ini ├── test_settings.py └── CHANGES /test_app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /waffle/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /waffle/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /waffle/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /waffle/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /waffle/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /waffle/south_migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | include waffle/templates/waffle/waffle.js 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | [flake8] 5 | exclude=waffle/migrations/*,waffle/south_migrations/* 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[oc] 2 | *.swp 3 | build 4 | dist 5 | *.egg-info 6 | *test.db 7 | .coverage 8 | .idea/ 9 | .project 10 | .pydevproject 11 | .settings 12 | docs/_* 13 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # These are required to run the tests. 2 | Django 3 | flake8 4 | 5 | # Everything else is in a Django-version-free version 6 | # for TravisCI. 7 | -r travis.txt 8 | -------------------------------------------------------------------------------- /travis.txt: -------------------------------------------------------------------------------- 1 | # These are the requirements for Travis, e.g. we don't specify Django 2 | # versions. 3 | mock==1.3.0 4 | Jinja2>=2.7.1 5 | South==1.0.2 6 | django-discover-runner==1.0 7 | -------------------------------------------------------------------------------- /docs/types/index.rst: -------------------------------------------------------------------------------- 1 | .. _types-index: 2 | 3 | ===== 4 | Types 5 | ===== 6 | 7 | Waffle supports three types of feature flippers: 8 | 9 | 10 | .. toctree:: 11 | :maxdepth: 1 12 | 13 | flag 14 | switch 15 | sample 16 | -------------------------------------------------------------------------------- /docs/starting/index.rst: -------------------------------------------------------------------------------- 1 | .. _starting-index: 2 | 3 | =============== 4 | Getting Started 5 | =============== 6 | 7 | .. toctree:: 8 | :titlesonly: 9 | 10 | requirements 11 | installation 12 | upgrading 13 | configuring 14 | -------------------------------------------------------------------------------- /waffle/urls.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.conf.urls import patterns, url 4 | 5 | from waffle.views import wafflejs 6 | 7 | urlpatterns = patterns('', 8 | url(r'^wafflejs$', wafflejs, name='wafflejs'), 9 | ) 10 | -------------------------------------------------------------------------------- /test_app/templates/django/django_email.html: -------------------------------------------------------------------------------- 1 | {% load waffle_tags %} 2 | 3 | {% switch switch %} 4 | switch on 5 | {% else %} 6 | switch off 7 | {% endswitch %} 8 | 9 | 10 | {% sample sample %} 11 | sample on 12 | {% else %} 13 | sample off 14 | {% endsample %} 15 | -------------------------------------------------------------------------------- /waffle/tests/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django import test 4 | from django.core import cache 5 | 6 | 7 | class TestCase(test.TransactionTestCase): 8 | 9 | def _pre_setup(self): 10 | cache.cache.clear() 11 | super(TestCase, self)._pre_setup() 12 | -------------------------------------------------------------------------------- /test_app/templates/jingo/jingo.html: -------------------------------------------------------------------------------- 1 | {% if waffle.flag('flag') %} 2 | flag on 3 | {% else %} 4 | flag off 5 | {% endif %} 6 | 7 | {% if waffle.switch('switch') %} 8 | switch on 9 | {% else %} 10 | switch off 11 | {% endif %} 12 | 13 | {% if waffle.sample('sample') %} 14 | sample on 15 | {% else %} 16 | sample off 17 | {% endif %} 18 | 19 | {{ waffle.wafflejs() }} 20 | -------------------------------------------------------------------------------- /docs/usage/index.rst: -------------------------------------------------------------------------------- 1 | .. _usage-index: 2 | 3 | ============ 4 | Using Waffle 5 | ============ 6 | 7 | Waffle provides a simple API to check the state of :ref:`flags 8 | `, :ref:`switches `, and :ref:`samples 9 | ` in views and templates, and even on the client in 10 | JavaScript. 11 | 12 | .. toctree:: 13 | :titlesonly: 14 | 15 | views 16 | decorators 17 | templates 18 | javascript 19 | cli 20 | -------------------------------------------------------------------------------- /docs/starting/upgrading.rst: -------------------------------------------------------------------------------- 1 | .. _starting-upgrading: 2 | 3 | ========= 4 | Upgrading 5 | ========= 6 | 7 | From v0.10.x to v0.11 8 | ===================== 9 | 10 | Jinja2 Templates 11 | ---------------- 12 | 13 | Waffle no longer supports `jingo's ` 14 | automatic helper import, but now ships with a `Jinja2 15 | ` extension that supports multiple Jinja2 16 | template loaders for Django. See the :ref:`installation docs 17 | ` for details on how to install this 18 | extension. 19 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ====== 2 | README 3 | ====== 4 | 5 | Django Waffle is (yet another) feature flipper for Django. You can 6 | define the conditions for which a flag should be active, and use it in 7 | a number of ways. 8 | 9 | .. image:: https://travis-ci.org/jsocol/django-waffle.png?branch=master 10 | :target: https://travis-ci.org/jsocol/django-waffle 11 | :alt: Travis-CI Build Status 12 | 13 | :Code: https://github.com/jsocol/django-waffle 14 | :License: BSD; see LICENSE file 15 | :Issues: https://github.com/jsocol/django-waffle/issues 16 | :Documentation: http://waffle.readthedocs.org/ 17 | -------------------------------------------------------------------------------- /waffle/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals, absolute_import 2 | 3 | import hashlib 4 | 5 | from django.conf import settings 6 | 7 | from . import defaults 8 | 9 | 10 | def get_setting(name): 11 | try: 12 | return getattr(settings, 'WAFFLE_' + name) 13 | except AttributeError: 14 | return getattr(defaults, name) 15 | 16 | 17 | def keyfmt(k, v=None): 18 | prefix = get_setting('CACHE_PREFIX') 19 | if v is None: 20 | key = prefix + k 21 | else: 22 | key = prefix + hashlib.md5((k % v).encode('utf-8')).hexdigest() 23 | return key.encode('utf-8') 24 | -------------------------------------------------------------------------------- /docs/about/goals.rst: -------------------------------------------------------------------------------- 1 | .. _about-goals: 2 | 3 | ============== 4 | Waffle's goals 5 | ============== 6 | 7 | .. note:: 8 | 9 | This document is a work in progress. See :ref:`the roadmap 10 | `, too. 11 | 12 | Waffle is designed to 13 | 14 | - support continuous integration and deployment, 15 | - support feature rollout, 16 | - with minimum set-up time and learning, 17 | - while covering common segments, 18 | - and being fast and robust enough for production use. 19 | 20 | Waffle is **not** designed to 21 | 22 | - be secure, or be a replacement for permissions, 23 | - cover all potential segments. 24 | -------------------------------------------------------------------------------- /waffle/defaults.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | 4 | COOKIE = 'dwf_%s' 5 | TEST_COOKIE = 'dwft_%s' 6 | SECURE = True 7 | MAX_AGE = 2592000 # 1 month in seconds 8 | 9 | CACHE_PREFIX = 'waffle:' 10 | FLAG_CACHE_KEY = 'flag:%s' 11 | FLAG_USERS_CACHE_KEY = 'flag:%s:users' 12 | FLAG_GROUPS_CACHE_KEY = 'flag:%s:groups' 13 | ALL_FLAGS_CACHE_KEY = 'flags:all' 14 | SAMPLE_CACHE_KEY = 'sample:%s' 15 | ALL_SAMPLES_CACHE_KEY = 'samples:all' 16 | SWITCH_CACHE_KEY = 'switch:%s' 17 | ALL_SWITCHES_CACHE_KEY = 'switches:all' 18 | 19 | FLAG_DEFAULT = False 20 | SAMPLE_DEFAULT = False 21 | SWITCH_DEFAULT = False 22 | 23 | OVERRIDE = False 24 | -------------------------------------------------------------------------------- /test_app/templates/django/django.html: -------------------------------------------------------------------------------- 1 | {% load waffle_tags %} 2 | {% flag flag %} 3 | flag on 4 | {% else %} 5 | flag off 6 | {% endflag %} 7 | 8 | 9 | {% switch switch %} 10 | switch on 11 | {% else %} 12 | switch off 13 | {% endswitch %} 14 | 15 | 16 | {% sample sample %} 17 | sample on 18 | {% else %} 19 | sample off 20 | {% endsample %} 21 | 22 | 23 | {% flag flag_var %} 24 | flag_var on 25 | {% else %} 26 | flag_var off 27 | {% endflag %} 28 | 29 | 30 | {% switch switch_var %} 31 | switch_var on 32 | {% else %} 33 | switch_var off 34 | {% endswitch %} 35 | 36 | 37 | {% sample sample_var %} 38 | sample_var on 39 | {% else %} 40 | sample_var off 41 | {% endsample %} 42 | 43 | {% wafflejs %} 44 | -------------------------------------------------------------------------------- /docs/types/switch.rst: -------------------------------------------------------------------------------- 1 | .. _types-switch: 2 | 3 | ======== 4 | Switches 5 | ======== 6 | 7 | Switches are simple booleans: they are on or off, for everyone, all the 8 | time. They do not require a request object and can be used in other 9 | contexts, such as management commands and tasks. 10 | 11 | 12 | Switch Attributes 13 | ================= 14 | 15 | Switches can be administered through the Django `admin site`_ or the 16 | :ref:`command line `. They have the following attributes: 17 | 18 | :Name: 19 | The name of the Switch. 20 | :Active: 21 | Is the Switch active or inactive. 22 | :Note: 23 | Describe where the Switch is used. 24 | 25 | 26 | .. _admin site: https://docs.djangoproject.com/en/dev/ref/contrib/admin/ 27 | -------------------------------------------------------------------------------- /waffle/compat.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import django 4 | import sys 5 | import types 6 | from django.conf import settings 7 | 8 | __all__ = ['cache'] 9 | PY3 = sys.version_info[0] == 3 10 | 11 | if hasattr(settings, 'WAFFLE_CACHE_NAME'): 12 | cache_name = settings.WAFFLE_CACHE_NAME 13 | if django.VERSION >= (1, 7): 14 | from django.core.cache import caches 15 | cache = caches[cache_name] 16 | else: 17 | from django.core.cache import get_cache 18 | cache = get_cache(cache_name) 19 | else: 20 | from django.core.cache import cache 21 | 22 | AUTH_USER_MODEL = getattr(settings, 'AUTH_USER_MODEL', 'auth.User') 23 | 24 | CLASS_TYPES = (type,) 25 | if not PY3: 26 | CLASS_TYPES = (type, types.ClassType) 27 | -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | export PYTHONPATH=".:$PYTHONPATH" 4 | export DJANGO_SETTINGS_MODULE="test_settings" 5 | 6 | usage() { 7 | echo "USAGE: $0 [command]" 8 | echo " test - run the waffle tests" 9 | echo " shell - open the Django shell" 10 | echo " schema - create a schema migration for any model changes" 11 | exit 1 12 | } 13 | 14 | CMD="$1" 15 | shift 16 | 17 | case "$CMD" in 18 | "test" ) 19 | django-admin.py test waffle $@ ;; 20 | "lint" ) 21 | flake8 waffle $@ ;; 22 | "shell" ) 23 | django-admin.py shell $@ ;; 24 | "schema" ) 25 | django-admin.py schemamigration waffle --auto $@ ;; 26 | "makemigrations" ) 27 | django-admin.py makemigrations waffle $@ ;; 28 | * ) 29 | usage ;; 30 | esac 31 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. _index: 2 | 3 | ============= 4 | Django Waffle 5 | ============= 6 | 7 | Waffle is feature flipper for Django. You can define the conditions for 8 | which a flag should be active, and use it in a number of ways. 9 | 10 | :Version: |release| 11 | :Code: https://github.com/jsocol/django-waffle 12 | :License: BSD; see LICENSE file 13 | :Issues: https://github.com/jsocol/django-waffle/issues 14 | 15 | Contents: 16 | 17 | .. toctree:: 18 | :titlesonly: 19 | 20 | about/why-waffle 21 | starting/index 22 | types/index 23 | usage/index 24 | testing/index 25 | about/contributing 26 | about/goals 27 | about/roadmap 28 | 29 | 30 | Indices and tables 31 | ================== 32 | 33 | * :ref:`genindex` 34 | * :ref:`modindex` 35 | * :ref:`search` 36 | 37 | -------------------------------------------------------------------------------- /waffle/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.conf import settings 4 | from django.test import TestCase 5 | from django.test.utils import override_settings 6 | 7 | from waffle import defaults 8 | from waffle.utils import get_setting 9 | 10 | 11 | class GetSettingTests(TestCase): 12 | def test_overridden_setting(self): 13 | prefix = get_setting('CACHE_PREFIX') 14 | self.assertEqual(settings.WAFFLE_CACHE_PREFIX, prefix) 15 | 16 | def test_default_setting(self): 17 | age = get_setting('MAX_AGE') 18 | self.assertEqual(defaults.MAX_AGE, age) 19 | 20 | def test_override_settings(self): 21 | assert not get_setting('OVERRIDE') 22 | with override_settings(WAFFLE_OVERRIDE=True): 23 | assert get_setting('OVERRIDE') 24 | -------------------------------------------------------------------------------- /waffle/jinja.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import jinja2 4 | from jinja2.ext import Extension 5 | 6 | from waffle import flag_is_active, sample_is_active, switch_is_active 7 | from waffle.views import _generate_waffle_js 8 | 9 | 10 | @jinja2.contextfunction 11 | def flag_helper(context, flag_name): 12 | return flag_is_active(context['request'], flag_name) 13 | 14 | 15 | @jinja2.contextfunction 16 | def inline_wafflejs_helper(context): 17 | return _generate_waffle_js(context['request']) 18 | 19 | 20 | class WaffleExtension(Extension): 21 | def __init__(self, environment): 22 | environment.globals['waffle'] = { 23 | 'flag': flag_helper, 24 | 'switch': switch_is_active, 25 | 'sample': sample_is_active, 26 | 'wafflejs': inline_wafflejs_helper 27 | } 28 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: false 3 | python: 4 | - "2.6" 5 | - "2.7" 6 | - "3.3" 7 | - "3.4" 8 | env: 9 | - DJANGO_VERSION=1.4 JINJA_WITH="jingo>=0.7.1,<0.8" 10 | - DJANGO_VERSION=1.6 JINJA_WITH="jingo>=0.7.1,<0.8" 11 | - DJANGO_VERSION=1.7 JINJA_WITH="jingo>=0.8" 12 | - DJANGO_VERSION=1.8 JINJA_WITH="django-jinja>=1.4,<1.5" 13 | install: 14 | - pip install -q "Django>=${DJANGO_VERSION},<${DJANGO_VERSION}.99" "${JINJA_WITH}" -r travis.txt 15 | script: ./run.sh test 16 | matrix: 17 | exclude: 18 | - python: "2.6" 19 | env: DJANGO_VERSION=1.7 JINJA_WITH="jingo>=0.8" 20 | - python: "2.6" 21 | env: DJANGO_VERSION=1.8 JINJA_WITH="django-jinja>=1.4,<1.5" 22 | - python: "3.3" 23 | env: DJANGO_VERSION=1.4 JINJA_WITH="jingo>=0.7.1,<0.8" 24 | - python: "3.4" 25 | env: DJANGO_VERSION=1.4 JINJA_WITH="jingo>=0.7.1,<0.8" 26 | -------------------------------------------------------------------------------- /waffle/middleware.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.utils.encoding import smart_str 4 | 5 | from waffle.utils import get_setting 6 | 7 | 8 | class WaffleMiddleware(object): 9 | def process_response(self, request, response): 10 | secure = get_setting('SECURE') 11 | max_age = get_setting('MAX_AGE') 12 | 13 | if hasattr(request, 'waffles'): 14 | for k in request.waffles: 15 | name = smart_str(get_setting('COOKIE') % k) 16 | active, rollout = request.waffles[k] 17 | if rollout and not active: 18 | # "Inactive" is a session cookie during rollout mode. 19 | age = None 20 | else: 21 | age = max_age 22 | response.set_cookie(name, value=active, max_age=age, 23 | secure=secure) 24 | if hasattr(request, 'waffle_tests'): 25 | for k in request.waffle_tests: 26 | name = smart_str(get_setting('TEST_COOKIE') % k) 27 | value = request.waffle_tests[k] 28 | response.set_cookie(name, value=value) 29 | 30 | return response 31 | -------------------------------------------------------------------------------- /docs/usage/views.rst: -------------------------------------------------------------------------------- 1 | .. _usage-views: 2 | 3 | ===================== 4 | Using Waffle in views 5 | ===================== 6 | 7 | Waffle provides simple methods to test :ref:`flags `, 8 | :ref:`switches `, or :ref:`samples ` in 9 | views (or, for switches and samples, anywhere else you're writing 10 | Python). 11 | 12 | 13 | Flags 14 | ===== 15 | 16 | :: 17 | 18 | waffle.flag_is_active(request, 'flag_name') 19 | 20 | Returns ``True`` if the flag is active for this request, else ``False``. 21 | For example:: 22 | 23 | import waffle 24 | 25 | def my_view(request): 26 | if waffle.flag_is_active(request, 'flag_name'): 27 | """Behavior if flag is active.""" 28 | else: 29 | """Behavior if flag is inactive.""" 30 | 31 | 32 | Switches 33 | ======== 34 | 35 | :: 36 | 37 | waffle.switch_is_active('switch_name') 38 | 39 | Returns ``True`` if the switch is active, else ``False``. 40 | 41 | 42 | Samples 43 | ======= 44 | 45 | :: 46 | 47 | waffle.sample_is_active('sample_name') 48 | 49 | Returns ``True`` if the sample is active, else ``False``. 50 | 51 | .. warning:: 52 | 53 | See the warning in the :ref:`Sample chapter `. 54 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name='django-waffle', 5 | version='0.11', 6 | description='A feature flipper for Django.', 7 | long_description=open('README.rst').read(), 8 | author='James Socol', 9 | author_email='me@jamessocol.com', 10 | url='http://github.com/jsocol/django-waffle', 11 | license='BSD', 12 | packages=find_packages(exclude=['test_app', 'test_settings']), 13 | include_package_data=True, 14 | package_data={'': ['README.rst']}, 15 | zip_safe=False, 16 | classifiers=[ 17 | 'Development Status :: 4 - Beta', 18 | 'Environment :: Web Environment', 19 | 'Framework :: Django', 20 | 'Intended Audience :: Developers', 21 | 'License :: OSI Approved :: BSD License', 22 | 'Operating System :: OS Independent', 23 | 'Programming Language :: Python', 24 | 'Programming Language :: Python :: 2', 25 | 'Programming Language :: Python :: 2.6', 26 | 'Programming Language :: Python :: 2.7', 27 | 'Programming Language :: Python :: 3', 28 | 'Programming Language :: Python :: 3.3', 29 | 'Programming Language :: Python :: 3.4', 30 | 'Topic :: Software Development :: Libraries :: Python Modules', 31 | ] 32 | ) 33 | -------------------------------------------------------------------------------- /test_app/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import patterns, url, include 2 | from django.contrib import admin 3 | from django.http import HttpResponseNotFound, HttpResponseServerError 4 | 5 | from test_app import views 6 | 7 | 8 | handler404 = lambda r: HttpResponseNotFound() 9 | handler500 = lambda r: HttpResponseServerError() 10 | 11 | admin.autodiscover() 12 | 13 | urlpatterns = patterns('', 14 | url(r'^flag_in_view', views.flag_in_view, name='flag_in_view'), 15 | url(r'^switch-on', views.switched_view), 16 | url(r'^switch-off', views.switched_off_view), 17 | url(r'^flag-on', views.flagged_view), 18 | url(r'^foo_view', views.foo_view, name='foo_view'), 19 | url(r'^switched_view_with_valid_redirect', views.switched_view_with_valid_redirect), 20 | url(r'^switched_view_with_valid_url_name', views.switched_view_with_valid_url_name), 21 | url(r'^switched_view_with_invalid_redirect', views.switched_view_with_invalid_redirect), 22 | url(r'^flagged_view_with_valid_redirect', views.flagged_view_with_valid_redirect), 23 | url(r'^flagged_view_with_valid_url_name', views.flagged_view_with_valid_url_name), 24 | url(r'^flagged_view_with_invalid_redirect', views.flagged_view_with_invalid_redirect), 25 | url(r'^flag-off', views.flagged_off_view), 26 | (r'^', include('waffle.urls')), 27 | (r'^admin/', include(admin.site.urls)) 28 | ) 29 | -------------------------------------------------------------------------------- /fabfile.py: -------------------------------------------------------------------------------- 1 | """ 2 | Creating standalone Django apps is a PITA because you're not in a project, so 3 | you don't have a settings.py file. I can never remember to define 4 | DJANGO_SETTINGS_MODULE, so I run these commands which get the right env 5 | automatically. 6 | """ 7 | import functools 8 | import os 9 | 10 | from fabric.api import local as _local 11 | 12 | 13 | NAME = os.path.basename(os.path.dirname(__file__)) 14 | ROOT = os.path.abspath(os.path.dirname(__file__)) 15 | 16 | os.environ['DJANGO_SETTINGS_MODULE'] = 'test_settings' 17 | os.environ['PYTHONPATH'] = '.' 18 | 19 | _local = functools.partial(_local, capture=False) 20 | 21 | 22 | def shell(): 23 | """Start a Django shell with the test settings.""" 24 | _local('django-admin.py shell') 25 | 26 | 27 | def test(): 28 | """Run the Waffle test suite.""" 29 | _local('django-admin.py test') 30 | 31 | 32 | def serve(): 33 | """Start the Django dev server.""" 34 | _local('django-admin.py runserver') 35 | 36 | 37 | def syncdb(): 38 | """Create a database for testing in the shell or server.""" 39 | _local('django-admin.py syncdb') 40 | 41 | 42 | def schema(): 43 | """Create a schema migration for any changes.""" 44 | _local('django-admin.py schemamigration waffle --auto') 45 | 46 | 47 | def migrate(): 48 | """Update a testing database with south.""" 49 | _local('django-admin.py migrate') 50 | -------------------------------------------------------------------------------- /waffle/templates/waffle/waffle.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | var FLAGS = { 3 | {% for flag, value in flags %}'{{ flag }}': {% if value %}true{% else %}false{% endif %}{% if not forloop.last %},{% endif %}{% endfor %} 4 | }, 5 | SWITCHES = { 6 | {% for switch, value in switches %}'{{ switch }}': {% if value %}true{% else %}false{% endif %}{% if not forloop.last %},{% endif %}{% endfor %} 7 | }, 8 | SAMPLES = { 9 | {% for sample, value in samples %}'{{ sample }}': {% if value %}true{% else %}false{% endif %}{% if not forloop.last %},{% endif %}{% endfor %} 10 | }; 11 | window.waffle = { 12 | "flag_is_active": function waffle_flag(flag_name) { 13 | {% if flag_default %} 14 | if(FLAGS[flag_name] === undefined) return true; 15 | {% endif %} 16 | return !!FLAGS[flag_name]; 17 | }, 18 | "switch_is_active": function waffle_switch(switch_name) { 19 | {% if switch_default %} 20 | if(SWITCHES[switch_name] === undefined) return true; 21 | {% endif %} 22 | return !!SWITCHES[switch_name]; 23 | }, 24 | "sample_is_active": function waffle_sample(sample_name) { 25 | {% if sample_default %} 26 | if(SAMPLES[sample_name] === undefined) return true; 27 | {% endif %} 28 | return !!SAMPLES[sample_name]; 29 | }, 30 | "FLAGS": FLAGS, 31 | "SWITCHES": SWITCHES, 32 | "SAMPLES": SAMPLES 33 | }; 34 | })(); 35 | -------------------------------------------------------------------------------- /docs/types/sample.rst: -------------------------------------------------------------------------------- 1 | .. _types-sample: 2 | 3 | ======= 4 | Samples 5 | ======= 6 | 7 | Samples are on a given percentage of the time. They do not require a 8 | request object and can be used in other contexts, such as management 9 | commands and tasks. 10 | 11 | .. warning:: 12 | 13 | Sample values are random: if you check a Sample twice, there is no 14 | guarantee you will get the same value both times. If you need to 15 | rely on the value more than once, you should store it in a variable. 16 | 17 | :: 18 | 19 | # YES 20 | foo_on = sample_is_active('foo') 21 | if foo_on: 22 | pass 23 | 24 | # ...later... 25 | if foo_on: 26 | pass 27 | 28 | :: 29 | 30 | # NO! 31 | if sample_is_active('foo'): 32 | pass 33 | 34 | # ...later... 35 | if sample_is_active('foo'): # INDEPENDENT of the previous check 36 | pass 37 | 38 | 39 | Sample Attributes 40 | ================= 41 | 42 | Samples can be administered through the Django `admin site`_ or the 43 | :ref:`command line `. They have the following attributes: 44 | 45 | :Name: 46 | The name of the Sample. 47 | :Percent: 48 | A number from 0.0 to 100.0 that determines how often the Sample 49 | will be active. 50 | :Note: 51 | Describe where the Sample is used. 52 | 53 | 54 | .. _admin site: https://docs.djangoproject.com/en/dev/ref/contrib/admin/ 55 | -------------------------------------------------------------------------------- /docs/testing/index.rst: -------------------------------------------------------------------------------- 1 | .. _testing-index: 2 | 3 | =================== 4 | Testing with Waffle 5 | =================== 6 | 7 | "Testing" takes on at least two distinct meanings with Waffle: 8 | 9 | - Testing your application with automated tools 10 | - Testing your feature with users 11 | 12 | For the purposes of this chapter, we'll refer to the former as 13 | "automated testing" and the latter as "user testing" for clarity. 14 | 15 | .. toctree:: 16 | :maxdepth: 1 17 | 18 | automated 19 | user 20 | 21 | 22 | Automated testing 23 | ================= 24 | 25 | Automated testing encompasses things like unit and integration tests, 26 | whether they use the Python/Django unittest framework or an external 27 | tool like Selenium. 28 | 29 | Waffle is often non-deterministic, i.e. it introduces true randomness to 30 | the system-under-test, which is a nightmare for automated testing. Thus, 31 | Waffle includes tools to re-introduce determinism in automated test 32 | suites. 33 | 34 | :ref:`Read more about automated testing `. 35 | 36 | 37 | User testing 38 | ============ 39 | 40 | User testing occurs on both a (relatively) large scale with automated 41 | metric collection and on a small, often one-to-one—such as testing 42 | sessions with a user and research or turning on a feature within a 43 | company or team. 44 | 45 | Waffle does what it can to support these kinds of tests while still 46 | remaining agnostic about metrics platforms. 47 | 48 | :ref:`Read more about user testing `. 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 James Socol 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of django-waffle nor the names of its contributors may 15 | be used to endorse or promote products derived from this software 16 | without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | 29 | -------------------------------------------------------------------------------- /docs/starting/requirements.rst: -------------------------------------------------------------------------------- 1 | .. _starting-requirements: 2 | 3 | ============ 4 | Requirements 5 | ============ 6 | 7 | Waffle depends only on Django (except for :ref:`running Waffle's tests 8 | `) but does require certain Django features. 9 | 10 | 11 | User Models 12 | =========== 13 | 14 | Waffle requires Django's `auth system`_, in particular it requires both 15 | a user model and Django's groups. If you're using a `custom user 16 | model`_, this can be accomplished by including Django's 17 | `PermissionsMixin`_, e.g.:: 18 | 19 | from django.contrib.auth import models 20 | 21 | class MyUser(models.AbstractBaseUser, models.PermissionsMixin): 22 | 23 | And of ``django.contrib.auth`` must be in ``INSTALLED_APPS``, along with 24 | `its requirements`_. 25 | 26 | .. _auth system: https://docs.djangoproject.com/en/dev/topics/auth/ 27 | .. _custom user model: https://docs.djangoproject.com/en/dev/topics/auth/customizing/#specifying-a-custom-user-model 28 | .. _PermissionsMixin: https://docs.djangoproject.com/en/dev/topics/auth/customizing/#custom-users-and-permissions 29 | .. _its requirements: https://docs.djangoproject.com/en/dev/topics/auth/#installation 30 | 31 | 32 | Templates 33 | ========= 34 | 35 | Waffle provides template tags to check flags directly in templates. 36 | Using these requires the ``request`` object in the template context, 37 | which can be easily added with the ``request`` `template context 38 | processor`_:: 39 | 40 | TEMPLATE_CONTEXT_PROCESSORS = ( 41 | # ... 42 | 'django.core.context_processors.request', 43 | # ... 44 | 45 | .. _template context processor: https://docs.djangoproject.com/en/dev/ref/settings/#template-context-processors 46 | -------------------------------------------------------------------------------- /docs/usage/decorators.rst: -------------------------------------------------------------------------------- 1 | .. _usage-decorators: 2 | 3 | ======================= 4 | Decorating entire views 5 | ======================= 6 | 7 | Waffle provides decorators to wrap an entire view in a :ref:`flag 8 | ` or :ref:`switch `. (Due to their 9 | always-random nature, no decorator is provided for :ref:`samples 10 | `.) 11 | 12 | When the flag or switch is active, the view executes normally. When it 13 | is inactive, the view returns a 404. Optionally, you can provide a 14 | view or URL name where the decorator can redirect to if you don't want 15 | to show a 404 page when the flag or switch is inactive. 16 | 17 | 18 | Flags 19 | ===== 20 | 21 | :: 22 | 23 | from waffle.decorators import waffle_flag 24 | 25 | @waffle_flag('flag_name') 26 | def myview(request): 27 | pass 28 | 29 | @waffle_flag('flag_name', 'url_name_to_redirect_to') 30 | def myotherview(request): 31 | pass 32 | 33 | Switches 34 | ======== 35 | 36 | :: 37 | 38 | from waffle.decorators import waffle_switch 39 | 40 | @waffle_switch('switch_name') 41 | def myview(request): 42 | pass 43 | 44 | @waffle_switch('switch_name', 'url_name_to_redirect_to') 45 | def myotherview(request): 46 | pass 47 | 48 | Inverting Decorators 49 | ==================== 50 | 51 | Both ``waffle_flag`` and ``waffle_switch`` can be reversed (i.e. they 52 | will raise a 404 if the flag or switch is *active*, and otherwise 53 | execute the view normally) by prepending the name of the flag or switch 54 | with an exclamation point: ``!``. 55 | 56 | :: 57 | 58 | @waffle_switch('!switch_name') 59 | def myview(request): 60 | """Only runs if 'switch_name' is OFF.""" 61 | -------------------------------------------------------------------------------- /docs/about/why-waffle.rst: -------------------------------------------------------------------------------- 1 | .. _about-why-waffle: 2 | 3 | =========== 4 | Why Waffle? 5 | =========== 6 | 7 | `Feature flags`_ are a critical tool for continuously integrating and 8 | deploying applications. Waffle is one of `several options`_ for managing 9 | feature flags in Django applications. 10 | 11 | Waffle :ref:`aims to ` 12 | 13 | - provide a simple, intuitive API everywhere in your application; 14 | - cover common use cases with batteries-included; 15 | - be simple to install and manage; 16 | - be fast and robust enough to use in production; and 17 | - minimize dependencies and complexity. 18 | 19 | Waffle has an `active community`_ and gets `fairly steady updates`_. 20 | 21 | 22 | vs Gargoyle 23 | =========== 24 | 25 | The other major, active feature flag tool for Django is Disqus's 26 | Gargoyle_. Both support similar features, though Gargoyle offers more 27 | options for building custom segments in exchange for some more 28 | complexity and requirements. 29 | 30 | 31 | Waffle in Production 32 | ==================== 33 | 34 | Despite its pre-1.0 version number, Waffle has been used in production 35 | for years at places like Mozilla, Yipit and TodaysMeet. 36 | 37 | - Mozilla (Support, MDN, Addons, etc) 38 | - TodaysMeet 39 | - Yipit 40 | 41 | (If you're using Waffle in production and don't mind being included 42 | here, let me know or add yourself in a pull request!) 43 | 44 | 45 | .. _Feature flags: http://code.flickr.net/2009/12/02/flipping-out/ 46 | .. _several options: https://www.djangopackages.com/grids/g/feature-flip/ 47 | .. _active community: https://github.com/jsocol/django-waffle/graphs/contributors 48 | .. _fairly steady updates: https://github.com/jsocol/django-waffle/pulse/monthly 49 | .. _Gargoyle: https://github.com/disqus/gargoyle 50 | -------------------------------------------------------------------------------- /docs/starting/configuring.rst: -------------------------------------------------------------------------------- 1 | .. _starting-configuring: 2 | 3 | ================== 4 | Configuring Waffle 5 | ================== 6 | 7 | There are a few global settings you can define to adjust Waffle's 8 | behavior. 9 | 10 | ``WAFFLE_COOKIE`` 11 | The format for the cookies Waffle sets. Must contain ``%s``. 12 | Defaults to ``dwf_%s``. 13 | 14 | ``WAFFLE_FLAG_DEFAULT`` 15 | When a Flag is undefined in the database, Waffle considers it 16 | ``False``. Set this to ``True`` to make Waffle consider undefined 17 | flags ``True``. Defaults to ``False``. 18 | 19 | ``WAFFLE_SWITCH_DEFAULT`` 20 | When a Switch is undefined in the database, Waffle considers it 21 | ``False``. Set this to ``True`` to make Waffle consider undefined 22 | switches ``True``. Defaults to ``False``. 23 | 24 | ``WAFFLE_SAMPLE_DEFAULT`` 25 | When a Sample is undefined in the database, Waffle considers it 26 | ``False``. Set this to ``True`` to make Waffle consider undefined 27 | samples ``True``. Defaults to ``False``. 28 | 29 | ``WAFFLE_MAX_AGE`` 30 | How long should Waffle cookies last? (Integer, in seconds.) Defaults 31 | to ``2529000`` (one month). 32 | 33 | ``WAFFLE_OVERRIDE`` 34 | Allow *all* Flags to be controlled via the querystring (to allow 35 | e.g. Selenium to control their behavior). Defaults to ``False``. 36 | 37 | ``WAFFLE_SECURE`` 38 | Whether to set the ``secure`` flag on cookies. Defaults to ``True``. 39 | 40 | ``WAFFLE_CACHE_PREFIX`` 41 | Waffle tries to store objects in cache pretty aggressively. If you 42 | ever upgrade and change the shape of the objects (for example 43 | upgrading from <0.7.5 to >0.7.5) you'll want to set this to 44 | something other than ``'waffle:'``. 45 | 46 | ``WAFFLE_CACHE_NAME`` 47 | Which cache to use. Defaults to ``'default'``. 48 | -------------------------------------------------------------------------------- /waffle/tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.http import HttpResponse 4 | from django.test import RequestFactory 5 | 6 | from waffle.middleware import WaffleMiddleware 7 | 8 | 9 | get = RequestFactory().get('/foo') 10 | 11 | 12 | def test_set_cookies(): 13 | get.waffles = {'foo': [True, False], 'bar': [False, False]} 14 | resp = HttpResponse() 15 | assert 'dwf_foo' not in resp.cookies 16 | assert 'dwf_bar' not in resp.cookies 17 | 18 | resp = WaffleMiddleware().process_response(get, resp) 19 | assert 'dwf_foo' in resp.cookies 20 | assert 'dwf_bar' in resp.cookies 21 | 22 | assert 'True' == resp.cookies['dwf_foo'].value 23 | assert 'False' == resp.cookies['dwf_bar'].value 24 | 25 | 26 | def test_rollout_cookies(): 27 | get.waffles = {'foo': [True, True], 28 | 'bar': [False, True], 29 | 'baz': [True, False], 30 | 'qux': [False, False]} 31 | resp = HttpResponse() 32 | resp = WaffleMiddleware().process_response(get, resp) 33 | for k in get.waffles: 34 | cookie = 'dwf_%s' % k 35 | assert cookie in resp.cookies 36 | assert str(get.waffles[k][0]) == resp.cookies[cookie].value 37 | if get.waffles[k][1]: 38 | assert bool(resp.cookies[cookie]['max-age']) == get.waffles[k][0] 39 | else: 40 | assert resp.cookies[cookie]['max-age'] 41 | 42 | 43 | def test_testing_cookies(): 44 | get.waffles = {} 45 | get.waffle_tests = {'foo': True, 'bar': False} 46 | resp = HttpResponse() 47 | resp = WaffleMiddleware().process_response(get, resp) 48 | for k in get.waffle_tests: 49 | cookie = 'dwft_%s' % k 50 | assert str(get.waffle_tests[k]) == resp.cookies[cookie].value 51 | assert not resp.cookies[cookie]['max-age'] 52 | -------------------------------------------------------------------------------- /waffle/views.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.http import HttpResponse 4 | from django.template import loader 5 | from django.views.decorators.cache import never_cache 6 | 7 | from waffle import flag_is_active, sample_is_active 8 | from waffle.compat import cache 9 | from waffle.models import Flag, Sample, Switch 10 | from waffle.utils import get_setting, keyfmt 11 | 12 | 13 | @never_cache 14 | def wafflejs(request): 15 | return HttpResponse(_generate_waffle_js(request), 16 | content_type='application/x-javascript') 17 | 18 | 19 | def _generate_waffle_js(request): 20 | flags = cache.get(keyfmt(get_setting('ALL_FLAGS_CACHE_KEY'))) 21 | if flags is None: 22 | flags = Flag.objects.values_list('name', flat=True) 23 | cache.add(keyfmt(get_setting('ALL_FLAGS_CACHE_KEY')), flags) 24 | flag_values = [(f, flag_is_active(request, f)) for f in flags] 25 | 26 | switches = cache.get(keyfmt(get_setting('ALL_SWITCHES_CACHE_KEY'))) 27 | if switches is None: 28 | switches = Switch.objects.values_list('name', 'active') 29 | cache.add(keyfmt(get_setting('ALL_SWITCHES_CACHE_KEY')), switches) 30 | 31 | samples = cache.get(keyfmt(get_setting('ALL_SAMPLES_CACHE_KEY'))) 32 | if samples is None: 33 | samples = Sample.objects.values_list('name', flat=True) 34 | cache.add(keyfmt(get_setting('ALL_SAMPLES_CACHE_KEY')), samples) 35 | sample_values = [(s, sample_is_active(s)) for s in samples] 36 | 37 | return loader.render_to_string('waffle/waffle.js', { 38 | 'flags': flag_values, 39 | 'switches': switches, 40 | 'samples': sample_values, 41 | 'flag_default': get_setting('FLAG_DEFAULT'), 42 | 'switch_default': get_setting('SWITCH_DEFAULT'), 43 | 'sample_default': get_setting('SAMPLE_DEFAULT'), 44 | }) 45 | -------------------------------------------------------------------------------- /waffle/management/commands/waffle_switch.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | from optparse import make_option 4 | 5 | from django.core.management.base import BaseCommand, CommandError 6 | 7 | from waffle.models import Switch 8 | 9 | 10 | class Command(BaseCommand): 11 | option_list = BaseCommand.option_list + ( 12 | make_option('-l', '--list', 13 | action='store_true', dest='list_switch', default=False, 14 | help='List existing switchs.'), 15 | make_option('--create', 16 | action='store_true', 17 | dest='create', 18 | default=False, 19 | help="If the switch doesn't exist, create it."), 20 | ) 21 | help = 'Activate or deactivate a switch.' 22 | args = ' ' 23 | 24 | def handle(self, switch_name=None, state=None, *args, **options): 25 | list_switch = options['list_switch'] 26 | 27 | if list_switch: 28 | print('Switches:') 29 | for switch in Switch.objects.iterator(): 30 | print('%s: %s', (switch.name, 31 | 'on' if switch.active else 'off')) 32 | return 33 | 34 | if not (switch_name and state): 35 | raise CommandError('You need to specify a switch name and state.') 36 | 37 | if not state in ['on', 'off']: 38 | raise CommandError('You need to specify state of switch with ' 39 | '"on" or "off".') 40 | 41 | if options['create']: 42 | switch, created = Switch.objects.get_or_create(name=switch_name) 43 | if created: 44 | print('Creating switch: %s' % switch_name) 45 | else: 46 | try: 47 | switch = Switch.objects.get(name=switch_name) 48 | except Switch.DoesNotExist: 49 | raise CommandError("This switch doesn't exist.") 50 | 51 | switch.active = state == "on" 52 | switch.save() 53 | -------------------------------------------------------------------------------- /docs/usage/javascript.rst: -------------------------------------------------------------------------------- 1 | .. _usage-javascript: 2 | 3 | ============== 4 | Using WaffleJS 5 | ============== 6 | 7 | Waffle supports using :ref:`flags `, :ref:`switches 8 | `, and :ref:`samples ` in JavaScript 9 | ("WaffleJS") either via inline script or an external script. 10 | 11 | .. warning:: 12 | 13 | Unlike samples when used in Python, samples in WaffleJS **are only 14 | calculated once** and so are **consistent**. 15 | 16 | 17 | The WaffleJS ``waffle`` object 18 | ============================== 19 | 20 | WaffleJS exposes a global ``waffle`` object that gives access to flags, 21 | switches, and samples. 22 | 23 | 24 | Methods 25 | ------- 26 | 27 | These methods can be used exactly like their Python equivalents: 28 | 29 | - ``waffle.flag_is_active(flag_name)`` 30 | - ``waffle.switch_is_active(switch_name)`` 31 | - ``waffle.sample_is_active(sample_name)`` 32 | 33 | 34 | Members 35 | ------- 36 | 37 | WaffleJS also directly exposes dictionaries of each type, where keys are 38 | the names and values are ``true`` or ``false``: 39 | 40 | - ``waffle.FLAGS`` 41 | - ``waffle.SWITCHES`` 42 | - ``waffle.SAMPLES`` 43 | 44 | 45 | Installing WaffleJS 46 | =================== 47 | 48 | 49 | As an external script 50 | --------------------- 51 | 52 | Using the ``wafflejs`` view requires adding Waffle to your URL 53 | configuration. For example, in your ``ROOT_URLCONF``:: 54 | 55 | urlpatterns = patterns('', 56 | (r'^', include('waffle.urls')), 57 | ) 58 | 59 | This adds a route called ``wafflejs``, which you can use with the 60 | ``url`` template tag: 61 | 62 | .. code-blocK:: django 63 | 64 | 65 | 66 | 67 | As an inline script 68 | ------------------- 69 | 70 | To avoid an extra request, you can also use the ``wafflejs`` template 71 | tag to include WaffleJS as an inline script: 72 | 73 | .. code-block:: django 74 | 75 | {% load waffle_tags %} 76 | 79 | -------------------------------------------------------------------------------- /waffle/management/commands/waffle_sample.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | from optparse import make_option 4 | 5 | from django.core.management.base import BaseCommand, CommandError 6 | 7 | from waffle.models import Sample 8 | 9 | 10 | class Command(BaseCommand): 11 | option_list = BaseCommand.option_list + ( 12 | make_option('-l', '--list', 13 | action='store_true', dest='list_sample', default=False, 14 | help='List existing samples.'), 15 | make_option('--create', 16 | action='store_true', 17 | dest='create', 18 | default=False, 19 | help="If the sample doesn't exist, create it."), 20 | ) 21 | 22 | help = 'Change percentage of a sample.' 23 | args = ' ' 24 | 25 | def handle(self, sample_name=None, percent=None, *args, **options): 26 | list_sample = options['list_sample'] 27 | 28 | if list_sample: 29 | print('Samples:') 30 | for sample in Sample.objects.iterator(): 31 | print('%s: %s%%' % (sample.name, sample.percent)) 32 | return 33 | 34 | if not (sample_name and percent): 35 | raise CommandError('You need to specify a sample ' 36 | 'name and percentage.') 37 | 38 | try: 39 | percent = float(percent) 40 | if not (0.0 <= percent <= 100.0): 41 | raise ValueError() 42 | except ValueError: 43 | raise CommandError('You need to enter a valid percentage value.') 44 | 45 | if options['create']: 46 | sample, created = Sample.objects.get_or_create( 47 | name=sample_name, defaults={'percent': 0}) 48 | if created: 49 | print('Creating sample: %s' % sample_name) 50 | else: 51 | try: 52 | sample = Sample.objects.get(name=sample_name) 53 | except Sample.DoesNotExist: 54 | raise CommandError('This sample does not exist.') 55 | 56 | sample.percent = percent 57 | sample.save() 58 | -------------------------------------------------------------------------------- /waffle/admin.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.contrib import admin 4 | 5 | from waffle.models import Flag, Sample, Switch 6 | 7 | 8 | def enable_for_all(ma, request, qs): 9 | # Iterate over all objects to cause cache invalidation. 10 | for f in qs.all(): 11 | f.everyone = True 12 | f.save() 13 | enable_for_all.short_description = 'Enable selected flags for everyone.' 14 | 15 | 16 | def disable_for_all(ma, request, qs): 17 | # Iterate over all objects to cause cache invalidation. 18 | for f in qs.all(): 19 | f.everyone = False 20 | f.save() 21 | disable_for_all.short_description = 'Disable selected flags for everyone.' 22 | 23 | 24 | class FlagAdmin(admin.ModelAdmin): 25 | actions = [enable_for_all, disable_for_all] 26 | date_hierarchy = 'created' 27 | list_display = ('name', 'note', 'everyone', 'percent', 'superusers', 28 | 'staff', 'authenticated', 'languages') 29 | list_filter = ('everyone', 'superusers', 'staff', 'authenticated') 30 | raw_id_fields = ('users', 'groups') 31 | ordering = ('-id',) 32 | 33 | 34 | def enable_switches(ma, request, qs): 35 | for switch in qs: 36 | switch.active = True 37 | switch.save() 38 | enable_switches.short_description = 'Enable the selected switches.' 39 | 40 | 41 | def disable_switches(ma, request, qs): 42 | for switch in qs: 43 | switch.active = False 44 | switch.save() 45 | disable_switches.short_description = 'Disable the selected switches.' 46 | 47 | 48 | class SwitchAdmin(admin.ModelAdmin): 49 | actions = [enable_switches, disable_switches] 50 | date_hierarchy = 'created' 51 | list_display = ('name', 'active', 'note', 'created', 'modified') 52 | list_filter = ('active',) 53 | ordering = ('-id',) 54 | 55 | 56 | class SampleAdmin(admin.ModelAdmin): 57 | date_hierarchy = 'created' 58 | list_display = ('name', 'percent', 'note', 'created', 'modified') 59 | ordering = ('-id',) 60 | 61 | 62 | admin.site.register(Flag, FlagAdmin) 63 | admin.site.register(Sample, SampleAdmin) 64 | admin.site.register(Switch, SwitchAdmin) 65 | -------------------------------------------------------------------------------- /waffle/decorators.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from functools import wraps 4 | 5 | from django.http import Http404 6 | from django.utils.decorators import available_attrs 7 | from django.core.urlresolvers import reverse, NoReverseMatch 8 | from django.shortcuts import redirect 9 | 10 | from waffle import flag_is_active, switch_is_active 11 | 12 | 13 | def waffle_flag(flag_name, redirect_to=None): 14 | def decorator(view): 15 | @wraps(view, assigned=available_attrs(view)) 16 | def _wrapped_view(request, *args, **kwargs): 17 | if flag_name.startswith('!'): 18 | active = not flag_is_active(request, flag_name[1:]) 19 | else: 20 | active = flag_is_active(request, flag_name) 21 | 22 | if not active: 23 | response_to_redirect_to = get_response_to_redirect(redirect_to) 24 | if response_to_redirect_to: 25 | return response_to_redirect_to 26 | else: 27 | raise Http404 28 | 29 | return view(request, *args, **kwargs) 30 | return _wrapped_view 31 | return decorator 32 | 33 | 34 | def waffle_switch(switch_name, redirect_to=None): 35 | def decorator(view): 36 | @wraps(view, assigned=available_attrs(view)) 37 | def _wrapped_view(request, *args, **kwargs): 38 | if switch_name.startswith('!'): 39 | active = not switch_is_active(switch_name[1:]) 40 | else: 41 | active = switch_is_active(switch_name) 42 | 43 | if not active: 44 | response_to_redirect_to = get_response_to_redirect(redirect_to) 45 | if response_to_redirect_to: 46 | return response_to_redirect_to 47 | else: 48 | raise Http404 49 | 50 | return view(request, *args, **kwargs) 51 | return _wrapped_view 52 | return decorator 53 | 54 | 55 | def get_response_to_redirect(view): 56 | try: 57 | return redirect(reverse(view)) if view else None 58 | except NoReverseMatch: 59 | return None 60 | 61 | -------------------------------------------------------------------------------- /docs/usage/cli.rst: -------------------------------------------------------------------------------- 1 | .. _usage-cli: 2 | .. highlight:: shell 3 | 4 | ========================================== 5 | Managing Waffle data from the command line 6 | ========================================== 7 | 8 | Aside the Django admin interface, you can use the command line tools to 9 | manage all your waffle objects. 10 | 11 | 12 | Flags 13 | ===== 14 | 15 | Use ``manage.py`` to change the values of your flags:: 16 | 17 | $ ./manage.py waffle_flag name-of-my-flag --everyone --percent=47 18 | 19 | Use ``--everyone`` to turn on and ``--deactive`` to turn off the flag. 20 | Set a percentage with ``--percent`` or ``-p``. Set the flag on for 21 | superusers (``--superusers``), staff (``--staff``) or authenticated 22 | (``--authenticated``) users. Set the rollout mode on with ``--rollout`` 23 | or ``-r``. 24 | 25 | If the flag doesn't exist, add ``--create`` to create it before setting 26 | its values:: 27 | 28 | $ ./manage.py waffle_flag name-of-my-flag --deactivate --create 29 | 30 | To list all the existing flags, use ``-l``:: 31 | 32 | $ ./manage.py waffle_flag -l 33 | Flags: 34 | name-of-my-flag 35 | 36 | 37 | Switches 38 | ======== 39 | 40 | Use ``manage.py`` to change the values of your switches:: 41 | 42 | $ ./manage.py waffle_switch name-of-my-switch off 43 | 44 | You can set a switch to ``on`` or ``off``. If that switch doesn't exist, 45 | add ``--create`` to create it before setting its value:: 46 | 47 | $ ./manage.py waffle_switch name-of-my-switch on --create 48 | 49 | To list all the existing switches, use ``-l``:: 50 | 51 | $ ./manage.py waffle_switch -l 52 | Switches: 53 | name-of-my-switch on 54 | 55 | 56 | Samples 57 | ======= 58 | 59 | Use ``manage.py`` to change the values of your samples:: 60 | 61 | $ ./manage.py waffle_sample name-of-my-sample 100 62 | 63 | You can set a sample to any floating value between ``0.0`` and 64 | ``100.0``. If that sample doesn't exist, add ``--create`` to create it 65 | before setting its value:: 66 | 67 | $ ./manage.py waffle_sample name-of-my-sample 50.0 --create 68 | 69 | To list all the existing samples, use ``-l``:: 70 | 71 | $ ./manage.py waffle_sample -l 72 | Samples: 73 | name-of-my-sample: 50% 74 | -------------------------------------------------------------------------------- /waffle/tests/test_templates.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.contrib.auth.models import AnonymousUser 4 | from django.template import Template 5 | from django.template.base import VariableNode 6 | from django.test import RequestFactory 7 | 8 | from test_app import views 9 | from waffle.middleware import WaffleMiddleware 10 | from waffle.tests.base import TestCase 11 | 12 | 13 | def get(): 14 | request = RequestFactory().get('/foo') 15 | request.user = AnonymousUser() 16 | return request 17 | 18 | 19 | def process_request(request, view): 20 | response = view(request) 21 | return WaffleMiddleware().process_response(request, response) 22 | 23 | 24 | class WaffleTemplateTests(TestCase): 25 | 26 | def test_django_tags(self): 27 | request = get() 28 | response = process_request(request, views.flag_in_django) 29 | self.assertContains(response, 'flag off') 30 | self.assertContains(response, 'switch off') 31 | self.assertContains(response, 'sample') 32 | self.assertContains(response, 'flag_var off') 33 | self.assertContains(response, 'switch_var off') 34 | self.assertContains(response, 'sample_var') 35 | self.assertContains(response, 'window.waffle =') 36 | 37 | def test_get_nodes_by_type(self): 38 | """WaffleNode.get_nodes_by_type() should correctly find all child nodes""" 39 | test_template = Template('{% load waffle_tags %}{% switch "x" %}{{ a }}{% else %}{{ b }}{% endswitch %}') 40 | children = test_template.nodelist.get_nodes_by_type(VariableNode) 41 | self.assertEqual(len(children), 2) 42 | 43 | def test_no_request_context(self): 44 | """Switches and Samples shouldn't require a request context.""" 45 | request = get() 46 | content = process_request(request, views.no_request_context) 47 | assert 'switch off' in content 48 | assert 'sample' in content 49 | 50 | def test_jinja_tags(self): 51 | request = get() 52 | response = process_request(request, views.flag_in_jingo) 53 | self.assertContains(response, 'flag off') 54 | self.assertContains(response, 'switch off') 55 | self.assertContains(response, 'sample') 56 | self.assertContains(response, 'window.waffle =') 57 | -------------------------------------------------------------------------------- /test_app/views.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | from django.shortcuts import render_to_response, render 3 | from django.template import Context, RequestContext 4 | from django.template.loader import render_to_string 5 | 6 | from waffle import flag_is_active 7 | from waffle.decorators import waffle_flag, waffle_switch 8 | 9 | 10 | def flag_in_view(request): 11 | if flag_is_active(request, 'myflag'): 12 | return HttpResponse('on') 13 | return HttpResponse('off') 14 | 15 | 16 | def flag_in_jingo(request): 17 | return render(request, 'jingo/jingo.html') 18 | 19 | 20 | def flag_in_django(request): 21 | c = RequestContext(request, { 22 | 'flag_var': 'flag_var', 23 | 'switch_var': 'switch_var', 24 | 'sample_var': 'sample_var', 25 | }) 26 | return render_to_response('django/django.html', context_instance=c) 27 | 28 | 29 | def no_request_context(request): 30 | c = Context({}) 31 | return render_to_string('django/django_email.html', context_instance=c) 32 | 33 | 34 | @waffle_switch('foo') 35 | def switched_view(request): 36 | return HttpResponse('foo') 37 | 38 | 39 | @waffle_switch('!foo') 40 | def switched_off_view(request): 41 | return HttpResponse('foo') 42 | 43 | 44 | @waffle_flag('foo') 45 | def flagged_view(request): 46 | return HttpResponse('foo') 47 | 48 | 49 | @waffle_flag('!foo') 50 | def flagged_off_view(request): 51 | return HttpResponse('foo') 52 | 53 | 54 | def foo_view(request): 55 | return HttpResponse('redirected') 56 | 57 | 58 | @waffle_switch('foo', redirect_to=foo_view) 59 | def switched_view_with_valid_redirect(request): 60 | return HttpResponse('foo') 61 | 62 | 63 | @waffle_switch('foo', redirect_to='foo_view') 64 | def switched_view_with_valid_url_name(request): 65 | return HttpResponse('foo') 66 | 67 | 68 | @waffle_switch('foo', redirect_to='invalid_view') 69 | def switched_view_with_invalid_redirect(request): 70 | return HttpResponse('foo') 71 | 72 | 73 | @waffle_flag('foo', redirect_to=foo_view) 74 | def flagged_view_with_valid_redirect(request): 75 | return HttpResponse('foo') 76 | 77 | 78 | @waffle_flag('foo', redirect_to='foo_view') 79 | def flagged_view_with_valid_url_name(request): 80 | return HttpResponse('foo') 81 | 82 | 83 | @waffle_flag('foo', redirect_to='invalid_view') 84 | def flagged_view_with_invalid_redirect(request): 85 | return HttpResponse('foo') 86 | -------------------------------------------------------------------------------- /waffle/tests/test_views.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.core.urlresolvers import reverse 4 | 5 | from waffle.models import Flag, Sample, Switch 6 | from waffle.tests.base import TestCase 7 | 8 | 9 | class WaffleViewTests(TestCase): 10 | def test_wafflejs(self): 11 | response = self.client.get(reverse('wafflejs')) 12 | self.assertEqual(200, response.status_code) 13 | self.assertEqual('application/x-javascript', response['content-type']) 14 | self.assertEqual('max-age=0', response['cache-control']) 15 | 16 | def test_flush_all_flags(self): 17 | """Test the 'FLAGS_ALL' list gets invalidated correctly.""" 18 | Flag.objects.create(name='myflag1', everyone=True) 19 | response = self.client.get(reverse('wafflejs')) 20 | self.assertEqual(200, response.status_code) 21 | assert ('myflag1', True) in response.context['flags'] 22 | 23 | Flag.objects.create(name='myflag2', everyone=True) 24 | 25 | response = self.client.get(reverse('wafflejs')) 26 | self.assertEqual(200, response.status_code) 27 | assert ('myflag2', True) in response.context['flags'] 28 | 29 | def test_flush_all_switches(self): 30 | """Test the 'SWITCHES_ALL' list gets invalidated correctly.""" 31 | switch = Switch.objects.create(name='myswitch', active=True) 32 | response = self.client.get(reverse('wafflejs')) 33 | self.assertEqual(200, response.status_code) 34 | assert ('myswitch', True) in response.context['switches'] 35 | 36 | switch.active = False 37 | switch.save() 38 | response = self.client.get(reverse('wafflejs')) 39 | self.assertEqual(200, response.status_code) 40 | assert ('myswitch', False) in response.context['switches'] 41 | 42 | def test_flush_all_samples(self): 43 | """Test the 'SAMPLES_ALL' list gets invalidated correctly.""" 44 | Sample.objects.create(name='sample1', percent='100.0') 45 | response = self.client.get(reverse('wafflejs')) 46 | self.assertEqual(200, response.status_code) 47 | assert ('sample1', True) in response.context['samples'] 48 | 49 | Sample.objects.create(name='sample2', percent='100.0') 50 | 51 | response = self.client.get(reverse('wafflejs')) 52 | self.assertEqual(200, response.status_code) 53 | assert ('sample2', True) in response.context['samples'] 54 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. _about-contributing: 2 | .. highlight:: shell 3 | 4 | ====================== 5 | Contributing to Waffle 6 | ====================== 7 | 8 | Waffle is pretty simple to hack, and has a decent test suite! Here's how 9 | to patch Waffle, add tests, run them, and contribute changes. 10 | 11 | **Please** `open a new issue`_ to discuss a new feature before beginning 12 | work on it. Not all suggestions are accepted. The :ref:`Goals 13 | ` may help guide which features are likely to be accepted. 14 | 15 | 16 | Set Up 17 | ====== 18 | 19 | Setting up an environment is easy! You'll want ``virtualenv`` and 20 | ``pip``, then just create a new virtual environment and install the 21 | requirements:: 22 | 23 | $ mkvirtualenv waffle 24 | $ pip install -r requirements.txt 25 | 26 | Done! 27 | 28 | 29 | Writing Patches 30 | =============== 31 | 32 | Fork_ Waffle and create a new branch off master for your patch. Run the 33 | tests often:: 34 | 35 | $ ./run.sh test 36 | 37 | Try to keep each branch to a single feature or bugfix. 38 | 39 | .. note:: 40 | 41 | To update branches, please **rebase** onto master, do not merge 42 | master into your branch. 43 | 44 | 45 | Submitting Patches 46 | ================== 47 | 48 | Open a pull request on GitHub! 49 | 50 | Before a pull request gets merged, it should be **rebased** onto master 51 | and squashed into a minimal set of commits. Each commit should include 52 | the necessary code, test, and documentation changes for a single "piece" 53 | of functionality. 54 | 55 | To be mergable, patches must: 56 | 57 | - be rebased onto the latest master, 58 | - be automatically mergeable, 59 | - not break existing tests (TravisCI_ will run them, too), 60 | - not change existing tests without a *very* good reason, 61 | - add tests for new code (bug fixes should include regression tests, new 62 | features should have relevant tests), 63 | - not introduce any new flake8_ errors (run ``./run.sh lint``), 64 | - document any new features, and 65 | - have a `good commit message`_. 66 | 67 | Regressions tests should fail without the rest of the patch and pass 68 | with it. 69 | 70 | 71 | .. _open a new issue: https://github.com/jsocol/django-waffle/issues/new 72 | .. _Fork: https://github.com/jsocol/django-waffle/fork 73 | .. _TravisCI: https://travis-ci.org/jsocol/django-waffle 74 | .. _flake8: https://pypi.python.org/pypi/flake8 75 | .. _good commit message: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html 76 | -------------------------------------------------------------------------------- /docs/about/contributing.rst: -------------------------------------------------------------------------------- 1 | .. _about-contributing: 2 | .. highlight:: shell 3 | 4 | ====================== 5 | Contributing to Waffle 6 | ====================== 7 | 8 | Waffle is pretty simple to hack, and has a decent test suite! Here's how 9 | to patch Waffle, add tests, run them, and contribute changes. 10 | 11 | **Please** `open a new issue`_ to discuss a new feature before beginning 12 | work on it. Not all suggestions are accepted. The :ref:`Goals 13 | ` may help guide which features are likely to be accepted. 14 | 15 | 16 | Set Up 17 | ====== 18 | 19 | Setting up an environment is easy! You'll want ``virtualenv`` and 20 | ``pip``, then just create a new virtual environment and install the 21 | requirements:: 22 | 23 | $ mkvirtualenv waffle 24 | $ pip install -r requirements.txt 25 | 26 | Done! 27 | 28 | 29 | Writing Patches 30 | =============== 31 | 32 | Fork_ Waffle and create a new branch off master for your patch. Run the 33 | tests often:: 34 | 35 | $ ./run.sh test 36 | 37 | Try to keep each branch to a single feature or bugfix. 38 | 39 | .. note:: 40 | 41 | To update branches, please **rebase** onto master, do not merge 42 | master into your branch. 43 | 44 | 45 | Submitting Patches 46 | ================== 47 | 48 | Open a pull request on GitHub! 49 | 50 | Before a pull request gets merged, it should be **rebased** onto master 51 | and squashed into a minimal set of commits. Each commit should include 52 | the necessary code, test, and documentation changes for a single "piece" 53 | of functionality. 54 | 55 | To be mergable, patches must: 56 | 57 | - be rebased onto the latest master, 58 | - be automatically mergeable, 59 | - not break existing tests (TravisCI_ will run them, too), 60 | - not change existing tests without a *very* good reason, 61 | - add tests for new code (bug fixes should include regression tests, new 62 | features should have relevant tests), 63 | - not introduce any new flake8_ errors (run ``./run.sh lint``), 64 | - document any new features, and 65 | - have a `good commit message`_. 66 | 67 | Regressions tests should fail without the rest of the patch and pass 68 | with it. 69 | 70 | 71 | .. _open a new issue: https://github.com/jsocol/django-waffle/issues/new 72 | .. _Fork: https://github.com/jsocol/django-waffle/fork 73 | .. _TravisCI: https://travis-ci.org/jsocol/django-waffle 74 | .. _flake8: https://pypi.python.org/pypi/flake8 75 | .. _good commit message: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html 76 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py2.6-django1.4, 3 | py2.7-django1.4, 4 | py2.6-django1.5, 5 | py2.7-django1.5, 6 | py3.2-django1.5, 7 | py3.3-django1.5, 8 | py3.4-django1.5, 9 | py2.6-django1.6, 10 | py2.7-django1.6, 11 | py3.2-django1.6, 12 | py3.3-django1.6, 13 | py3.4-django1.6, 14 | py2.7-django1.7, 15 | py3.2-django1.7, 16 | py3.3-django1.7, 17 | py3.4-django1.7, 18 | 19 | [testenv] 20 | commands=./run.sh test 21 | 22 | [testenv:py2.6-django1.4] 23 | basepython = python2.6 24 | deps = Django>=1.4,<1.5 25 | -rtravis.txt 26 | 27 | [testenv:py2.7-django1.4] 28 | basepython = python2.7 29 | deps = Django>=1.4,<1.5 30 | -rtravis.txt 31 | 32 | [testenv:py2.6-django1.5] 33 | basepython = python2.6 34 | deps = Django>=1.5,<1.6 35 | -rtravis.txt 36 | 37 | [testenv:py2.7-django1.5] 38 | basepython = python2.7 39 | deps = Django>=1.5,<1.6 40 | -rtravis.txt 41 | 42 | [testenv:py3.2-django1.5] 43 | basepython = python3.2 44 | deps = Django>=1.5,<1.6 45 | -rtravis.txt 46 | 47 | [testenv:py3.3-django1.5] 48 | basepython = python3.3 49 | deps = Django>=1.5,<1.6 50 | -rtravis.txt 51 | 52 | [testenv:py3.4-django1.5] 53 | basepython = python3.4 54 | deps = Django>=1.5,<1.6 55 | -rtravis.txt 56 | 57 | [testenv:py2.6-django1.6] 58 | basepython = python2.6 59 | deps = Django>=1.6,<1.7 60 | -rtravis.txt 61 | 62 | [testenv:py2.7-django1.6] 63 | basepython = python2.7 64 | deps = Django>=1.6,<1.7 65 | -rtravis.txt 66 | 67 | [testenv:py3.2-django1.6] 68 | basepython = python3.2 69 | deps = Django>=1.6,<1.7 70 | -rtravis.txt 71 | 72 | [testenv:py3.3-django1.6] 73 | basepython = python3.3 74 | deps = Django>=1.6,<1.7 75 | -rtravis.txt 76 | 77 | [testenv:py3.4-django1.6] 78 | basepython = python3.4 79 | deps = Django>=1.6,<1.7 80 | -rtravis.txt 81 | 82 | [testenv:py2.7-django1.7] 83 | basepython = python2.7 84 | deps = Django>=1.7,<1.8 85 | -rtravis.txt 86 | 87 | [testenv:py3.2-django1.7] 88 | basepython = python3.2 89 | deps = Django>=1.7,<1.8 90 | -rtravis.txt 91 | 92 | [testenv:py3.3-django1.7] 93 | basepython = python3.3 94 | deps = Django>=1.7,<1.8 95 | -rtravis.txt 96 | 97 | [testenv:py3.4-django1.7] 98 | basepython = python3.4 99 | deps = Django>=1.7,<1.8 100 | -rtravis.txt 101 | -------------------------------------------------------------------------------- /docs/testing/automated.rst: -------------------------------------------------------------------------------- 1 | .. _testing-automated: 2 | 3 | ============================= 4 | Automated testing with Waffle 5 | ============================= 6 | 7 | Feature flags present a new challenge for writing tests. The test 8 | database may not have Flags, Switches, or Samples defined, or they may 9 | be non-deterministic. 10 | 11 | My philosophy, and one I encourage you to adopt, is that tests should 12 | cover *both* code paths, with any feature flags on and off. To do 13 | this, you'll need to make the code behave deterministically. 14 | 15 | Here, I'll cover some tips and best practices for testing your app 16 | while using feature flags. I'll talk specifically about Flags but this 17 | can equally apply to Switches or Samples. 18 | 19 | 20 | Unit tests 21 | ========== 22 | 23 | Waffle provides three context managers (that can also be used as 24 | decorators) in ``waffle.testutils`` that make testing easier. 25 | 26 | - ``override_flag`` 27 | - ``override_sample`` 28 | - ``override_switch`` 29 | 30 | All three are used the same way:: 31 | 32 | with override_flag('flag_name', active=True): 33 | # Only 'flag_name' is affected, other flags behave normally. 34 | assert waffle.flag_is_active(request, 'flag_name') 35 | 36 | Or:: 37 | 38 | @override_sample('sample_name', active=True) 39 | def test_with_sample(): 40 | # Only 'sample_name' is affected, and will always be True. Other 41 | # samples behave normally. 42 | assert waffle.sample_is_active('sample_name') 43 | 44 | All three will restore the relevant flag, sample, or switch to its 45 | previous state: they will restore the old values and will delete objects 46 | that did not exist. 47 | 48 | 49 | External test suites 50 | ==================== 51 | 52 | Tests that run in a separate process, such as Selenium tests, may not 53 | have access to the test database or the ability to mock Waffle values. 54 | 55 | For tests that make HTTP requests to the system-under-test (e.g. with 56 | Selenium_ or PhantomJS_) the ``WAFFLE_OVERRIDE`` :ref:`setting 57 | ` makes it possible to control the value of any 58 | *Flag* via the querystring. 59 | 60 | .. highlight:: http 61 | 62 | For example, for a flag named ``foo``, we can ensure that it is "on" for 63 | a request:: 64 | 65 | GET /testpage?foo=1 HTTP/1.1 66 | 67 | or that it is "off":: 68 | 69 | GET /testpage?foo=0 HTTP/1.1 70 | 71 | 72 | .. _mock: http://pypi.python.org/pypi/mock/ 73 | .. _fudge: http://farmdev.com/projects/fudge/ 74 | .. _Selenium: http://www.seleniumhq.org/ 75 | .. _PhantomJS: http://phantomjs.org/ 76 | -------------------------------------------------------------------------------- /docs/usage/templates.rst: -------------------------------------------------------------------------------- 1 | .. _usage-templates: 2 | .. highlight:: django 3 | 4 | ========================= 5 | Using Waffle in templates 6 | ========================= 7 | 8 | Waffle makes it easy to test :ref:`flags `, :ref:`switches 9 | `, and :ref:`samples ` in templates to flip 10 | features on the front-end. It includes support for both Django's 11 | built-in templates and for Jinja2_ via jingo_. 12 | 13 | .. warning:: 14 | 15 | Before using samples in templates, see the warning in the 16 | :ref:`Sample chapter `. 17 | 18 | 19 | .. _templates-django: 20 | 21 | Django Templates 22 | ================ 23 | 24 | Load the ``waffle_tags`` template tags:: 25 | 26 | {% load waffle_tags %} 27 | 28 | In Django templates, Waffle provides three new block types, ``flag``, 29 | ``switch``, and ``sample``, that function like ``if`` blocks. Each block 30 | supports an optional ``else`` to be rendered if the flag, switch, or 31 | sample in inactive. 32 | 33 | 34 | Flags 35 | ----- 36 | 37 | :: 38 | 39 | {% flag "flag_name" %} 40 | flag_name is active! 41 | {% else %} 42 | flag_name is inactive 43 | {% endflag %} 44 | 45 | 46 | Switches 47 | -------- 48 | 49 | :: 50 | 51 | {% switch "switch_name" %} 52 | switch_name is active! 53 | {% else %} 54 | switch_name is inactive 55 | {% endswitch %} 56 | 57 | 58 | Samples 59 | ------- 60 | 61 | :: 62 | 63 | {% sample "sample_name" %} 64 | sample_name is active! 65 | {% else %} 66 | sample_name is inactive 67 | {% endsample %} 68 | 69 | 70 | .. _templates-jinja: 71 | 72 | Jinja Templates 73 | =============== 74 | 75 | When used with jingo_, Waffle provides a ``waffle`` object in the Jinja 76 | template context that can be used with normal ``if`` statements. Because 77 | these are normal ``if`` statements, you can use ``else`` or ``if not`` 78 | as normal. 79 | 80 | 81 | Flags 82 | ----- 83 | 84 | :: 85 | 86 | {% if waffle.flag('flag_name') %} 87 | flag_name is active! 88 | {% endif %} 89 | 90 | 91 | Switches 92 | -------- 93 | 94 | :: 95 | 96 | {% if waffle.switch('switch_name') %} 97 | switch_name is active! 98 | {% endif %} 99 | 100 | 101 | Samples 102 | ------- 103 | 104 | :: 105 | 106 | {% if waffle.sample('sample_name') %} 107 | sample_name is active! 108 | {% endif %} 109 | 110 | 111 | .. _Jinja2: http://jinja.pocoo.org/ 112 | .. _jingo: http://github.com/jbalogh/jingo 113 | -------------------------------------------------------------------------------- /waffle/templatetags/waffle_tags.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django import template 4 | from django.template.base import VariableDoesNotExist 5 | 6 | from waffle import flag_is_active, sample_is_active, switch_is_active 7 | from waffle.views import _generate_waffle_js 8 | 9 | 10 | register = template.Library() 11 | 12 | 13 | class WaffleNode(template.Node): 14 | child_nodelists = ('nodelist_true', 'nodelist_false') 15 | 16 | def __init__(self, nodelist_true, nodelist_false, condition, name, 17 | compiled_name): 18 | self.nodelist_true = nodelist_true 19 | self.nodelist_false = nodelist_false 20 | self.condition = condition 21 | self.name = name 22 | self.compiled_name = compiled_name 23 | 24 | def __repr__(self): 25 | return '' % self.name 26 | 27 | def __iter__(self): 28 | for node in self.nodelist_true: 29 | yield node 30 | for node in self.nodelist_false: 31 | yield node 32 | 33 | def render(self, context): 34 | try: 35 | name = self.compiled_name.resolve(context) 36 | except VariableDoesNotExist: 37 | name = self.name 38 | if not name: 39 | name = self.name 40 | if self.condition(context.get('request', None), name): 41 | return self.nodelist_true.render(context) 42 | return self.nodelist_false.render(context) 43 | 44 | @classmethod 45 | def handle_token(cls, parser, token, kind, condition): 46 | bits = token.split_contents() 47 | if len(bits) < 2: 48 | raise template.TemplateSyntaxError("%r tag requires an argument" % 49 | bits[0]) 50 | 51 | name = bits[1] 52 | compiled_name = parser.compile_filter(name) 53 | 54 | nodelist_true = parser.parse(('else', 'end%s' % kind)) 55 | token = parser.next_token() 56 | if token.contents == 'else': 57 | nodelist_false = parser.parse(('end%s' % kind,)) 58 | parser.delete_first_token() 59 | else: 60 | nodelist_false = template.NodeList() 61 | 62 | return cls(nodelist_true, nodelist_false, condition, 63 | name, compiled_name) 64 | 65 | 66 | @register.tag 67 | def flag(parser, token): 68 | return WaffleNode.handle_token(parser, token, 'flag', flag_is_active) 69 | 70 | 71 | @register.tag 72 | def switch(parser, token): 73 | condition = lambda request, name: switch_is_active(name) 74 | return WaffleNode.handle_token(parser, token, 'switch', condition) 75 | 76 | 77 | @register.tag 78 | def sample(parser, token): 79 | condition = lambda request, name: sample_is_active(name) 80 | return WaffleNode.handle_token(parser, token, 'sample', condition) 81 | 82 | 83 | class InlineWaffleJSNode(template.Node): 84 | def render(self, context): 85 | return _generate_waffle_js(context['request']) 86 | 87 | 88 | @register.tag 89 | def wafflejs(parser, token): 90 | return InlineWaffleJSNode() 91 | -------------------------------------------------------------------------------- /test_settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | import django 3 | 4 | # Make filepaths relative to settings. 5 | ROOT = os.path.dirname(os.path.abspath(__file__)) 6 | path = lambda *a: os.path.join(ROOT, *a) 7 | 8 | DEBUG = True 9 | TEMPLATE_DEBUG = True 10 | 11 | if django.VERSION < (1, 6): 12 | TEST_RUNNER = 'discover_runner.DiscoverRunner' 13 | else: 14 | TEST_RUNNER = 'django.test.runner.DiscoverRunner' 15 | 16 | JINJA_CONFIG = {} 17 | 18 | SITE_ID = 1 19 | USE_I18N = False 20 | 21 | SECRET_KEY = 'foobar' 22 | 23 | DATABASES = { 24 | 'default': { 25 | 'NAME': 'test.db', 26 | 'ENGINE': 'django.db.backends.sqlite3', 27 | } 28 | } 29 | 30 | INSTALLED_APPS = ( 31 | 'django.contrib.admin', 32 | 'django.contrib.auth', 33 | 'django.contrib.contenttypes', 34 | 'django.contrib.sessions', 35 | 'django.contrib.sites', 36 | 'waffle', 37 | 'test_app', 38 | ) 39 | 40 | MIDDLEWARE_CLASSES = ( 41 | 'django.middleware.common.CommonMiddleware', 42 | 'django.contrib.sessions.middleware.SessionMiddleware', 43 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 44 | 'waffle.middleware.WaffleMiddleware', 45 | ) 46 | 47 | ROOT_URLCONF = 'test_app.urls' 48 | 49 | _CONTEXT_PROCESSORS = ( 50 | 'django.contrib.auth.context_processors.auth', 51 | 'django.core.context_processors.request', 52 | ) 53 | 54 | if django.VERSION < (1, 8): 55 | TEMPLATE_CONTEXT_PROCESSORS = _CONTEXT_PROCESSORS 56 | 57 | TEMPLATE_LOADERS = ( 58 | 'jingo.Loader', 59 | 'django.template.loaders.filesystem.Loader', 60 | 'django.template.loaders.app_directories.Loader', 61 | ) 62 | 63 | JINGO_EXCLUDE_APPS = ( 64 | 'django', 65 | 'waffle', 66 | ) 67 | 68 | JINJA_CONFIG = { 69 | 'extensions': [ 70 | 'jinja2.ext.autoescape', 71 | 'waffle.jinja.WaffleExtension', 72 | ], 73 | } 74 | 75 | else: 76 | TEMPLATES = [ 77 | { 78 | 'BACKEND': 'django_jinja.backend.Jinja2', 79 | 'DIRS': [], 80 | 'APP_DIRS': True, 81 | 'OPTIONS': { 82 | 'match_regex': r'jingo.*', 83 | 'match_extension': '', 84 | 'newstyle_gettext': True, 85 | 'context_processors': _CONTEXT_PROCESSORS, 86 | 'undefined': 'jinja2.Undefined', 87 | 'extensions': [ 88 | 'jinja2.ext.i18n', 89 | 'jinja2.ext.autoescape', 90 | 'waffle.jinja.WaffleExtension', 91 | ], 92 | } 93 | }, 94 | { 95 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 96 | 'DIRS': [], 97 | 'APP_DIRS': True, 98 | 'OPTIONS': { 99 | 'debug': DEBUG, 100 | 'context_processors': _CONTEXT_PROCESSORS, 101 | } 102 | }, 103 | ] 104 | 105 | WAFFLE_FLAG_DEFAULT = False 106 | WAFFLE_SWITCH_DEFAULT = False 107 | WAFFLE_SAMPLE_DEFAULT = False 108 | WAFFLE_OVERRIDE = False 109 | WAFFLE_CACHE_PREFIX = 'test:' 110 | 111 | if django.VERSION < (1, 7): 112 | INSTALLED_APPS += ('south', ) 113 | 114 | SOUTH_MIGRATION_MODULES = { 115 | 'waffle': 'waffle.south_migrations' 116 | } 117 | -------------------------------------------------------------------------------- /CHANGES: -------------------------------------------------------------------------------- 1 | ================ 2 | Waffle Changelog 3 | ================ 4 | 5 | v0.11 6 | ===== 7 | 8 | - Support Django 1.8. 9 | - Move from jingo-specific to generic Jinja2 template support. 10 | - Added tools for integration testing. 11 | - Drop Django 1.5 support. 12 | - Fix several code and documentation bugs. 13 | - Add optional redirect parameter to view decorators. 14 | 15 | 16 | v0.10.2 17 | ======= 18 | 19 | - Overhaul documentation. 20 | - Move CLI commands to waffle_(flag|sample|switch) to be more polite. 21 | - Add override_(flag|sample|switch) testing tools. 22 | - Changed the default of WAFFLE_SECURE to True. 23 | 24 | 25 | v0.10.1 26 | ======= 27 | 28 | - Support Python 3. 29 | - Support Django 1.7. 30 | - Add WAFFLE_CACHE_NAME. 31 | - Fix caching for empty lists. 32 | 33 | 34 | v0.10.0 35 | ======= 36 | 37 | - Replace waffle.get_flags with waffle.{FLAGS,SWITCHES,SAMPLES} in JS. 38 | - Update Custom User Models for Django 1.6 support. 39 | - Support WaffleJS inline in templates. 40 | - Improve test infrastructure and coverage. 41 | 42 | 43 | v0.9.2 44 | ====== 45 | 46 | - Add get_flags method to waffle.js. 47 | - Fix issue with South migrations and custom user models in Django 1.5. 48 | - Document command-line access and get more useful information from it. 49 | - Support non-naive datetimes when appropriate. 50 | - Fix a cache invalidation issue. 51 | 52 | 53 | v0.9.1 54 | ====== 55 | 56 | - Real Django 1.5 support. 57 | - JavaScript obeys WAFFLE_*_DEFAULT settings. 58 | 59 | v0.9 60 | ==== 61 | 62 | - Reorganized documentation. 63 | - Hash form values for better memcached keys. 64 | - Simplified and improved Django template tags. 65 | - Renamed JS functions to *_is_active to avoid reserved keywords. 66 | 67 | v0.8.1 68 | ====== 69 | 70 | - Fix cache flushing issues. 71 | - Fix order of flag_is_active checks. 72 | - Add a waffle.urls module. 73 | - Add management commands. 74 | - Add language support to flags. 75 | - Better caching for missing flags/switches/samples. 76 | - Re-add 'note' field. 77 | - Created a set_flag method to make custom flag cookie triggers easier. 78 | 79 | v0.8 80 | ==== 81 | 82 | - Fix issue with repeated flag_is_active calls. 83 | - Add created/modified dates to models. 84 | - Add WAFFLE_CACHE_PREFIX settings. 85 | 86 | v0.7.6 87 | ====== 88 | 89 | - Fix waffle template functions when no request is present. 90 | - Added testing mode to flags. 91 | - Add WAFFLE_*_DEFAULT for switches and samples. 92 | 93 | v0.7.5 94 | ====== 95 | 96 | - Fix issue with stale cache using bulk admin actions. 97 | 98 | v0.7.4 99 | ====== 100 | 101 | - Fix waffle.js in Safari. 102 | 103 | v0.7.2 104 | ====== 105 | 106 | - Handle 404s correctly. 107 | 108 | v0.7.1 109 | ====== 110 | 111 | - I am bad at packaging. 112 | 113 | v0.7 114 | ==== 115 | 116 | - Add 'note' field. 117 | - Add migrations for Samples. 118 | - Clean up Jinja2 functions. 119 | 120 | v0.6 121 | ==== 122 | 123 | - Add Samples. 124 | 125 | v0.5 126 | ==== 127 | 128 | - Fix waffle.js view with Switches. 129 | - Add South migrations. 130 | - Cache values to save database queries. 131 | 132 | v0.4 133 | ==== 134 | 135 | - Add Switches. 136 | 137 | v0.3 138 | ==== 139 | 140 | - Add waffle.js view. 141 | 142 | v0.2.1 143 | ====== 144 | 145 | - Add bulk admin actions. 146 | 147 | v0.2 148 | ==== 149 | 150 | - Add rollout mode to Flags. 151 | -------------------------------------------------------------------------------- /docs/starting/installation.rst: -------------------------------------------------------------------------------- 1 | .. _starting-installation: 2 | 3 | ============ 4 | Installation 5 | ============ 6 | 7 | After ensuring that the :ref:`requirements ` are 8 | met, installing Waffle is a simple process. 9 | 10 | 11 | Getting Waffle 12 | ============== 13 | 14 | Waffle is `hosted on PyPI`_ and can be installed with ``pip`` or 15 | ``easy_install``: 16 | 17 | .. code-block:: shell 18 | 19 | $ pip install django-waffle 20 | $ easy_install django-waffle 21 | 22 | Waffle is also available `on GitHub`_. In general, ``master`` should be 23 | stable, but use caution depending on unreleased versions. 24 | 25 | .. _hosted on PyPI: http://pypi.python.org/pypi/django-waffle 26 | .. _on GitHub: https://github.com/jsocol/django-waffle 27 | 28 | 29 | .. _installation-settings: 30 | 31 | Settings 32 | ======== 33 | 34 | Add ``waffle`` to the ``INSTALLED_APPS`` setting, and 35 | ``waffle.middleware.WaffleMiddleware`` to ``MIDDLEWARE_CLASSES``, e.g.:: 36 | 37 | INSTALLED_APPS = ( 38 | # ... 39 | 'waffle', 40 | # ... 41 | ) 42 | 43 | MIDDLEWARE_CLASSES = ( 44 | # ... 45 | 'waffle.middleware.WaffleMiddleware', 46 | # ... 47 | ) 48 | 49 | 50 | .. _installation-settings-templates: 51 | 52 | Jinja Templates 53 | --------------- 54 | 55 | .. versionchanged:: 0.11 56 | 57 | If you're using Jinja2 templates, Waffle provides a Jinja2 extension 58 | (``waffle.jinja.WaffleExtension``) to :ref:`use Waffle directly from 59 | templates `. How you install this depends on which 60 | adapter you're using. 61 | 62 | With django-jinja_, add the extension to the ``extensions`` list:: 63 | 64 | TEMPLATES = [ 65 | { 66 | 'BACKEND': 'django_jinja.backend.Jinja2', 67 | 'OPTIONS': { 68 | 'extensions': [ 69 | # ... 70 | 'waffle.jinja.WaffleExtension', 71 | ], 72 | # ... 73 | }, 74 | # ... 75 | }, 76 | # ... 77 | ] 78 | 79 | With jingo_, add it to the ``JINJA_CONFIG['extensions']`` list:: 80 | 81 | JINJA_CONFIG = { 82 | 'extensions': [ 83 | # ... 84 | 'waffle.jinja.WaffleExtension', 85 | ], 86 | # ... 87 | } 88 | 89 | 90 | .. _installation-settings-south: 91 | 92 | South Migrations 93 | ---------------- 94 | 95 | If you're using South_ for database migrations, you'll need to add 96 | Waffle to the ``SOUTH_MIGRATION_MODULES`` setting, as well:: 97 | 98 | SOUTH_MIGRATION_MODULES = { 99 | # ... 100 | 'waffle': 'waffle.south_migrations', 101 | # ... 102 | } 103 | 104 | 105 | Database Schema 106 | =============== 107 | 108 | Waffle includes both South_ migrations and `Django migrations`_ for 109 | creating the correct database schema. If using South or Django >= 1.7, 110 | simply run the ``migrate`` management command after adding Waffle to 111 | ``INSTALLED_APPS``: 112 | 113 | .. code-block:: shell 114 | 115 | $ django-admin.py migrate 116 | 117 | If you're using a version of Django without migrations, you can run 118 | ``syncdb`` to create the Waffle tables. 119 | 120 | .. _South: http://south.aeracode.org/ 121 | .. _Django migrations: https://docs.djangoproject.com/en/dev/topics/migrations/ 122 | .. _django-jinja: https://pypi.python.org/pypi/django-jinja/ 123 | .. _jingo: http://jingo.readthedocs.org/ 124 | -------------------------------------------------------------------------------- /waffle/tests/test_decorators.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from waffle.models import Flag, Switch 4 | from waffle.tests.base import TestCase 5 | 6 | 7 | class DecoratorTests(TestCase): 8 | def test_flag_must_be_active(self): 9 | resp = self.client.get('/flag-on') 10 | self.assertEqual(404, resp.status_code) 11 | Flag.objects.create(name='foo', everyone=True) 12 | resp = self.client.get('/flag-on') 13 | self.assertEqual(200, resp.status_code) 14 | 15 | def test_flag_must_be_inactive(self): 16 | resp = self.client.get('/flag-off') 17 | self.assertEqual(200, resp.status_code) 18 | Flag.objects.create(name='foo', everyone=True) 19 | resp = self.client.get('/flag-off') 20 | self.assertEqual(404, resp.status_code) 21 | 22 | def test_switch_must_be_active(self): 23 | resp = self.client.get('/switch-on') 24 | self.assertEqual(404, resp.status_code) 25 | Switch.objects.create(name='foo', active=True) 26 | resp = self.client.get('/switch-on') 27 | self.assertEqual(200, resp.status_code) 28 | 29 | def test_switch_must_be_inactive(self): 30 | resp = self.client.get('/switch-off') 31 | self.assertEqual(200, resp.status_code) 32 | Switch.objects.create(name='foo', active=True) 33 | resp = self.client.get('/switch-off') 34 | self.assertEqual(404, resp.status_code) 35 | 36 | def test_switch_must_be_inactive_and_redirect_to_view(self): 37 | resp = self.client.get('/switched_view_with_valid_redirect') 38 | self.assertEqual(302, resp.status_code) 39 | Switch.objects.create(name='foo', active=True) 40 | resp = self.client.get('/switched_view_with_valid_redirect') 41 | self.assertEqual(200, resp.status_code) 42 | 43 | def test_switch_must_be_inactive_and_redirect_to_named_view(self): 44 | resp = self.client.get('/switched_view_with_valid_url_name') 45 | self.assertEqual(302, resp.status_code) 46 | Switch.objects.create(name='foo', active=True) 47 | resp = self.client.get('/switched_view_with_valid_url_name') 48 | self.assertEqual(200, resp.status_code) 49 | 50 | def test_switch_must_be_inactive_and_not_redirect(self): 51 | resp = self.client.get('/switched_view_with_invalid_redirect') 52 | self.assertEqual(404, resp.status_code) 53 | Switch.objects.create(name='foo', active=True) 54 | resp = self.client.get('/switched_view_with_invalid_redirect') 55 | self.assertEqual(200, resp.status_code) 56 | 57 | def test_flag_must_be_inactive_and_redirect_to_view(self): 58 | resp = self.client.get('/flagged_view_with_valid_redirect') 59 | self.assertEqual(302, resp.status_code) 60 | Flag.objects.create(name='foo', everyone=True) 61 | resp = self.client.get('/flagged_view_with_valid_redirect') 62 | self.assertEqual(200, resp.status_code) 63 | 64 | def test_flag_must_be_inactive_and_redirect_to_named_view(self): 65 | resp = self.client.get('/flagged_view_with_valid_url_name') 66 | self.assertEqual(302, resp.status_code) 67 | Flag.objects.create(name='foo', everyone=True) 68 | resp = self.client.get('/flagged_view_with_valid_url_name') 69 | self.assertEqual(200, resp.status_code) 70 | 71 | def test_flag_must_be_inactive_and_not_redirect(self): 72 | resp = self.client.get('/flagged_view_with_invalid_redirect') 73 | self.assertEqual(404, resp.status_code) 74 | Flag.objects.create(name='foo', everyone=True) 75 | resp = self.client.get('/flagged_view_with_invalid_redirect') 76 | self.assertEqual(200, resp.status_code) 77 | -------------------------------------------------------------------------------- /waffle/management/commands/waffle_flag.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | from optparse import make_option 4 | 5 | from django.core.management.base import BaseCommand, CommandError 6 | 7 | from waffle.models import Flag 8 | 9 | 10 | class Command(BaseCommand): 11 | option_list = BaseCommand.option_list + ( 12 | make_option('-l', '--list', 13 | action='store_true', 14 | dest='list_flag', 15 | default=False, 16 | help="List existing samples."), 17 | make_option('--everyone', 18 | action='store_true', 19 | dest='everyone', 20 | help="Activate flag for all users."), 21 | make_option('--deactivate', 22 | action='store_false', 23 | dest='everyone', 24 | help="Deactivate flag for all users."), 25 | make_option('--percent', '-p', 26 | action='store', 27 | type='int', 28 | dest='percent', 29 | help=('Roll out the flag for a certain percentage of users. ' 30 | 'Takes a number between 0.0 and 100.0')), 31 | make_option('--superusers', 32 | action='store_true', 33 | dest='superusers', 34 | default=False, 35 | help='Turn on the flag for Django superusers.'), 36 | make_option('--staff', 37 | action='store_true', 38 | dest='staff', 39 | default=False, 40 | help='Turn on the flag for Django staff.'), 41 | make_option('--authenticated', 42 | action='store_true', 43 | dest='authenticated', 44 | default=False, 45 | help='Turn on the flag for logged in users.'), 46 | make_option('--rollout', '-r', 47 | action='store_true', 48 | dest='rollout', 49 | default=False, 50 | help='Turn on rollout mode.'), 51 | make_option('--create', 52 | action='store_true', 53 | dest='create', 54 | default=False, 55 | help='If the flag doesn\'t exist, create it.'), 56 | ) 57 | 58 | help = "Modify a flag." 59 | args = "" 60 | 61 | def handle(self, flag_name=None, *args, **options): 62 | list_flag = options['list_flag'] 63 | 64 | if list_flag: 65 | print('Flags:') 66 | for flag in Flag.objects.iterator(): 67 | print('\nNAME: %s' % flag.name) 68 | print('SUPERUSERS: %s' % flag.superusers) 69 | print('EVERYONE: %s' % flag.everyone) 70 | print('AUTHENTICATED: %s' % flag.authenticated) 71 | print('PERCENT: %s' % flag.percent) 72 | print('TESTING: %s' % flag.testing) 73 | print('ROLLOUT: %s' % flag.rollout) 74 | print('STAFF: %s' % flag.staff) 75 | return 76 | 77 | if not flag_name: 78 | raise CommandError("You need to specify a flag name.") 79 | 80 | if options['create']: 81 | flag, created = Flag.objects.get_or_create(name=flag_name) 82 | if created: 83 | print('Creating flag: %s' % flag_name) 84 | else: 85 | try: 86 | flag = Flag.objects.get(name=flag_name) 87 | except Flag.DoesNotExist: 88 | raise CommandError("This flag doesn't exist") 89 | 90 | # Loop through all options, setting Flag attributes that 91 | # match (ie. don't want to try setting flag.verbosity) 92 | for option in options: 93 | if hasattr(flag, option): 94 | print('Setting %s: %s' % (option, options[option])) 95 | setattr(flag, option, options[option]) 96 | 97 | flag.save() 98 | -------------------------------------------------------------------------------- /waffle/testutils.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from functools import wraps 4 | 5 | from waffle.compat import CLASS_TYPES 6 | from waffle.models import Flag, Switch, Sample 7 | 8 | 9 | __all__ = ['override_flag', 'override_sample', 'override_switch'] 10 | 11 | 12 | class _overrider(object): 13 | def __init__(self, name, active): 14 | self.name = name 15 | self.active = active 16 | 17 | def __call__(self, func): 18 | if isinstance(func, CLASS_TYPES): 19 | return self.for_class(func) 20 | else: 21 | return self.for_callable(func) 22 | 23 | def for_class(self, obj): 24 | """Wraps a class's test methods in the decorator""" 25 | for attr in dir(obj): 26 | if not attr.startswith('test_'): 27 | # Ignore non-test functions 28 | continue 29 | 30 | attr_value = getattr(obj, attr) 31 | 32 | if not callable(attr_value): 33 | # Ignore non-functions 34 | continue 35 | 36 | setattr(obj, attr, self.for_callable(attr_value)) 37 | 38 | return obj 39 | 40 | def for_callable(self, func): 41 | """Wraps a method in the decorator""" 42 | @wraps(func) 43 | def _wrapped(*args, **kwargs): 44 | with self: 45 | return func(*args, **kwargs) 46 | 47 | return _wrapped 48 | 49 | def get(self): 50 | self.obj, self.created = self.cls.objects.get_or_create(name=self.name) 51 | 52 | def update(self, active): 53 | raise NotImplementedError 54 | 55 | def get_value(self): 56 | raise NotImplementedError 57 | 58 | def __enter__(self): 59 | self.get() 60 | self.old_value = self.get_value() 61 | if self.old_value != self.active: 62 | self.update(self.active) 63 | 64 | def __exit__(self, exc_type, exc_val, exc_tb): 65 | if self.created: 66 | self.obj.delete() 67 | else: 68 | self.update(self.old_value) 69 | 70 | 71 | class override_switch(_overrider): 72 | """ 73 | override_switch is a contextmanager for easier testing of switches. 74 | 75 | It accepts two parameters, name of the switch and it's state. Example 76 | usage:: 77 | 78 | with override_switch('happy_mode', active=True): 79 | ... 80 | 81 | If `Switch` already existed, it's value would be changed inside the context 82 | block, then restored to the original value. If `Switch` did not exist 83 | before entering the context, it is created, then removed at the end of the 84 | block. 85 | 86 | It can also act as a decorator:: 87 | 88 | @override_switch('happy_mode', active=True) 89 | def test_happy_mode_enabled(): 90 | ... 91 | 92 | """ 93 | cls = Switch 94 | 95 | def update(self, active): 96 | self.cls.objects.filter(pk=self.obj.pk).update(active=active) 97 | 98 | def get_value(self): 99 | return self.obj.active 100 | 101 | 102 | class override_flag(_overrider): 103 | cls = Flag 104 | 105 | def update(self, active): 106 | self.cls.objects.filter(pk=self.obj.pk).update(everyone=active) 107 | 108 | def get_value(self): 109 | return self.obj.everyone 110 | 111 | 112 | class override_sample(_overrider): 113 | cls = Sample 114 | 115 | def get(self): 116 | try: 117 | self.obj = self.cls.objects.get(name=self.name) 118 | self.created = False 119 | except self.cls.DoesNotExist: 120 | self.obj = self.cls.objects.create(name=self.name, percent='0.0') 121 | self.created = True 122 | 123 | def update(self, active): 124 | if active is True: 125 | p = 100.0 126 | elif active is False: 127 | p = 0.0 128 | else: 129 | p = active 130 | self.cls.objects.filter(pk=self.obj.pk).update(percent='{0}'.format(p)) 131 | 132 | def get_value(self): 133 | p = self.obj.percent 134 | if p == 100.0: 135 | return True 136 | if p == 0.0: 137 | return False 138 | return p 139 | -------------------------------------------------------------------------------- /docs/about/roadmap.rst: -------------------------------------------------------------------------------- 1 | .. _about-roadmap: 2 | 3 | ======= 4 | Roadmap 5 | ======= 6 | 7 | .. note:: 8 | 9 | This roadmap is subject to change, but represents the rough 10 | direction I plan to go. For specific issues, see the current 11 | milestones_. 12 | 13 | 14 | Waffle is already a useful library used in many production systems, but 15 | it is not done evolving. 16 | 17 | 18 | Present through 0.12 19 | ===================== 20 | 21 | The immediate future is finishing common segment features and bug fixes. 22 | 23 | 24 | 0.10.2/0.11 25 | ----------- 26 | 27 | 0.10.2_ was primarily a docs overhaul with a major fix to how caching 28 | works. It will probably not be released on its own but combined with 29 | 0.11_. 30 | 31 | 0.11 includes a couple of significant refactors designed to pay down 32 | some of the debt that's accrued in the past few years. It also includes 33 | finally making a decision about auto-create/data-in-settings. There are 34 | also a few small tools like template syntax sugar and and integration 35 | testing tools. 36 | 37 | 38 | 0.12 39 | ---- 40 | 41 | 0.12_ is about closing some long-standing feature gaps, like segmenting 42 | by IP and User-Agent. 43 | 44 | 45 | Toward 1.0 46 | ========== 47 | 48 | There are no solid criteria for what makes 1.0 right now, but after 49 | 0.12, most outstanding issues will be resolved and Waffle will be in 50 | very good shape. There are no plans for a 0.13, so it seems likely that 51 | the next step after 0.12 would be some clean-up and finally a 1.0. 52 | 53 | 54 | Beyond 1.0 55 | ========== 56 | 57 | *tl;dr: Waffle2 may be a complete break from Waffle.* 58 | 59 | Waffle is one of the first Python libraries I created, you can see that 60 | in the amount of code I left in ``__init__.py``. It is also 4 years old, 61 | and was created during a different period in my career, and in Django. 62 | 63 | There are some philosophical issues with how Waffle is designed. Adding 64 | new methods of segmenting users requires at least one new column each, 65 | and increasing the cyclomatic complexity. Caching is difficult. The 66 | requirements are stringent and no longer realistic (they were created 67 | before Django 1.5). The distinction between Flags, Samples, and Switches 68 | is confusing and triples the API surface area (Flags can easily act as 69 | Switches, less easily as Samples). It is not extensible. 70 | 71 | Some challenges also just accrue over time. Dropping support for Django 72 | 1.4, the current Extended Support Release, would significantly simplify 73 | a few parts. 74 | 75 | There is a simplicity to Waffle that I've always appreciated vs, say, 76 | Gargoyle_. Not least of which is that Waffle works with the built-in 77 | admin (or any other admin you care to use). I don't have to write any 78 | code to start using Waffle, other than an ``if`` block. Just add a row 79 | and click some checkboxes. Most batteries are included. These are all 80 | things that any new version of Waffle must maintain. 81 | 82 | Still, if I *want* to write code to do some kind of custom segment that 83 | isn't common-enough to belong in Waffle, shouldn't I be able to? (And, 84 | if all the core segmenters were built as the same kind of extension, we 85 | could lower the bar for inclusion.) If I only care about IP address and 86 | percentage, it would be great to skip all the other checks that just 87 | happen to be higher in the code. 88 | 89 | I have rough sketches of what this looks like, but there are still some 90 | significant sticking points, particularly around shoehorning all of this 91 | into the existing Django admin. I believe it's *possible*, just 92 | potentially *gross*. (Then again, if it's gross underneath but exposes a 93 | pleasant UI, that's not ideal, but it's OK.) 94 | 95 | The other big sticking point is that this won't be a simple ``ALTER 96 | TABLE wafle_flag ADD COLUMN`` upgrade; things will break. 97 | 98 | I've been thinking what Waffle would be like if I designed it from 99 | scratch today with slightly different goals, like extensibility. Beyond 100 | 1.0, it's difficult to see continuing to add new features without this 101 | kind of overhaul. 102 | 103 | 104 | .. _milestones: https://github.com/jsocol/django-waffle/milestones 105 | .. _0.10.2: https://github.com/jsocol/django-waffle/milestones/0.10.2 106 | .. _0.11: https://github.com/jsocol/django-waffle/milestones/0.11 107 | .. _0.12: https://github.com/jsocol/django-waffle/milestones/0.12 108 | .. _Gargoyle: https://github.com/disqus/gargoyle 109 | -------------------------------------------------------------------------------- /docs/types/flag.rst: -------------------------------------------------------------------------------- 1 | .. _types-flag: 2 | 3 | ===== 4 | Flags 5 | ===== 6 | 7 | Flags are the most robust, flexible method of rolling out a feature with 8 | Waffle. Flags can be used to enable a feature for specific users, 9 | groups, users meeting certain criteria (such as being authenticated, or 10 | superusers) or a certain percentage of visitors. 11 | 12 | 13 | How Flags Work 14 | ============== 15 | 16 | Flags compare the current request_ to their criteria to decide whether 17 | they are active. Consider this simple example:: 18 | 19 | if flag_is_active(request, 'foo'): 20 | pass 21 | 22 | The :ref:`flag_is_active ` function takes two arguments, the 23 | request, and the name of a flag. Assuming this flag (``foo``) is defined 24 | in the database, Waffle will make roughly the following decisions: 25 | 26 | - Is ``WAFFLE_OVERRIDE`` active and if so does this request specify a 27 | value for this flag? If so, use that value. 28 | - If not, is the flag set to globally on or off (the *Everyone* 29 | setting)? If so, use that value. 30 | - If not, is the flag in *Testing* mode, and does the request specify a 31 | value for this flag? If so, use that value and set a testing cookie. 32 | - If not, does the current user meet any of our criteria? If so, the 33 | flag is active. 34 | - If not, does the user have an existing cookie set for this flag? If 35 | so, use that value. 36 | - If not, randomly assign a value for this user based on the 37 | *Percentage* and set a cookie. 38 | 39 | 40 | Flag Attributes 41 | =============== 42 | 43 | Flags can be administered through the Django `admin site`_ or the 44 | :ref:`command line `. They have the following attributes: 45 | 46 | :Name: 47 | The name of the flag. Will be used to identify the flag everywhere. 48 | :Everyone: 49 | Globally set the Flag, **overriding all other criteria**. Leave as 50 | *Unknown* to use other critera. 51 | :Testing: 52 | Can the flag be specified via a querystring parameter? :ref:`See 53 | below `. 72 | :Note: 73 | Describe where the flag is used. 74 | 75 | A Flag will be active if *any* of the criteria are true for the current 76 | user or request (i.e. they are combined with ``or``). For example, if a 77 | Flag is active for superusers, a specific group, and 12% of visitors, 78 | then it will be active if the current user is a superuser *or* if they 79 | are in the group *or* if they are in the 12%. 80 | 81 | 82 | .. note:: 83 | 84 | Users are assigned randomly when using Percentages, so in practice 85 | the actual proportion of users for whom the Flag is active will 86 | probably differ slightly from the Percentage value. 87 | 88 | 89 | .. _types-flag-testing: 90 | 91 | Testing Mode 92 | ============ 93 | 94 | See :ref:`User testing with Waffle `. 95 | 96 | 97 | .. _types-flag-rollout: 98 | 99 | Rollout Mode 100 | ============ 101 | 102 | When a Flag is activated by chance, Waffle sets a cookie so the flag 103 | will not flip back and forth on subsequent visits. This can present a 104 | problem for gradually deploying new features: users can get "stuck" with 105 | the Flag turned off, even as the percentage increases. 106 | 107 | *Rollout mode* addresses this by changing the TTL of "off" cookies. When 108 | Rollout mode is active, cookies setting the Flag to "off" are session 109 | cookies, while those setting the Flag to "on" are still controlled by 110 | :ref:`WAFFLE_MAX_AGE `. 111 | 112 | Effectively, Rollout mode changes the *Percentage* from "percentage of 113 | visitors" to "percent chance that the Flag will be activated per visit." 114 | 115 | 116 | .. _request: https://docs.djangoproject.com/en/dev/topics/http/urls/#how-django-processes-a-request 117 | .. _admin site: https://docs.djangoproject.com/en/dev/ref/contrib/admin/ 118 | -------------------------------------------------------------------------------- /waffle/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import django.utils.timezone 6 | from django.conf import settings 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('auth', '0001_initial'), 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Flag', 19 | fields=[ 20 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 21 | ('name', models.CharField(help_text='The human/computer readable name.', unique=True, max_length=100)), 22 | ('everyone', models.NullBooleanField(help_text='Flip this flag on (Yes) or off (No) for everyone, overriding all other settings. Leave as Unknown to use normally.')), 23 | ('percent', models.DecimalField(help_text='A number between 0.0 and 99.9 to indicate a percentage of users for whom this flag will be active.', null=True, max_digits=3, decimal_places=1, blank=True)), 24 | ('testing', models.BooleanField(default=False, help_text='Allow this flag to be set for a session for user testing.')), 25 | ('superusers', models.BooleanField(default=True, help_text='Flag always active for superusers?')), 26 | ('staff', models.BooleanField(default=False, help_text='Flag always active for staff?')), 27 | ('authenticated', models.BooleanField(default=False, help_text='Flag always active for authenticate users?')), 28 | ('languages', models.TextField(default='', help_text='Activate this flag for users with one of these languages (comma separated list)', blank=True)), 29 | ('rollout', models.BooleanField(default=False, help_text='Activate roll-out mode?')), 30 | ('note', models.TextField(help_text='Note where this Flag is used.', blank=True)), 31 | ('created', models.DateTimeField(default=django.utils.timezone.now, help_text='Date when this Flag was created.', db_index=True)), 32 | ('modified', models.DateTimeField(default=django.utils.timezone.now, help_text='Date when this Flag was last modified.')), 33 | ('groups', models.ManyToManyField(help_text='Activate this flag for these user groups.', to='auth.Group', blank=True)), 34 | ('users', models.ManyToManyField(help_text='Activate this flag for these users.', to=settings.AUTH_USER_MODEL, blank=True)), 35 | ], 36 | options={ 37 | }, 38 | bases=(models.Model,), 39 | ), 40 | migrations.CreateModel( 41 | name='Sample', 42 | fields=[ 43 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 44 | ('name', models.CharField(help_text='The human/computer readable name.', unique=True, max_length=100)), 45 | ('percent', models.DecimalField(help_text='A number between 0.0 and 100.0 to indicate a percentage of the time this sample will be active.', max_digits=4, decimal_places=1)), 46 | ('note', models.TextField(help_text='Note where this Sample is used.', blank=True)), 47 | ('created', models.DateTimeField(default=django.utils.timezone.now, help_text='Date when this Sample was created.', db_index=True)), 48 | ('modified', models.DateTimeField(default=django.utils.timezone.now, help_text='Date when this Sample was last modified.')), 49 | ], 50 | options={ 51 | }, 52 | bases=(models.Model,), 53 | ), 54 | migrations.CreateModel( 55 | name='Switch', 56 | fields=[ 57 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 58 | ('name', models.CharField(help_text='The human/computer readable name.', unique=True, max_length=100)), 59 | ('active', models.BooleanField(default=False, help_text='Is this flag active?')), 60 | ('note', models.TextField(help_text='Note where this Switch is used.', blank=True)), 61 | ('created', models.DateTimeField(default=django.utils.timezone.now, help_text='Date when this Switch was created.', db_index=True)), 62 | ('modified', models.DateTimeField(default=django.utils.timezone.now, help_text='Date when this Switch was last modified.')), 63 | ], 64 | options={ 65 | 'verbose_name_plural': 'Switches', 66 | }, 67 | bases=(models.Model,), 68 | ), 69 | ] 70 | -------------------------------------------------------------------------------- /waffle/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from decimal import Decimal 4 | import random 5 | 6 | from waffle.utils import get_setting, keyfmt 7 | 8 | 9 | VERSION = (0, 11) 10 | __version__ = '.'.join(map(str, VERSION)) 11 | 12 | 13 | class DoesNotExist(object): 14 | """The record does not exist.""" 15 | @property 16 | def active(self): 17 | return get_setting('SWITCH_DEFAULT') 18 | 19 | 20 | def set_flag(request, flag_name, active=True, session_only=False): 21 | """Set a flag value on a request object.""" 22 | if not hasattr(request, 'waffles'): 23 | request.waffles = {} 24 | request.waffles[flag_name] = [active, session_only] 25 | 26 | 27 | def flag_is_active(request, flag_name): 28 | from .models import cache_flag, Flag 29 | from .compat import cache 30 | 31 | flag = cache.get(keyfmt(get_setting('FLAG_CACHE_KEY'), flag_name)) 32 | if flag is None: 33 | try: 34 | flag = Flag.objects.get(name=flag_name) 35 | cache_flag(instance=flag) 36 | except Flag.DoesNotExist: 37 | return get_setting('FLAG_DEFAULT') 38 | 39 | if get_setting('OVERRIDE'): 40 | if flag_name in request.GET: 41 | return request.GET[flag_name] == '1' 42 | 43 | if flag.everyone: 44 | return True 45 | elif flag.everyone is False: 46 | return False 47 | 48 | if flag.testing: # Testing mode is on. 49 | tc = get_setting('TEST_COOKIE') % flag_name 50 | if tc in request.GET: 51 | on = request.GET[tc] == '1' 52 | if not hasattr(request, 'waffle_tests'): 53 | request.waffle_tests = {} 54 | request.waffle_tests[flag_name] = on 55 | return on 56 | if tc in request.COOKIES: 57 | return request.COOKIES[tc] == 'True' 58 | 59 | user = request.user 60 | 61 | if flag.authenticated and user.is_authenticated(): 62 | return True 63 | 64 | if flag.staff and user.is_staff: 65 | return True 66 | 67 | if flag.superusers and user.is_superuser: 68 | return True 69 | 70 | if flag.languages: 71 | languages = flag.languages.split(',') 72 | if (hasattr(request, 'LANGUAGE_CODE') and 73 | request.LANGUAGE_CODE in languages): 74 | return True 75 | 76 | flag_users = cache.get(keyfmt(get_setting('FLAG_USERS_CACHE_KEY'), 77 | flag.name)) 78 | if flag_users is None: 79 | flag_users = flag.users.all() 80 | cache_flag(instance=flag) 81 | if user in flag_users: 82 | return True 83 | 84 | flag_groups = cache.get(keyfmt(get_setting('FLAG_GROUPS_CACHE_KEY'), 85 | flag.name)) 86 | if flag_groups is None: 87 | flag_groups = flag.groups.all() 88 | cache_flag(instance=flag) 89 | user_groups = user.groups.all() 90 | for group in flag_groups: 91 | if group in user_groups: 92 | return True 93 | 94 | if flag.percent and flag.percent > 0: 95 | if not hasattr(request, 'waffles'): 96 | request.waffles = {} 97 | elif flag_name in request.waffles: 98 | return request.waffles[flag_name][0] 99 | 100 | cookie = get_setting('COOKIE') % flag_name 101 | if cookie in request.COOKIES: 102 | flag_active = (request.COOKIES[cookie] == 'True') 103 | set_flag(request, flag_name, flag_active, flag.rollout) 104 | return flag_active 105 | 106 | if Decimal(str(random.uniform(0, 100))) <= flag.percent: 107 | set_flag(request, flag_name, True, flag.rollout) 108 | return True 109 | set_flag(request, flag_name, False, flag.rollout) 110 | 111 | return False 112 | 113 | 114 | def switch_is_active(switch_name): 115 | from .models import cache_switch, Switch 116 | from .compat import cache 117 | 118 | switch = cache.get(keyfmt(get_setting('SWITCH_CACHE_KEY'), switch_name)) 119 | if switch is None: 120 | try: 121 | switch = Switch.objects.get(name=switch_name) 122 | cache_switch(instance=switch) 123 | except Switch.DoesNotExist: 124 | switch = DoesNotExist() 125 | switch.name = switch_name 126 | cache_switch(instance=switch) 127 | return switch.active 128 | 129 | 130 | def sample_is_active(sample_name): 131 | from .models import cache_sample, Sample 132 | from .compat import cache 133 | 134 | sample = cache.get(keyfmt(get_setting('SAMPLE_CACHE_KEY'), sample_name)) 135 | if sample is None: 136 | try: 137 | sample = Sample.objects.get(name=sample_name) 138 | cache_sample(instance=sample) 139 | except Sample.DoesNotExist: 140 | return get_setting('SAMPLE_DEFAULT') 141 | 142 | return Decimal(str(random.uniform(0, 100))) <= sample.percent 143 | -------------------------------------------------------------------------------- /docs/testing/user.rst: -------------------------------------------------------------------------------- 1 | .. _testing-user: 2 | 3 | ======================== 4 | User testing with Waffle 5 | ======================== 6 | 7 | Testing a feature (i.e. not :ref:`testing the code `) 8 | with users usually takes one of two forms: small-scale tests with 9 | individuals or known group, and large-scale tests with a subset of 10 | production users. Waffle provides tools for the former and has some 11 | suggestions for the latter. 12 | 13 | 14 | Small-scale tests 15 | ================= 16 | 17 | There are two ways to control a flag for an individual user: 18 | 19 | - add their account to the flag's list of users, or 20 | - use testing mode. 21 | 22 | Testing mode makes it possible to enable a flag via a querystring 23 | parameter (like ``WAFFLE_OVERRIDE``) but is unique for two reasons: 24 | 25 | - it can be enabled and disabled on a flag-by-flag basis, and 26 | - it only requires the querystring parameter once, then relies on 27 | cookies. 28 | 29 | If the flag we're testing is called ``foo``, then we can enable testing 30 | mode, and send users to ``oursite.com/testpage?dwft_foo=1`` (or ``=0``) 31 | and the flag will be on (or off) for them for the remainder of their 32 | session. 33 | 34 | .. warning:: 35 | 36 | Currently, the flag **must** be used by the first page they visit, 37 | or the cookie will not get set. See `#80`_ on GitHub. 38 | 39 | Researchers can send a link with these parameters to anyone and then 40 | observe or ask questions. At the end of their session, or when testing 41 | mode is deactivated, they will call back to normal behavior. 42 | 43 | For a small group, like a company or team, it may be worth creating a 44 | Django group and adding or removing the group from the flag. 45 | 46 | 47 | Large-scale tests 48 | ================= 49 | 50 | Large scale tests are tests along the lines of "roll this out to 5% of 51 | users and observe the relevant metrics." Since "the relevant metrics" 52 | is very difficult to define across all sites, here are some thoughts 53 | from my experience with these sorts of tests. 54 | 55 | 56 | Client-side metrics 57 | ------------------- 58 | 59 | Google Analytics—and I imagine similar products—has the ability so 60 | segment by page or session variables. If you want to A/B test a 61 | conversion rate or funnel, or otherwise measure the impact on some 62 | client-side metric, using these variables is a solid way to go. For 63 | example, in GA, you might do the following to A/B test a landing page: 64 | 65 | .. code-block:: django 66 | 67 | _gaq.push(['_setCustomVar', 68 | 1, 69 | 'Landing Page Version' 70 | {% flag "new_landing_page" %}2{% else %}1{% endif %}, 71 | 3 72 | ]); 73 | 74 | Similarly you might set session or visitor variables for funnel tests. 75 | 76 | The exact steps to both set a variable like this and then to create 77 | segments and examine the data will depend on your client-side analytics 78 | tool. And, of course, this can be combined with other data and further 79 | segmented if you need to. 80 | 81 | 82 | Server-side metrics 83 | ------------------- 84 | 85 | I use StatsD_ religiously. Sometimes Waffle is useful for load and 86 | capacity testing in which case I want to observe timing data or error 87 | rates. 88 | 89 | Sometimes, it makes sense to create entirely new metrics, and measure 90 | them directly, e.g.:: 91 | 92 | if flag_is_active('image-process-service'): 93 | with statsd.timer('imageservice'): 94 | try: 95 | processed = make_call_to_service(data) 96 | except ServiceError: 97 | statsd.incr('imageservice.error') 98 | else: 99 | statsd.incr('imageservice.success') 100 | else: 101 | with statsd.timer('process-image'): 102 | processed = do_inline_processing(data) 103 | 104 | Other times, existing data—e.g. timers on the whole view—isn't going to 105 | move. If you have enough data to be statistically meaningful, you can 106 | measure the impact for a given proportion of traffic and derive the time 107 | for the new code. 108 | 109 | If a flag enabling a refactored codepath is set to 20% of users, and 110 | average time has improved by 10%, you can calculate that you've improved 111 | the speed by 50%! 112 | 113 | You can use the following to figure out the average for requests using 114 | the new code. Let :math:`t_{old}` be the average time with the flag at 115 | 0%, :math:`t_{total}` be the average time with the flag at :math:`p * 116 | 100%`. Then the average for requests using new code, :math:`t_{new}` 117 | is... 118 | 119 | .. math:: 120 | 121 | t_{new} = t_{old} - \frac{t_{old} - t_{total}}{p} 122 | 123 | If you believe my math (you should check it!) then you can measure the 124 | average with the flag at 0% to get :math:`t_{old}` (let's say 1.2 125 | seconds), then at :math:`p * 100` % (let's say 20%, so :math:`p = 0.2`) 126 | to get :math:`t_{total}` (let's say 1.08 seconds, a 10% improvement) and 127 | you have enough to get the average of the new path. 128 | 129 | .. math:: 130 | 131 | t_{new} = 1.2 - \frac{1.2 - 1.08}{0.2} = 0.6 132 | 133 | Wow, good work! 134 | 135 | You can use similar methods to derive the impact on other factors. 136 | 137 | 138 | .. _#80: https://github.com/jsocol/django-waffle/issues/80 139 | .. _StatsD: https://github.com/etsy/statsd 140 | -------------------------------------------------------------------------------- /waffle/south_migrations/0002_auto__add_sample.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | import datetime 3 | from south.db import db 4 | from south.v2 import SchemaMigration 5 | from django.db import models 6 | 7 | class Migration(SchemaMigration): 8 | 9 | def forwards(self, orm): 10 | 11 | # Adding model 'Sample' 12 | db.create_table('waffle_sample', ( 13 | ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), 14 | ('name', self.gf('django.db.models.fields.CharField')(unique=True, max_length=100)), 15 | ('percent', self.gf('django.db.models.fields.DecimalField')(max_digits=4, decimal_places=1)), 16 | )) 17 | db.send_create_signal('waffle', ['Sample']) 18 | 19 | 20 | def backwards(self, orm): 21 | 22 | # Deleting model 'Sample' 23 | db.delete_table('waffle_sample') 24 | 25 | 26 | models = { 27 | 'auth.group': { 28 | 'Meta': {'object_name': 'Group'}, 29 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 30 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), 31 | 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) 32 | }, 33 | 'auth.permission': { 34 | 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, 35 | 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 36 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), 37 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 38 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) 39 | }, 40 | 'auth.user': { 41 | 'Meta': {'object_name': 'User'}, 42 | 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 43 | 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), 44 | 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 45 | 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), 46 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 47 | 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 48 | 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 49 | 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 50 | 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 51 | 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 52 | 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), 53 | 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), 54 | 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) 55 | }, 56 | 'contenttypes.contenttype': { 57 | 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, 58 | 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 59 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 60 | 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 61 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) 62 | }, 63 | 'waffle.flag': { 64 | 'Meta': {'object_name': 'Flag'}, 65 | 'authenticated': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 66 | 'everyone': ('django.db.models.fields.NullBooleanField', [], {'null': 'True', 'blank': 'True'}), 67 | 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), 68 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 69 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), 70 | 'percent': ('django.db.models.fields.DecimalField', [], {'null': 'True', 'max_digits': '3', 'decimal_places': '1', 'blank': 'True'}), 71 | 'rollout': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 72 | 'staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 73 | 'superusers': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 74 | 'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'symmetrical': 'False', 'blank': 'True'}) 75 | }, 76 | 'waffle.sample': { 77 | 'Meta': {'object_name': 'Sample'}, 78 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 79 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), 80 | 'percent': ('django.db.models.fields.DecimalField', [], {'max_digits': '4', 'decimal_places': '1'}) 81 | }, 82 | 'waffle.switch': { 83 | 'Meta': {'object_name': 'Switch'}, 84 | 'active': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 85 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 86 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}) 87 | } 88 | } 89 | 90 | complete_apps = ['waffle'] 91 | -------------------------------------------------------------------------------- /waffle/south_migrations/0004_auto__add_field_flag_testing.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | import datetime 3 | from south.db import db 4 | from south.v2 import SchemaMigration 5 | from django.db import models 6 | 7 | class Migration(SchemaMigration): 8 | 9 | def forwards(self, orm): 10 | 11 | # Adding field 'Flag.testing' 12 | db.add_column('waffle_flag', 'testing', self.gf('django.db.models.fields.BooleanField')(default=False), keep_default=False) 13 | 14 | 15 | def backwards(self, orm): 16 | 17 | # Deleting field 'Flag.testing' 18 | db.delete_column('waffle_flag', 'testing') 19 | 20 | 21 | models = { 22 | 'auth.group': { 23 | 'Meta': {'object_name': 'Group'}, 24 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 25 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), 26 | 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) 27 | }, 28 | 'auth.permission': { 29 | 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, 30 | 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 31 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), 32 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 33 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) 34 | }, 35 | 'auth.user': { 36 | 'Meta': {'object_name': 'User'}, 37 | 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 38 | 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), 39 | 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 40 | 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), 41 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 42 | 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 43 | 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 44 | 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 45 | 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 46 | 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 47 | 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), 48 | 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), 49 | 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) 50 | }, 51 | 'contenttypes.contenttype': { 52 | 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, 53 | 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 54 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 55 | 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 56 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) 57 | }, 58 | 'waffle.flag': { 59 | 'Meta': {'object_name': 'Flag'}, 60 | 'authenticated': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 61 | 'everyone': ('django.db.models.fields.NullBooleanField', [], {'null': 'True', 'blank': 'True'}), 62 | 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), 63 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 64 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), 65 | 'note': ('django.db.models.fields.TextField', [], {'blank': 'True'}), 66 | 'percent': ('django.db.models.fields.DecimalField', [], {'null': 'True', 'max_digits': '3', 'decimal_places': '1', 'blank': 'True'}), 67 | 'rollout': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 68 | 'staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 69 | 'superusers': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 70 | 'testing': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 71 | 'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'symmetrical': 'False', 'blank': 'True'}) 72 | }, 73 | 'waffle.sample': { 74 | 'Meta': {'object_name': 'Sample'}, 75 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 76 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), 77 | 'note': ('django.db.models.fields.TextField', [], {'blank': 'True'}), 78 | 'percent': ('django.db.models.fields.DecimalField', [], {'max_digits': '4', 'decimal_places': '1'}) 79 | }, 80 | 'waffle.switch': { 81 | 'Meta': {'object_name': 'Switch'}, 82 | 'active': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 83 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 84 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), 85 | 'note': ('django.db.models.fields.TextField', [], {'blank': 'True'}) 86 | } 87 | } 88 | 89 | complete_apps = ['waffle'] 90 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " pickle to make pickle files" 25 | @echo " json to make JSON files" 26 | @echo " htmlhelp to make HTML files and a HTML help project" 27 | @echo " qthelp to make HTML files and a qthelp project" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 31 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-waffle.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-waffle.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django-waffle" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-waffle" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /waffle/south_migrations/0003_auto__add_field_flag_note__add_field_switch_note__add_field_sample_not.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | import datetime 3 | from south.db import db 4 | from south.v2 import SchemaMigration 5 | from django.db import models 6 | 7 | class Migration(SchemaMigration): 8 | 9 | def forwards(self, orm): 10 | 11 | # Adding field 'Flag.note' 12 | db.add_column('waffle_flag', 'note', self.gf('django.db.models.fields.TextField')(default='', blank=True), keep_default=False) 13 | 14 | # Adding field 'Switch.note' 15 | db.add_column('waffle_switch', 'note', self.gf('django.db.models.fields.TextField')(default='', blank=True), keep_default=False) 16 | 17 | # Adding field 'Sample.note' 18 | db.add_column('waffle_sample', 'note', self.gf('django.db.models.fields.TextField')(default='', blank=True), keep_default=False) 19 | 20 | 21 | def backwards(self, orm): 22 | 23 | # Deleting field 'Flag.note' 24 | db.delete_column('waffle_flag', 'note') 25 | 26 | # Deleting field 'Switch.note' 27 | db.delete_column('waffle_switch', 'note') 28 | 29 | # Deleting field 'Sample.note' 30 | db.delete_column('waffle_sample', 'note') 31 | 32 | 33 | models = { 34 | 'auth.group': { 35 | 'Meta': {'object_name': 'Group'}, 36 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 37 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), 38 | 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) 39 | }, 40 | 'auth.permission': { 41 | 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, 42 | 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 43 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), 44 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 45 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) 46 | }, 47 | 'auth.user': { 48 | 'Meta': {'object_name': 'User'}, 49 | 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 50 | 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), 51 | 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 52 | 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), 53 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 54 | 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 55 | 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 56 | 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 57 | 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 58 | 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 59 | 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), 60 | 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), 61 | 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) 62 | }, 63 | 'contenttypes.contenttype': { 64 | 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, 65 | 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 66 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 67 | 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 68 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) 69 | }, 70 | 'waffle.flag': { 71 | 'Meta': {'object_name': 'Flag'}, 72 | 'authenticated': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 73 | 'everyone': ('django.db.models.fields.NullBooleanField', [], {'null': 'True', 'blank': 'True'}), 74 | 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), 75 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 76 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), 77 | 'note': ('django.db.models.fields.TextField', [], {'blank': 'True'}), 78 | 'percent': ('django.db.models.fields.DecimalField', [], {'null': 'True', 'max_digits': '3', 'decimal_places': '1', 'blank': 'True'}), 79 | 'rollout': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 80 | 'staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 81 | 'superusers': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 82 | 'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'symmetrical': 'False', 'blank': 'True'}) 83 | }, 84 | 'waffle.sample': { 85 | 'Meta': {'object_name': 'Sample'}, 86 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 87 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), 88 | 'note': ('django.db.models.fields.TextField', [], {'blank': 'True'}), 89 | 'percent': ('django.db.models.fields.DecimalField', [], {'max_digits': '4', 'decimal_places': '1'}) 90 | }, 91 | 'waffle.switch': { 92 | 'Meta': {'object_name': 'Switch'}, 93 | 'active': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 94 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 95 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), 96 | 'note': ('django.db.models.fields.TextField', [], {'blank': 'True'}) 97 | } 98 | } 99 | 100 | complete_apps = ['waffle'] 101 | -------------------------------------------------------------------------------- /waffle/south_migrations/0008_auto__add_field_flag_languages.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | import datetime 3 | from south.db import db 4 | from south.v2 import SchemaMigration 5 | from django.db import models 6 | 7 | class Migration(SchemaMigration): 8 | 9 | def forwards(self, orm): 10 | 11 | # Adding field 'Flag.languages' 12 | db.add_column('waffle_flag', 'languages', self.gf('django.db.models.fields.TextField')(default='', blank=True), keep_default=False) 13 | 14 | 15 | def backwards(self, orm): 16 | 17 | # Deleting field 'Flag.languages' 18 | db.delete_column('waffle_flag', 'languages') 19 | 20 | 21 | models = { 22 | 'auth.group': { 23 | 'Meta': {'object_name': 'Group'}, 24 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 25 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), 26 | 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) 27 | }, 28 | 'auth.permission': { 29 | 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, 30 | 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 31 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), 32 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 33 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) 34 | }, 35 | 'auth.user': { 36 | 'Meta': {'object_name': 'User'}, 37 | 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 38 | 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), 39 | 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 40 | 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), 41 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 42 | 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 43 | 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 44 | 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 45 | 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 46 | 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 47 | 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), 48 | 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), 49 | 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) 50 | }, 51 | 'contenttypes.contenttype': { 52 | 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, 53 | 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 54 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 55 | 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 56 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) 57 | }, 58 | 'waffle.flag': { 59 | 'Meta': {'object_name': 'Flag'}, 60 | 'authenticated': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 61 | 'created': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'db_index': 'True'}), 62 | 'everyone': ('django.db.models.fields.NullBooleanField', [], {'null': 'True', 'blank': 'True'}), 63 | 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), 64 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 65 | 'languages': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), 66 | 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 67 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), 68 | 'note': ('django.db.models.fields.TextField', [], {'blank': 'True'}), 69 | 'percent': ('django.db.models.fields.DecimalField', [], {'null': 'True', 'max_digits': '3', 'decimal_places': '1', 'blank': 'True'}), 70 | 'rollout': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 71 | 'staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 72 | 'superusers': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 73 | 'testing': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 74 | 'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'symmetrical': 'False', 'blank': 'True'}) 75 | }, 76 | 'waffle.sample': { 77 | 'Meta': {'object_name': 'Sample'}, 78 | 'created': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'db_index': 'True'}), 79 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 80 | 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 81 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), 82 | 'note': ('django.db.models.fields.TextField', [], {'blank': 'True'}), 83 | 'percent': ('django.db.models.fields.DecimalField', [], {'max_digits': '4', 'decimal_places': '1'}) 84 | }, 85 | 'waffle.switch': { 86 | 'Meta': {'object_name': 'Switch'}, 87 | 'active': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 88 | 'created': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'db_index': 'True'}), 89 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 90 | 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 91 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), 92 | 'note': ('django.db.models.fields.TextField', [], {'blank': 'True'}) 93 | } 94 | } 95 | 96 | complete_apps = ['waffle'] 97 | -------------------------------------------------------------------------------- /waffle/south_migrations/0005_auto__add_field_flag_created__add_field_flag_modified.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | import datetime 3 | from south.db import db 4 | from south.v2 import SchemaMigration 5 | from django.db import models 6 | 7 | try: 8 | from django.utils.timezone import now 9 | except ImportError: 10 | now = datetime.datetime.now 11 | 12 | default_datetime = now() 13 | 14 | 15 | class Migration(SchemaMigration): 16 | 17 | def forwards(self, orm): 18 | 19 | # Adding field 'Flag.created' 20 | db.add_column('waffle_flag', 'created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, default=default_datetime, db_index=True, blank=True), keep_default=False) 21 | 22 | # Adding field 'Flag.modified' 23 | db.add_column('waffle_flag', 'modified', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, default=default_datetime, blank=True), keep_default=False) 24 | 25 | 26 | def backwards(self, orm): 27 | 28 | # Deleting field 'Flag.created' 29 | db.delete_column('waffle_flag', 'created') 30 | 31 | # Deleting field 'Flag.modified' 32 | db.delete_column('waffle_flag', 'modified') 33 | 34 | 35 | models = { 36 | 'auth.group': { 37 | 'Meta': {'object_name': 'Group'}, 38 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 39 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), 40 | 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) 41 | }, 42 | 'auth.permission': { 43 | 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, 44 | 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 45 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), 46 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 47 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) 48 | }, 49 | 'auth.user': { 50 | 'Meta': {'object_name': 'User'}, 51 | 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 52 | 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), 53 | 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 54 | 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), 55 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 56 | 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 57 | 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 58 | 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 59 | 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 60 | 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 61 | 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), 62 | 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), 63 | 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) 64 | }, 65 | 'contenttypes.contenttype': { 66 | 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, 67 | 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 68 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 69 | 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 70 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) 71 | }, 72 | 'waffle.flag': { 73 | 'Meta': {'object_name': 'Flag'}, 74 | 'authenticated': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 75 | 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), 76 | 'everyone': ('django.db.models.fields.NullBooleanField', [], {'null': 'True', 'blank': 'True'}), 77 | 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), 78 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 79 | 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), 80 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), 81 | 'note': ('django.db.models.fields.TextField', [], {'blank': 'True'}), 82 | 'percent': ('django.db.models.fields.DecimalField', [], {'null': 'True', 'max_digits': '3', 'decimal_places': '1', 'blank': 'True'}), 83 | 'rollout': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 84 | 'staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 85 | 'superusers': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 86 | 'testing': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 87 | 'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'symmetrical': 'False', 'blank': 'True'}) 88 | }, 89 | 'waffle.sample': { 90 | 'Meta': {'object_name': 'Sample'}, 91 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 92 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), 93 | 'note': ('django.db.models.fields.TextField', [], {'blank': 'True'}), 94 | 'percent': ('django.db.models.fields.DecimalField', [], {'max_digits': '4', 'decimal_places': '1'}) 95 | }, 96 | 'waffle.switch': { 97 | 'Meta': {'object_name': 'Switch'}, 98 | 'active': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 99 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 100 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), 101 | 'note': ('django.db.models.fields.TextField', [], {'blank': 'True'}) 102 | } 103 | } 104 | 105 | complete_apps = ['waffle'] 106 | -------------------------------------------------------------------------------- /waffle/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | try: 4 | from django.utils import timezone as datetime 5 | except ImportError: 6 | from datetime import datetime 7 | 8 | from django.contrib.auth.models import Group 9 | from django.db import models 10 | from django.db.models.signals import post_save, post_delete, m2m_changed 11 | from django.utils.encoding import python_2_unicode_compatible 12 | 13 | from waffle.compat import AUTH_USER_MODEL, cache 14 | from waffle.utils import get_setting, keyfmt 15 | 16 | 17 | @python_2_unicode_compatible 18 | class Flag(models.Model): 19 | """A feature flag. 20 | 21 | Flags are active (or not) on a per-request basis. 22 | 23 | """ 24 | name = models.CharField(max_length=100, unique=True, 25 | help_text='The human/computer readable name.') 26 | everyone = models.NullBooleanField(blank=True, help_text=( 27 | 'Flip this flag on (Yes) or off (No) for everyone, overriding all ' 28 | 'other settings. Leave as Unknown to use normally.')) 29 | percent = models.DecimalField(max_digits=3, decimal_places=1, null=True, 30 | blank=True, help_text=( 31 | 'A number between 0.0 and 99.9 to indicate a percentage of users for ' 32 | 'whom this flag will be active.')) 33 | testing = models.BooleanField(default=False, help_text=( 34 | 'Allow this flag to be set for a session for user testing.')) 35 | superusers = models.BooleanField(default=True, help_text=( 36 | 'Flag always active for superusers?')) 37 | staff = models.BooleanField(default=False, help_text=( 38 | 'Flag always active for staff?')) 39 | authenticated = models.BooleanField(default=False, help_text=( 40 | 'Flag always active for authenticate users?')) 41 | languages = models.TextField(blank=True, default='', help_text=( 42 | 'Activate this flag for users with one of these languages (comma ' 43 | 'separated list)')) 44 | groups = models.ManyToManyField(Group, blank=True, help_text=( 45 | 'Activate this flag for these user groups.')) 46 | users = models.ManyToManyField(AUTH_USER_MODEL, blank=True, help_text=( 47 | 'Activate this flag for these users.')) 48 | rollout = models.BooleanField(default=False, help_text=( 49 | 'Activate roll-out mode?')) 50 | note = models.TextField(blank=True, help_text=( 51 | 'Note where this Flag is used.')) 52 | created = models.DateTimeField(default=datetime.now, db_index=True, 53 | help_text=('Date when this Flag was created.')) 54 | modified = models.DateTimeField(default=datetime.now, help_text=( 55 | 'Date when this Flag was last modified.')) 56 | 57 | def __str__(self): 58 | return self.name 59 | 60 | def save(self, *args, **kwargs): 61 | self.modified = datetime.now() 62 | super(Flag, self).save(*args, **kwargs) 63 | 64 | 65 | @python_2_unicode_compatible 66 | class Switch(models.Model): 67 | """A feature switch. 68 | 69 | Switches are active, or inactive, globally. 70 | 71 | """ 72 | name = models.CharField(max_length=100, unique=True, 73 | help_text='The human/computer readable name.') 74 | active = models.BooleanField(default=False, help_text=( 75 | 'Is this flag active?')) 76 | note = models.TextField(blank=True, help_text=( 77 | 'Note where this Switch is used.')) 78 | created = models.DateTimeField(default=datetime.now, db_index=True, 79 | help_text=('Date when this Switch was created.')) 80 | modified = models.DateTimeField(default=datetime.now, help_text=( 81 | 'Date when this Switch was last modified.')) 82 | 83 | def __str__(self): 84 | return self.name 85 | 86 | def save(self, *args, **kwargs): 87 | self.modified = datetime.now() 88 | super(Switch, self).save(*args, **kwargs) 89 | 90 | class Meta: 91 | verbose_name_plural = 'Switches' 92 | 93 | 94 | @python_2_unicode_compatible 95 | class Sample(models.Model): 96 | """A sample is true some percentage of the time, but is not connected 97 | to users or requests. 98 | """ 99 | name = models.CharField(max_length=100, unique=True, 100 | help_text='The human/computer readable name.') 101 | percent = models.DecimalField(max_digits=4, decimal_places=1, help_text=( 102 | 'A number between 0.0 and 100.0 to indicate a percentage of the time ' 103 | 'this sample will be active.')) 104 | note = models.TextField(blank=True, help_text=( 105 | 'Note where this Sample is used.')) 106 | created = models.DateTimeField(default=datetime.now, db_index=True, 107 | help_text=('Date when this Sample was created.')) 108 | modified = models.DateTimeField(default=datetime.now, help_text=( 109 | 'Date when this Sample was last modified.')) 110 | 111 | def __str__(self): 112 | return self.name 113 | 114 | def save(self, *args, **kwargs): 115 | self.modified = datetime.now() 116 | super(Sample, self).save(*args, **kwargs) 117 | 118 | 119 | def cache_flag(**kwargs): 120 | action = kwargs.get('action', None) 121 | # action is included for m2m_changed signal. Only cache on the post_*. 122 | if not action or action in ['post_add', 'post_remove', 'post_clear']: 123 | f = kwargs.get('instance') 124 | cache.add(keyfmt(get_setting('FLAG_CACHE_KEY'), f.name), f) 125 | cache.add(keyfmt(get_setting('FLAG_USERS_CACHE_KEY'), f.name), 126 | f.users.all()) 127 | cache.add(keyfmt(get_setting('FLAG_GROUPS_CACHE_KEY'), f.name), 128 | f.groups.all()) 129 | 130 | 131 | def uncache_flag(**kwargs): 132 | flag = kwargs.get('instance') 133 | data = { 134 | keyfmt(get_setting('FLAG_CACHE_KEY'), flag.name): None, 135 | keyfmt(get_setting('FLAG_USERS_CACHE_KEY'), flag.name): None, 136 | keyfmt(get_setting('FLAG_GROUPS_CACHE_KEY'), flag.name): None, 137 | keyfmt(get_setting('ALL_FLAGS_CACHE_KEY')): None 138 | } 139 | cache.set_many(data, 5) 140 | 141 | post_save.connect(uncache_flag, sender=Flag, dispatch_uid='save_flag') 142 | post_delete.connect(uncache_flag, sender=Flag, dispatch_uid='delete_flag') 143 | m2m_changed.connect(uncache_flag, sender=Flag.users.through, 144 | dispatch_uid='m2m_flag_users') 145 | m2m_changed.connect(uncache_flag, sender=Flag.groups.through, 146 | dispatch_uid='m2m_flag_groups') 147 | 148 | 149 | def cache_sample(**kwargs): 150 | sample = kwargs.get('instance') 151 | cache.add(keyfmt(get_setting('SAMPLE_CACHE_KEY'), sample.name), sample) 152 | 153 | 154 | def uncache_sample(**kwargs): 155 | sample = kwargs.get('instance') 156 | cache.set(keyfmt(get_setting('SAMPLE_CACHE_KEY'), sample.name), None, 5) 157 | cache.set(keyfmt(get_setting('ALL_SAMPLES_CACHE_KEY')), None, 5) 158 | 159 | post_save.connect(uncache_sample, sender=Sample, dispatch_uid='save_sample') 160 | post_delete.connect(uncache_sample, sender=Sample, 161 | dispatch_uid='delete_sample') 162 | 163 | 164 | def cache_switch(**kwargs): 165 | switch = kwargs.get('instance') 166 | cache.add(keyfmt(get_setting('SWITCH_CACHE_KEY'), switch.name), switch) 167 | 168 | 169 | def uncache_switch(**kwargs): 170 | switch = kwargs.get('instance') 171 | cache.set(keyfmt(get_setting('SWITCH_CACHE_KEY'), switch.name), None, 5) 172 | cache.set(keyfmt(get_setting('ALL_SWITCHES_CACHE_KEY')), None, 5) 173 | 174 | post_delete.connect(uncache_switch, sender=Switch, 175 | dispatch_uid='delete_switch') 176 | post_save.connect(uncache_switch, sender=Switch, dispatch_uid='save_switch') 177 | -------------------------------------------------------------------------------- /waffle/tests/test_testutils.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from decimal import Decimal 4 | 5 | from django.contrib.auth.models import AnonymousUser 6 | from django.test import TestCase, RequestFactory 7 | 8 | import waffle 9 | from waffle.models import Switch, Flag, Sample 10 | from waffle.testutils import override_switch, override_flag, override_sample 11 | 12 | 13 | class OverrideSwitchTests(TestCase): 14 | def test_switch_existed_and_was_active(self): 15 | Switch.objects.create(name='foo', active=True) 16 | 17 | with override_switch('foo', active=True): 18 | assert waffle.switch_is_active('foo') 19 | 20 | with override_switch('foo', active=False): 21 | assert not waffle.switch_is_active('foo') 22 | 23 | # make sure it didn't change 'active' value 24 | assert Switch.objects.get(name='foo').active 25 | 26 | def test_switch_existed_and_was_NOT_active(self): 27 | Switch.objects.create(name='foo', active=False) 28 | 29 | with override_switch('foo', active=True): 30 | assert waffle.switch_is_active('foo') 31 | 32 | with override_switch('foo', active=False): 33 | assert not waffle.switch_is_active('foo') 34 | 35 | # make sure it didn't change 'active' value 36 | assert not Switch.objects.get(name='foo').active 37 | 38 | def test_new_switch(self): 39 | assert not Switch.objects.filter(name='foo').exists() 40 | 41 | with override_switch('foo', active=True): 42 | assert waffle.switch_is_active('foo') 43 | 44 | with override_switch('foo', active=False): 45 | assert not waffle.switch_is_active('foo') 46 | 47 | assert not Switch.objects.filter(name='foo').exists() 48 | 49 | def test_as_decorator(self): 50 | assert not Switch.objects.filter(name='foo').exists() 51 | 52 | @override_switch('foo', active=True) 53 | def test_enabled(): 54 | assert waffle.switch_is_active('foo') 55 | 56 | test_enabled() 57 | 58 | @override_switch('foo', active=False) 59 | def test_disabled(): 60 | assert not waffle.switch_is_active('foo') 61 | 62 | test_disabled() 63 | 64 | assert not Switch.objects.filter(name='foo').exists() 65 | 66 | def test_restores_after_exception(self): 67 | Switch.objects.create(name='foo', active=True) 68 | 69 | def inner(): 70 | with override_switch('foo', active=False): 71 | raise RuntimeError("Trying to break") 72 | 73 | with self.assertRaises(RuntimeError): 74 | inner() 75 | 76 | assert Switch.objects.get(name='foo').active 77 | 78 | def test_restores_after_exception_in_decorator(self): 79 | Switch.objects.create(name='foo', active=True) 80 | 81 | @override_switch('foo', active=False) 82 | def inner(): 83 | raise RuntimeError("Trying to break") 84 | 85 | with self.assertRaises(RuntimeError): 86 | inner() 87 | 88 | assert Switch.objects.get(name='foo').active 89 | 90 | 91 | def req(): 92 | r = RequestFactory().get('/') 93 | r.user = AnonymousUser() 94 | return r 95 | 96 | 97 | class OverrideFlagTests(TestCase): 98 | def test_flag_existed_and_was_active(self): 99 | Flag.objects.create(name='foo', everyone=True) 100 | 101 | with override_flag('foo', active=True): 102 | assert waffle.flag_is_active(req(), 'foo') 103 | 104 | with override_flag('foo', active=False): 105 | assert not waffle.flag_is_active(req(), 'foo') 106 | 107 | assert Flag.objects.get(name='foo').everyone 108 | 109 | def test_flag_existed_and_was_inactive(self): 110 | Flag.objects.create(name='foo', everyone=False) 111 | 112 | with override_flag('foo', active=True): 113 | assert waffle.flag_is_active(req(), 'foo') 114 | 115 | with override_flag('foo', active=False): 116 | assert not waffle.flag_is_active(req(), 'foo') 117 | 118 | assert Flag.objects.get(name='foo').everyone is False 119 | 120 | def test_flag_existed_and_was_null(self): 121 | Flag.objects.create(name='foo', everyone=None) 122 | 123 | with override_flag('foo', active=True): 124 | assert waffle.flag_is_active(req(), 'foo') 125 | 126 | with override_flag('foo', active=False): 127 | assert not waffle.flag_is_active(req(), 'foo') 128 | 129 | assert Flag.objects.get(name='foo').everyone is None 130 | 131 | def test_flag_did_not_exist(self): 132 | assert not Flag.objects.filter(name='foo').exists() 133 | 134 | with override_flag('foo', active=True): 135 | assert waffle.flag_is_active(req(), 'foo') 136 | 137 | with override_flag('foo', active=False): 138 | assert not waffle.flag_is_active(req(), 'foo') 139 | 140 | assert not Flag.objects.filter(name='foo').exists() 141 | 142 | 143 | class OverrideSampleTests(TestCase): 144 | def test_sample_existed_and_was_100(self): 145 | Sample.objects.create(name='foo', percent='100.0') 146 | 147 | with override_sample('foo', active=True): 148 | assert waffle.sample_is_active('foo') 149 | 150 | with override_sample('foo', active=False): 151 | assert not waffle.sample_is_active('foo') 152 | 153 | self.assertEquals(Decimal('100.0'), 154 | Sample.objects.get(name='foo').percent) 155 | 156 | def test_sample_existed_and_was_0(self): 157 | Sample.objects.create(name='foo', percent='0.0') 158 | 159 | with override_sample('foo', active=True): 160 | assert waffle.sample_is_active('foo') 161 | 162 | with override_sample('foo', active=False): 163 | assert not waffle.sample_is_active('foo') 164 | 165 | self.assertEquals(Decimal('0.0'), 166 | Sample.objects.get(name='foo').percent) 167 | 168 | def test_sample_existed_and_was_50(self): 169 | Sample.objects.create(name='foo', percent='50.0') 170 | 171 | with override_sample('foo', active=True): 172 | assert waffle.sample_is_active('foo') 173 | 174 | with override_sample('foo', active=False): 175 | assert not waffle.sample_is_active('foo') 176 | 177 | self.assertEquals(Decimal('50.0'), 178 | Sample.objects.get(name='foo').percent) 179 | 180 | def test_sample_did_not_exist(self): 181 | assert not Sample.objects.filter(name='foo').exists() 182 | 183 | with override_sample('foo', active=True): 184 | assert waffle.sample_is_active('foo') 185 | 186 | with override_sample('foo', active=False): 187 | assert not waffle.sample_is_active('foo') 188 | 189 | assert not Sample.objects.filter(name='foo').exists() 190 | 191 | 192 | @override_switch('foo', active=False) 193 | class OverrideSwitchOnClassTests(TestCase): 194 | def setUp(self): 195 | assert not Switch.objects.filter(name='foo').exists() 196 | Switch.objects.create(name='foo', active=True) 197 | 198 | def test_undecorated_method_is_set_properly_for_switch(self): 199 | self.assertFalse(waffle.switch_is_active('foo')) 200 | 201 | 202 | @override_flag('foo', active=False) 203 | class OverrideFlagOnClassTests(TestCase): 204 | def setUp(self): 205 | assert not Flag.objects.filter(name='foo').exists() 206 | Flag.objects.create(name='foo', everyone=True) 207 | 208 | def test_undecorated_method_is_set_properly_for_flag(self): 209 | self.assertFalse(waffle.flag_is_active(req(), 'foo')) 210 | 211 | 212 | @override_sample('foo', active=False) 213 | class OverrideSampleOnClassTests(TestCase): 214 | def setUp(self): 215 | assert not Sample.objects.filter(name='foo').exists() 216 | Sample.objects.create(name='foo', percent='100.0') 217 | 218 | def test_undecorated_method_is_set_properly_for_sample(self): 219 | self.assertFalse(waffle.sample_is_active('foo')) 220 | -------------------------------------------------------------------------------- /waffle/south_migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | import datetime 3 | import django 4 | 5 | from south.db import db 6 | from south.v2 import SchemaMigration 7 | from django.db import models 8 | 9 | 10 | # Django 1.5+ compatibility 11 | if django.VERSION >= (1, 5): 12 | from django.contrib.auth import get_user_model 13 | else: 14 | from django.contrib.auth.models import User 15 | 16 | def get_user_model(): 17 | return User 18 | 19 | 20 | class Migration(SchemaMigration): 21 | 22 | def forwards(self, orm): 23 | 24 | # Adding model 'Flag' 25 | db.create_table('waffle_flag', ( 26 | ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), 27 | ('name', self.gf('django.db.models.fields.CharField')(unique=True, max_length=100)), 28 | ('everyone', self.gf('django.db.models.fields.NullBooleanField')(null=True, blank=True)), 29 | ('percent', self.gf('django.db.models.fields.DecimalField')(null=True, max_digits=3, decimal_places=1, blank=True)), 30 | ('superusers', self.gf('django.db.models.fields.BooleanField')(default=True)), 31 | ('staff', self.gf('django.db.models.fields.BooleanField')(default=False)), 32 | ('authenticated', self.gf('django.db.models.fields.BooleanField')(default=False)), 33 | ('rollout', self.gf('django.db.models.fields.BooleanField')(default=False)), 34 | )) 35 | db.send_create_signal('waffle', ['Flag']) 36 | 37 | # Adding M2M table for field groups on 'Flag' 38 | db.create_table('waffle_flag_groups', ( 39 | ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)), 40 | ('flag', models.ForeignKey(orm['waffle.flag'], null=False)), 41 | ('group', models.ForeignKey(orm['auth.group'], null=False)) 42 | )) 43 | db.create_unique('waffle_flag_groups', ['flag_id', 'group_id']) 44 | 45 | # Adding M2M table for field users on 'Flag' 46 | db.create_table('waffle_flag_users', ( 47 | ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)), 48 | ('flag', models.ForeignKey(orm['waffle.flag'], null=False)), 49 | ('user', models.ForeignKey(get_user_model(), null=False)) 50 | )) 51 | db.create_unique('waffle_flag_users', ['flag_id', 'user_id']) 52 | 53 | # Adding model 'Switch' 54 | db.create_table('waffle_switch', ( 55 | ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), 56 | ('name', self.gf('django.db.models.fields.CharField')(unique=True, max_length=100)), 57 | ('active', self.gf('django.db.models.fields.BooleanField')(default=False)), 58 | )) 59 | db.send_create_signal('waffle', ['Switch']) 60 | 61 | 62 | def backwards(self, orm): 63 | 64 | # Deleting model 'Flag' 65 | db.delete_table('waffle_flag') 66 | 67 | # Removing M2M table for field groups on 'Flag' 68 | db.delete_table('waffle_flag_groups') 69 | 70 | # Removing M2M table for field users on 'Flag' 71 | db.delete_table('waffle_flag_users') 72 | 73 | # Deleting model 'Switch' 74 | db.delete_table('waffle_switch') 75 | 76 | 77 | models = { 78 | 'auth.group': { 79 | 'Meta': {'object_name': 'Group'}, 80 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 81 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), 82 | 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) 83 | }, 84 | 'auth.permission': { 85 | 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, 86 | 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 87 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), 88 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 89 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) 90 | }, 91 | 'auth.user': { 92 | 'Meta': {'object_name': 'User'}, 93 | 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 94 | 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), 95 | 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 96 | 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), 97 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 98 | 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 99 | 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 100 | 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 101 | 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 102 | 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 103 | 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), 104 | 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), 105 | 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) 106 | }, 107 | 'contenttypes.contenttype': { 108 | 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, 109 | 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 110 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 111 | 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 112 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) 113 | }, 114 | 'waffle.flag': { 115 | 'Meta': {'object_name': 'Flag'}, 116 | 'authenticated': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 117 | 'everyone': ('django.db.models.fields.NullBooleanField', [], {'null': 'True', 'blank': 'True'}), 118 | 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), 119 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 120 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), 121 | 'percent': ('django.db.models.fields.DecimalField', [], {'null': 'True', 'max_digits': '3', 'decimal_places': '1', 'blank': 'True'}), 122 | 'rollout': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 123 | 'staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 124 | 'superusers': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 125 | 'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'symmetrical': 'False', 'blank': 'True'}) 126 | }, 127 | 'waffle.switch': { 128 | 'Meta': {'object_name': 'Switch'}, 129 | 'active': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 130 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 131 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}) 132 | } 133 | } 134 | 135 | complete_apps = ['waffle'] 136 | -------------------------------------------------------------------------------- /waffle/south_migrations/0006_auto__add_field_switch_created__add_field_switch_modified__add_field_s.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | import datetime 3 | from south.db import db 4 | from south.v2 import SchemaMigration 5 | from django.db import models 6 | 7 | try: 8 | from django.utils.timezone import now 9 | except ImportError: 10 | now = datetime.datetime.now 11 | 12 | default_datetime = now() 13 | 14 | 15 | class Migration(SchemaMigration): 16 | 17 | def forwards(self, orm): 18 | 19 | # Adding field 'Switch.created' 20 | db.add_column('waffle_switch', 'created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, default=default_datetime, db_index=True, blank=True), keep_default=False) 21 | 22 | # Adding field 'Switch.modified' 23 | db.add_column('waffle_switch', 'modified', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, default=default_datetime, blank=True), keep_default=False) 24 | 25 | # Adding field 'Sample.created' 26 | db.add_column('waffle_sample', 'created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, default=default_datetime, db_index=True, blank=True), keep_default=False) 27 | 28 | # Adding field 'Sample.modified' 29 | db.add_column('waffle_sample', 'modified', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, default=default_datetime, blank=True), keep_default=False) 30 | 31 | 32 | def backwards(self, orm): 33 | 34 | # Deleting field 'Switch.created' 35 | db.delete_column('waffle_switch', 'created') 36 | 37 | # Deleting field 'Switch.modified' 38 | db.delete_column('waffle_switch', 'modified') 39 | 40 | # Deleting field 'Sample.created' 41 | db.delete_column('waffle_sample', 'created') 42 | 43 | # Deleting field 'Sample.modified' 44 | db.delete_column('waffle_sample', 'modified') 45 | 46 | 47 | models = { 48 | 'auth.group': { 49 | 'Meta': {'object_name': 'Group'}, 50 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 51 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), 52 | 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) 53 | }, 54 | 'auth.permission': { 55 | 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, 56 | 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 57 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), 58 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 59 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) 60 | }, 61 | 'auth.user': { 62 | 'Meta': {'object_name': 'User'}, 63 | 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 64 | 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), 65 | 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 66 | 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), 67 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 68 | 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 69 | 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 70 | 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 71 | 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 72 | 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 73 | 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), 74 | 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), 75 | 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) 76 | }, 77 | 'contenttypes.contenttype': { 78 | 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, 79 | 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 80 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 81 | 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 82 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) 83 | }, 84 | 'waffle.flag': { 85 | 'Meta': {'object_name': 'Flag'}, 86 | 'authenticated': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 87 | 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), 88 | 'everyone': ('django.db.models.fields.NullBooleanField', [], {'null': 'True', 'blank': 'True'}), 89 | 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), 90 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 91 | 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), 92 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), 93 | 'note': ('django.db.models.fields.TextField', [], {'blank': 'True'}), 94 | 'percent': ('django.db.models.fields.DecimalField', [], {'null': 'True', 'max_digits': '3', 'decimal_places': '1', 'blank': 'True'}), 95 | 'rollout': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 96 | 'staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 97 | 'superusers': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 98 | 'testing': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 99 | 'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'symmetrical': 'False', 'blank': 'True'}) 100 | }, 101 | 'waffle.sample': { 102 | 'Meta': {'object_name': 'Sample'}, 103 | 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), 104 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 105 | 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), 106 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), 107 | 'note': ('django.db.models.fields.TextField', [], {'blank': 'True'}), 108 | 'percent': ('django.db.models.fields.DecimalField', [], {'max_digits': '4', 'decimal_places': '1'}) 109 | }, 110 | 'waffle.switch': { 111 | 'Meta': {'object_name': 'Switch'}, 112 | 'active': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 113 | 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), 114 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 115 | 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), 116 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), 117 | 'note': ('django.db.models.fields.TextField', [], {'blank': 'True'}) 118 | } 119 | } 120 | 121 | complete_apps = ['waffle'] 122 | -------------------------------------------------------------------------------- /waffle/south_migrations/0007_auto__chg_field_flag_created__chg_field_flag_modified__chg_field_switc.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import datetime 3 | from south.db import db 4 | from south.v2 import SchemaMigration 5 | from django.db import models 6 | 7 | 8 | class Migration(SchemaMigration): 9 | 10 | def forwards(self, orm): 11 | 12 | # Changing field 'Flag.created' 13 | db.alter_column('waffle_flag', 'created', self.gf('django.db.models.fields.DateTimeField')()) 14 | 15 | # Changing field 'Flag.modified' 16 | db.alter_column('waffle_flag', 'modified', self.gf('django.db.models.fields.DateTimeField')()) 17 | 18 | # Changing field 'Switch.created' 19 | db.alter_column('waffle_switch', 'created', self.gf('django.db.models.fields.DateTimeField')()) 20 | 21 | # Changing field 'Switch.modified' 22 | db.alter_column('waffle_switch', 'modified', self.gf('django.db.models.fields.DateTimeField')()) 23 | 24 | # Changing field 'Sample.created' 25 | db.alter_column('waffle_sample', 'created', self.gf('django.db.models.fields.DateTimeField')()) 26 | 27 | # Changing field 'Sample.modified' 28 | db.alter_column('waffle_sample', 'modified', self.gf('django.db.models.fields.DateTimeField')()) 29 | 30 | def backwards(self, orm): 31 | 32 | # Changing field 'Flag.created' 33 | db.alter_column('waffle_flag', 'created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True)) 34 | 35 | # Changing field 'Flag.modified' 36 | db.alter_column('waffle_flag', 'modified', self.gf('django.db.models.fields.DateTimeField')(auto_now=True)) 37 | 38 | # Changing field 'Switch.created' 39 | db.alter_column('waffle_switch', 'created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True)) 40 | 41 | # Changing field 'Switch.modified' 42 | db.alter_column('waffle_switch', 'modified', self.gf('django.db.models.fields.DateTimeField')(auto_now=True)) 43 | 44 | # Changing field 'Sample.created' 45 | db.alter_column('waffle_sample', 'created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True)) 46 | 47 | # Changing field 'Sample.modified' 48 | db.alter_column('waffle_sample', 'modified', self.gf('django.db.models.fields.DateTimeField')(auto_now=True)) 49 | 50 | models = { 51 | 'auth.group': { 52 | 'Meta': {'object_name': 'Group'}, 53 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 54 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), 55 | 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) 56 | }, 57 | 'auth.permission': { 58 | 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, 59 | 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 60 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), 61 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 62 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) 63 | }, 64 | 'auth.user': { 65 | 'Meta': {'object_name': 'User'}, 66 | 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 67 | 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), 68 | 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 69 | 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), 70 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 71 | 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 72 | 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 73 | 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 74 | 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 75 | 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 76 | 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), 77 | 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), 78 | 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) 79 | }, 80 | 'contenttypes.contenttype': { 81 | 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, 82 | 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 83 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 84 | 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 85 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) 86 | }, 87 | 'waffle.flag': { 88 | 'Meta': {'object_name': 'Flag'}, 89 | 'authenticated': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 90 | 'created': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'db_index': 'True'}), 91 | 'everyone': ('django.db.models.fields.NullBooleanField', [], {'null': 'True', 'blank': 'True'}), 92 | 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), 93 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 94 | 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 95 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), 96 | 'note': ('django.db.models.fields.TextField', [], {'blank': 'True'}), 97 | 'percent': ('django.db.models.fields.DecimalField', [], {'null': 'True', 'max_digits': '3', 'decimal_places': '1', 'blank': 'True'}), 98 | 'rollout': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 99 | 'staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 100 | 'superusers': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 101 | 'testing': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 102 | 'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'symmetrical': 'False', 'blank': 'True'}) 103 | }, 104 | 'waffle.sample': { 105 | 'Meta': {'object_name': 'Sample'}, 106 | 'created': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'db_index': 'True'}), 107 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 108 | 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 109 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), 110 | 'note': ('django.db.models.fields.TextField', [], {'blank': 'True'}), 111 | 'percent': ('django.db.models.fields.DecimalField', [], {'max_digits': '4', 'decimal_places': '1'}) 112 | }, 113 | 'waffle.switch': { 114 | 'Meta': {'object_name': 'Switch'}, 115 | 'active': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 116 | 'created': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'db_index': 'True'}), 117 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 118 | 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 119 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), 120 | 'note': ('django.db.models.fields.TextField', [], {'blank': 'True'}) 121 | } 122 | } 123 | 124 | complete_apps = ['waffle'] -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # django-waffle documentation build configuration file, created by 4 | # sphinx-quickstart on Wed Aug 1 17:45:05 2012. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys, os 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | #sys.path.insert(0, os.path.abspath('.')) 20 | 21 | # -- General configuration ----------------------------------------------------- 22 | 23 | # If your documentation needs a minimal Sphinx version, state it here. 24 | #needs_sphinx = '1.0' 25 | 26 | # Add any Sphinx extension module names here, as strings. They can be extensions 27 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 28 | extensions = ['sphinx.ext.mathjax'] 29 | 30 | # Add any paths that contain templates here, relative to this directory. 31 | templates_path = ['_templates'] 32 | 33 | # The suffix of source filenames. 34 | source_suffix = '.rst' 35 | 36 | # The encoding of source files. 37 | #source_encoding = 'utf-8-sig' 38 | 39 | # The master toctree document. 40 | master_doc = 'index' 41 | 42 | # General information about the project. 43 | project = u'django-waffle' 44 | copyright = u'2012-2015, James Socol' 45 | 46 | # The version info for the project you're documenting, acts as replacement for 47 | # |version| and |release|, also used in various other places throughout the 48 | # built documents. 49 | # 50 | # The short X.Y version. 51 | version = '0.11' 52 | # The full version, including alpha/beta/rc tags. 53 | release = '0.11' 54 | 55 | # The language for content autogenerated by Sphinx. Refer to documentation 56 | # for a list of supported languages. 57 | #language = None 58 | 59 | # There are two options for replacing |today|: either, you set today to some 60 | # non-false value, then it is used: 61 | #today = '' 62 | # Else, today_fmt is used as the format for a strftime call. 63 | #today_fmt = '%B %d, %Y' 64 | 65 | # List of patterns, relative to source directory, that match files and 66 | # directories to ignore when looking for source files. 67 | exclude_patterns = ['_build'] 68 | 69 | # The reST default role (used for this markup: `text`) to use for all documents. 70 | #default_role = None 71 | 72 | # If true, '()' will be appended to :func: etc. cross-reference text. 73 | #add_function_parentheses = True 74 | 75 | # If true, the current module name will be prepended to all description 76 | # unit titles (such as .. function::). 77 | #add_module_names = True 78 | 79 | # If true, sectionauthor and moduleauthor directives will be shown in the 80 | # output. They are ignored by default. 81 | #show_authors = False 82 | 83 | # The name of the Pygments (syntax highlighting) style to use. 84 | pygments_style = 'sphinx' 85 | 86 | # A list of ignored prefixes for module index sorting. 87 | #modindex_common_prefix = [] 88 | 89 | 90 | # -- Options for HTML output --------------------------------------------------- 91 | 92 | # The theme to use for HTML and HTML Help pages. See the documentation for 93 | # a list of builtin themes. 94 | html_theme = 'nature' 95 | 96 | # Theme options are theme-specific and customize the look and feel of a theme 97 | # further. For a list of options available for each theme, see the 98 | # documentation. 99 | #html_theme_options = {} 100 | 101 | # Add any paths that contain custom themes here, relative to this directory. 102 | #html_theme_path = [] 103 | 104 | # The name for this set of Sphinx documents. If None, it defaults to 105 | # " v documentation". 106 | #html_title = None 107 | 108 | # A shorter title for the navigation bar. Default is the same as html_title. 109 | #html_short_title = None 110 | 111 | # The name of an image file (relative to this directory) to place at the top 112 | # of the sidebar. 113 | #html_logo = None 114 | 115 | # The name of an image file (within the static path) to use as favicon of the 116 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 117 | # pixels large. 118 | #html_favicon = None 119 | 120 | # Add any paths that contain custom static files (such as style sheets) here, 121 | # relative to this directory. They are copied after the builtin static files, 122 | # so a file named "default.css" will overwrite the builtin "default.css". 123 | html_static_path = ['_static'] 124 | 125 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 126 | # using the given strftime format. 127 | #html_last_updated_fmt = '%b %d, %Y' 128 | 129 | # If true, SmartyPants will be used to convert quotes and dashes to 130 | # typographically correct entities. 131 | #html_use_smartypants = True 132 | 133 | # Custom sidebar templates, maps document names to template names. 134 | #html_sidebars = {} 135 | 136 | # Additional templates that should be rendered to pages, maps page names to 137 | # template names. 138 | #html_additional_pages = {} 139 | 140 | # If false, no module index is generated. 141 | #html_domain_indices = True 142 | 143 | # If false, no index is generated. 144 | #html_use_index = True 145 | 146 | # If true, the index is split into individual pages for each letter. 147 | #html_split_index = False 148 | 149 | # If true, links to the reST sources are added to the pages. 150 | #html_show_sourcelink = True 151 | 152 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 153 | #html_show_sphinx = True 154 | 155 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 156 | #html_show_copyright = True 157 | 158 | # If true, an OpenSearch description file will be output, and all pages will 159 | # contain a tag referring to it. The value of this option must be the 160 | # base URL from which the finished HTML is served. 161 | #html_use_opensearch = '' 162 | 163 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 164 | #html_file_suffix = None 165 | 166 | # Output file base name for HTML help builder. 167 | htmlhelp_basename = 'django-waffledoc' 168 | 169 | 170 | # -- Options for LaTeX output -------------------------------------------------- 171 | 172 | latex_elements = { 173 | # The paper size ('letterpaper' or 'a4paper'). 174 | #'papersize': 'letterpaper', 175 | 176 | # The font size ('10pt', '11pt' or '12pt'). 177 | #'pointsize': '10pt', 178 | 179 | # Additional stuff for the LaTeX preamble. 180 | #'preamble': '', 181 | } 182 | 183 | # Grouping the document tree into LaTeX files. List of tuples 184 | # (source start file, target name, title, author, documentclass [howto/manual]). 185 | latex_documents = [ 186 | ('index', 'django-waffle.tex', u'django-waffle Documentation', 187 | u'James Socol', 'manual'), 188 | ] 189 | 190 | # The name of an image file (relative to this directory) to place at the top of 191 | # the title page. 192 | #latex_logo = None 193 | 194 | # For "manual" documents, if this is true, then toplevel headings are parts, 195 | # not chapters. 196 | #latex_use_parts = False 197 | 198 | # If true, show page references after internal links. 199 | #latex_show_pagerefs = False 200 | 201 | # If true, show URL addresses after external links. 202 | #latex_show_urls = False 203 | 204 | # Documents to append as an appendix to all manuals. 205 | #latex_appendices = [] 206 | 207 | # If false, no module index is generated. 208 | #latex_domain_indices = True 209 | 210 | 211 | # -- Options for manual page output -------------------------------------------- 212 | 213 | # One entry per manual page. List of tuples 214 | # (source start file, name, description, authors, manual section). 215 | man_pages = [ 216 | ('index', 'django-waffle', u'django-waffle Documentation', 217 | [u'James Socol'], 1) 218 | ] 219 | 220 | # If true, show URL addresses after external links. 221 | #man_show_urls = False 222 | 223 | 224 | # -- Options for Texinfo output ------------------------------------------------ 225 | 226 | # Grouping the document tree into Texinfo files. List of tuples 227 | # (source start file, target name, title, author, 228 | # dir menu entry, description, category) 229 | texinfo_documents = [ 230 | ('index', 'django-waffle', u'django-waffle Documentation', 231 | u'James Socol', 'django-waffle', 'One line description of project.', 232 | 'Miscellaneous'), 233 | ] 234 | 235 | # Documents to append as an appendix to all manuals. 236 | #texinfo_appendices = [] 237 | 238 | # If false, no module index is generated. 239 | #texinfo_domain_indices = True 240 | 241 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 242 | #texinfo_show_urls = 'footnote' 243 | -------------------------------------------------------------------------------- /waffle/tests/test_waffle.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import random 4 | 5 | from django.contrib.auth.models import AnonymousUser, Group, User 6 | from django.db import connection 7 | from django.test import RequestFactory 8 | from django.test.utils import override_settings 9 | 10 | import mock 11 | 12 | import waffle 13 | from test_app import views 14 | from waffle.middleware import WaffleMiddleware 15 | from waffle.models import Flag, Sample, Switch 16 | from waffle.tests.base import TestCase 17 | 18 | 19 | def get(**kw): 20 | request = RequestFactory().get('/foo', data=kw) 21 | request.user = AnonymousUser() 22 | return request 23 | 24 | 25 | def process_request(request, view): 26 | response = view(request) 27 | return WaffleMiddleware().process_response(request, response) 28 | 29 | 30 | class WaffleTests(TestCase): 31 | def test_persist_active_flag(self): 32 | Flag.objects.create(name='myflag', percent='0.1') 33 | request = get() 34 | 35 | # Flag stays on. 36 | request.COOKIES['dwf_myflag'] = 'True' 37 | response = process_request(request, views.flag_in_view) 38 | self.assertEqual(b'on', response.content) 39 | assert 'dwf_myflag' in response.cookies 40 | self.assertEqual('True', response.cookies['dwf_myflag'].value) 41 | 42 | def test_persist_inactive_flag(self): 43 | Flag.objects.create(name='myflag', percent='99.9') 44 | request = get() 45 | 46 | # Flag stays off. 47 | request.COOKIES['dwf_myflag'] = 'False' 48 | response = process_request(request, views.flag_in_view) 49 | self.assertEqual(b'off', response.content) 50 | assert 'dwf_myflag' in response.cookies 51 | self.assertEqual('False', response.cookies['dwf_myflag'].value) 52 | 53 | def test_no_set_unused_flag(self): 54 | """An unused flag shouldn't have its cookie reset.""" 55 | request = get() 56 | request.COOKIES['dwf_unused'] = 'True' 57 | response = process_request(request, views.flag_in_view) 58 | assert 'dwf_unused' not in response.cookies 59 | 60 | def test_superuser(self): 61 | """Test the superuser switch.""" 62 | Flag.objects.create(name='myflag', superusers=True) 63 | request = get() 64 | response = process_request(request, views.flag_in_view) 65 | self.assertEqual(b'off', response.content) 66 | assert 'dwf_myflag' not in response.cookies 67 | 68 | superuser = User(username='foo', is_superuser=True) 69 | request.user = superuser 70 | response = process_request(request, views.flag_in_view) 71 | self.assertEqual(b'on', response.content) 72 | assert 'dwf_myflag' not in response.cookies 73 | 74 | non_superuser = User(username='bar', is_superuser=False) 75 | non_superuser.save() 76 | request.user = non_superuser 77 | response = process_request(request, views.flag_in_view) 78 | self.assertEqual(b'off', response.content) 79 | assert 'dwf_myflag' not in response.cookies 80 | 81 | def test_staff(self): 82 | """Test the staff switch.""" 83 | Flag.objects.create(name='myflag', staff=True) 84 | request = get() 85 | response = process_request(request, views.flag_in_view) 86 | self.assertEqual(b'off', response.content) 87 | assert 'dwf_myflag' not in response.cookies 88 | 89 | staff = User(username='foo', is_staff=True) 90 | request.user = staff 91 | response = process_request(request, views.flag_in_view) 92 | self.assertEqual(b'on', response.content) 93 | assert 'dwf_myflag' not in response.cookies 94 | 95 | non_staff = User(username='foo', is_staff=False) 96 | non_staff.save() 97 | request.user = non_staff 98 | response = process_request(request, views.flag_in_view) 99 | self.assertEqual(b'off', response.content) 100 | assert 'dwf_myflag' not in response.cookies 101 | 102 | def test_languages(self): 103 | Flag.objects.create(name='myflag', languages='en,fr') 104 | request = get() 105 | response = process_request(request, views.flag_in_view) 106 | self.assertEqual(b'off', response.content) 107 | 108 | request.LANGUAGE_CODE = 'en' 109 | response = process_request(request, views.flag_in_view) 110 | self.assertEqual(b'on', response.content) 111 | 112 | request.LANGUAGE_CODE = 'de' 113 | response = process_request(request, views.flag_in_view) 114 | self.assertEqual(b'off', response.content) 115 | 116 | def test_user(self): 117 | """Test the per-user switch.""" 118 | user = User.objects.create(username='foo') 119 | flag = Flag.objects.create(name='myflag') 120 | flag.users.add(user) 121 | 122 | request = get() 123 | request.user = user 124 | response = process_request(request, views.flag_in_view) 125 | self.assertEqual(b'on', response.content) 126 | assert 'dwf_myflag' not in response.cookies 127 | 128 | request.user = User.objects.create(username='someone_else') 129 | response = process_request(request, views.flag_in_view) 130 | self.assertEqual(b'off', response.content) 131 | assert 'dwf_myflag' not in response.cookies 132 | 133 | def test_group(self): 134 | """Test the per-group switch.""" 135 | group = Group.objects.create(name='foo') 136 | user = User.objects.create(username='bar') 137 | user.groups.add(group) 138 | 139 | flag = Flag.objects.create(name='myflag') 140 | flag.groups.add(group) 141 | 142 | request = get() 143 | request.user = user 144 | response = process_request(request, views.flag_in_view) 145 | self.assertEqual(b'on', response.content) 146 | assert 'dwf_myflag' not in response.cookies 147 | 148 | request.user = User(username='someone_else') 149 | request.user.save() 150 | response = process_request(request, views.flag_in_view) 151 | self.assertEqual(b'off', response.content) 152 | assert 'dwf_myflag' not in response.cookies 153 | 154 | def test_authenticated(self): 155 | """Test the authenticated/anonymous switch.""" 156 | Flag.objects.create(name='myflag', authenticated=True) 157 | 158 | request = get() 159 | response = process_request(request, views.flag_in_view) 160 | self.assertEqual(b'off', response.content) 161 | assert 'dwf_myflag' not in response.cookies 162 | 163 | request.user = User(username='foo') 164 | assert request.user.is_authenticated() 165 | response = process_request(request, views.flag_in_view) 166 | self.assertEqual(b'on', response.content) 167 | assert 'dwf_myflag' not in response.cookies 168 | 169 | def test_everyone_on(self): 170 | """Test the 'everyone' switch on.""" 171 | Flag.objects.create(name='myflag', everyone=True) 172 | 173 | request = get() 174 | request.COOKIES['dwf_myflag'] = 'False' 175 | response = process_request(request, views.flag_in_view) 176 | self.assertEqual(b'on', response.content) 177 | assert 'dwf_myflag' not in response.cookies 178 | 179 | request.user = User(username='foo') 180 | assert request.user.is_authenticated() 181 | response = process_request(request, views.flag_in_view) 182 | self.assertEqual(b'on', response.content) 183 | assert 'dwf_myflag' not in response.cookies 184 | 185 | def test_everyone_off(self): 186 | """Test the 'everyone' switch off.""" 187 | Flag.objects.create(name='myflag', everyone=False, 188 | authenticated=True) 189 | 190 | request = get() 191 | request.COOKIES['dwf_myflag'] = 'True' 192 | response = process_request(request, views.flag_in_view) 193 | self.assertEqual(b'off', response.content) 194 | assert 'dwf_myflag' not in response.cookies 195 | 196 | request.user = User(username='foo') 197 | assert request.user.is_authenticated() 198 | response = process_request(request, views.flag_in_view) 199 | self.assertEqual(b'off', response.content) 200 | assert 'dwf_myflag' not in response.cookies 201 | 202 | def test_percent(self): 203 | """If you have no cookie, you get a cookie!""" 204 | Flag.objects.create(name='myflag', percent='50.0') 205 | request = get() 206 | response = process_request(request, views.flag_in_view) 207 | assert 'dwf_myflag' in response.cookies 208 | 209 | @mock.patch.object(random, 'uniform') 210 | def test_reroll(self, uniform): 211 | """Even without a cookie, calling flag_is_active twice should return 212 | the same value.""" 213 | Flag.objects.create(name='myflag', percent='50.0') 214 | # Make sure we're not really random. 215 | request = get() # Create a clean request. 216 | assert not hasattr(request, 'waffles') 217 | uniform.return_value = '10' # < 50. Flag is True. 218 | assert waffle.flag_is_active(request, 'myflag') 219 | assert hasattr(request, 'waffles') # We should record this flag. 220 | assert 'myflag' in request.waffles 221 | assert request.waffles['myflag'][0] 222 | uniform.return_value = '70' # > 50. Normally, Flag would be False. 223 | assert waffle.flag_is_active(request, 'myflag') 224 | assert request.waffles['myflag'][0] 225 | 226 | def test_undefined(self): 227 | """Undefined flags are always false.""" 228 | request = get() 229 | assert not waffle.flag_is_active(request, 'foo') 230 | 231 | @override_settings(WAFFLE_FLAG_DEFAULT=True) 232 | def test_undefined_default(self): 233 | """WAFFLE_FLAG_DEFAULT controls undefined flags.""" 234 | request = get() 235 | assert waffle.flag_is_active(request, 'foo') 236 | 237 | @override_settings(WAFFLE_OVERRIDE=True) 238 | def test_override(self): 239 | request = get(foo='1') 240 | Flag.objects.create(name='foo') # Off for everyone. 241 | assert waffle.flag_is_active(request, 'foo') 242 | 243 | def test_testing_flag(self): 244 | Flag.objects.create(name='foo', testing=True) 245 | request = get(dwft_foo='1') 246 | assert waffle.flag_is_active(request, 'foo') 247 | assert 'foo' in request.waffle_tests 248 | assert request.waffle_tests['foo'] 249 | 250 | # GET param should override cookie 251 | request = get(dwft_foo='0') 252 | request.COOKIES['dwft_foo'] = 'True' 253 | assert not waffle.flag_is_active(request, 'foo') 254 | assert 'foo' in request.waffle_tests 255 | assert not request.waffle_tests['foo'] 256 | 257 | def test_testing_disabled_flag(self): 258 | Flag.objects.create(name='foo') 259 | request = get(dwft_foo='1') 260 | assert not waffle.flag_is_active(request, 'foo') 261 | assert not hasattr(request, 'waffle_tests') 262 | 263 | request = get(dwft_foo='0') 264 | assert not waffle.flag_is_active(request, 'foo') 265 | assert not hasattr(request, 'waffle_tests') 266 | 267 | def test_set_then_unset_testing_flag(self): 268 | Flag.objects.create(name='myflag', testing=True) 269 | response = self.client.get('/flag_in_view?dwft_myflag=1') 270 | self.assertEqual(b'on', response.content) 271 | 272 | response = self.client.get('/flag_in_view') 273 | self.assertEqual(b'on', response.content) 274 | 275 | response = self.client.get('/flag_in_view?dwft_myflag=0') 276 | self.assertEqual(b'off', response.content) 277 | 278 | response = self.client.get('/flag_in_view') 279 | self.assertEqual(b'off', response.content) 280 | 281 | response = self.client.get('/flag_in_view?dwft_myflag=1') 282 | self.assertEqual(b'on', response.content) 283 | 284 | 285 | class SwitchTests(TestCase): 286 | def test_switch_active(self): 287 | switch = Switch.objects.create(name='myswitch', active=True) 288 | assert waffle.switch_is_active(switch.name) 289 | 290 | def test_switch_inactive(self): 291 | switch = Switch.objects.create(name='myswitch', active=False) 292 | assert not waffle.switch_is_active(switch.name) 293 | 294 | def test_switch_active_from_cache(self): 295 | """Do not make two queries for an existing active switch.""" 296 | switch = Switch.objects.create(name='myswitch', active=True) 297 | # Get the value once so that it will be put into the cache 298 | assert waffle.switch_is_active(switch.name) 299 | queries = len(connection.queries) 300 | assert waffle.switch_is_active(switch.name) 301 | self.assertEqual(queries, len(connection.queries), 'We should only make one query.') 302 | 303 | def test_switch_inactive_from_cache(self): 304 | """Do not make two queries for an existing inactive switch.""" 305 | switch = Switch.objects.create(name='myswitch', active=False) 306 | # Get the value once so that it will be put into the cache 307 | assert not waffle.switch_is_active(switch.name) 308 | queries = len(connection.queries) 309 | assert not waffle.switch_is_active(switch.name) 310 | self.assertEqual(queries, len(connection.queries), 'We should only make one query.') 311 | 312 | def test_undefined(self): 313 | assert not waffle.switch_is_active('foo') 314 | 315 | @override_settings(WAFFLE_SWITCH_DEFAULT=True) 316 | def test_undefined_default(self): 317 | assert waffle.switch_is_active('foo') 318 | 319 | @override_settings(DEBUG=True) 320 | def test_no_query(self): 321 | """Do not make two queries for a non-existent switch.""" 322 | assert not Switch.objects.filter(name='foo').exists() 323 | queries = len(connection.queries) 324 | assert not waffle.switch_is_active('foo') 325 | assert len(connection.queries) > queries, 'We should make one query.' 326 | queries = len(connection.queries) 327 | assert not waffle.switch_is_active('foo') 328 | self.assertEqual(queries, len(connection.queries), 'We should only make one query.') 329 | 330 | 331 | class SampleTests(TestCase): 332 | def test_sample_100(self): 333 | sample = Sample.objects.create(name='sample', percent='100.0') 334 | assert waffle.sample_is_active(sample.name) 335 | 336 | def test_sample_0(self): 337 | sample = Sample.objects.create(name='sample', percent='0.0') 338 | assert not waffle.sample_is_active(sample.name) 339 | 340 | def test_undefined(self): 341 | assert not waffle.sample_is_active('foo') 342 | 343 | @override_settings(WAFFLE_SAMPLE_DEFAULT=True) 344 | def test_undefined_default(self): 345 | assert waffle.sample_is_active('foo') 346 | --------------------------------------------------------------------------------