diff --git a/.travis.yml b/.travis.yml index 5471614..4d94572 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,20 +5,12 @@ matrix: include: - env: TOXENV=lint python: 3.6 - - env: TOXENV=py27-dj18-wag112 - python: 2.7 - env: TOXENV=py27-dj18-wag113 python: 2.7 - - env: TOXENV=py27-dj111-wag112 - python: 2.7 - env: TOXENV=py27-dj111-wag113 python: 2.7 - - env: TOXENV=py36-dj18-wag112 - python: 3.6 - env: TOXENV=py36-dj18-wag113 python: 3.6 - - env: TOXENV=py36-dj111-wag112 - python: 3.6 - env: TOXENV=py36-dj111-wag113 python: 3.6 - env: TOXENV=py36-dj20-wag20 @@ -32,3 +24,13 @@ script: after_success: coveralls + +deploy: + - provider: pypi + distributions: "sdist bdist_wheel --universal" + user: cfpb + password: + secure: "RQmVt7ulZ1qTr5xycobPguA6ctKfqdDaNEtjEdjgelk74g+N9lSMjGssEX0anqn3pQT0DTquby+Xb0C/MRhuWeNSTa61LESGKZ4nT02VAHsmEcmgxpcGbhtf/zvu4fztHNocF2ffSpsyq0n0YpJoT5N2znlw5sPX/aHNd+2m2tkiQu6yqUFR5PWcLDcz4KJNmyDk0miUTRHucS0yFanBsYjVCBzEWAWtnAxq5DCOW78dHOncfTCeAsMpjT/eVHOLldk9giEvBs/OJgWjFq8c/sVt3owCKhgkwbA1htXFGxofo9gl+m72cop3GEmUlOoTOMOs1AFTTMQQchyXqoLvDglRcBfOp+UhzCjEmckr7bKECa+Ok+8IeO7qyCR5Z1PsvMa5Vf12ZyEOsYs5QivQuA8tTKdB9Pni3rb/f0EqTLoWGb2Lg/WhP4GRSGxOC2SM4jJTEoi6bgzV3bvg1YIAPZIuK1whHPlo6EA1NzEOO/57iFw2G3R14Kw1B5QMXXLdKXulPc/FYRCcygqEkHl8nIe8FexeGEpJeC8URx+Gy9F0YxBL6Aqu3Jk09yhfyHTBbBaRCodfAUzt1/Ula3tOzYlIObxZhZnxNEETa3xY8lZWchXf4FEXleqsM1dGHprG1+dwBJwt1szTphd+kqc5I6/uuv1iVFOh4JAcCethG9s=" + on: + tags: true + condition: $TOXENV = "py36-dj111-wag113" diff --git a/README.md b/README.md index f9e479a..11bb745 100644 --- a/README.md +++ b/README.md @@ -3,25 +3,16 @@ [![Build Status](https://travis-ci.org/cfpb/wagtail-flags.svg?branch=master)](https://travis-ci.org/cfpb/wagtail-flags) [![Coverage Status](https://coveralls.io/repos/github/cfpb/wagtail-flags/badge.svg?branch=master)](https://coveralls.io/github/cfpb/wagtail-flags?branch=master) -Feature flags allow you to toggle functionality in both Django settings and the Wagtail or Django admin based on configurable conditions. +Feature flags allow you to toggle functionality in the Wagtail based on configurable conditions. + +Wagtail-Flags adds a Wagtail admin UI and Wagtail Site-based condition on top of [Django-Flags](https://github.com/cfpb/django-flags). For a more complete overview of feature flags and how to use them, please see the [Django-Flags documentation](https://cfpb.github.io/django-flags). ![Feature flags in the Wagtail admin](https://raw.githubusercontent.com/cfpb/wagtail-flags/master/screenshot_list.png) - [Dependencies](#dependencies) - [Installation](#installation) -- [Concepts](#concepts) - [Usage](#usage) - - [Quickstart](#quickstart) - - [Defining flags](#defining-flags) - - [Using flags in code](#using-flags-in-code) - - [Built-in conditions](#built-in-conditions) -- [API](#api) - - [Flag state](#flag-state) - - [Flag decorators](#flag-decorators) - - [Flagged URL patterns](#flagged-url-patterns) - - [Django templates](#django-templates) - - [Jinja2 templates](#jinja2-templates) - - [Conditions](#conditions) +- [Extended conditions](#built-in-conditions) - [Getting help](#getting-help) - [Getting involved](#getting-involved) - [Licensing](#licensing) @@ -31,6 +22,7 @@ Feature flags allow you to toggle functionality in both Django settings and the - Django 1.8+ (including Django 2.0) - Wagtail 1.10+ (including Wagtail 2.0) +- Django-Flags 3.0+ - Python 2.7+, 3.6+ ## Installation @@ -41,27 +33,20 @@ Feature flags allow you to toggle functionality in both Django settings and the pip install wagtail-flags ``` -2. Add `flags` as an installed app in your Django `settings.py`: +2. Add `flags` and `wagtailflags` as installed apps in your Django `settings.py`: ```python INSTALLED_APPS = ( ... 'flags', + 'wagtailflags', ... ) ``` -## Concepts - -Feature flags in Wagtail-Flags are identified by simple strings that are enabled when the conditions they are associated with are met. These flags can be used to wrap code and template content that should only be used when a flag is enabled or disabled. - -Conditions determine whether a flag is enabled or disabled by comparing a defined expected value of some kind with the value at the time the flag is checked. In many cases, the flag is checked during a request, and some piece of the request's metadata is what is compared. For example, a feature flag that is enabled for a specific Wagtail Site would be enabled if the request's site matches the condition's site. - ## Usage -### Quickstart - -To use Wagtail-Flags you first need to define the flag, use the flag, and define conditions for the flag to be enabled. +Please see the [Django-Flags documentation](https://cfpb.github.io/django-flags) for the most current information about defining and checking feature flags. First, define the flag in Django `settings.py`: @@ -84,9 +69,7 @@ Then use the flag in a Django template (`mytemplate.html`): {% endif %} ``` -Configure a URL for that template (`urls.py`): - -Django 2.0: +Next, configure a URL for that template (`urls.py`): ```python from django.urls import path @@ -97,142 +80,13 @@ urlpatterns = [ ] ``` -Django 1.x: - -```python -from django.conf.urls import url -from django.views.generic import TemplateView - -urlpatterns = [ - url(r'^mypage/$', TemplateView.as_view(template_name='mytemplate.html')), -] -``` - -Then in the Wagtail admin add conditions for the flag in "Settings", "Flags": +Finally, add conditions for the flag in the Wagtail admin under "Settings", "Flags": ![Creating conditions in the Wagtail admin](https://raw.githubusercontent.com/cfpb/wagtail-flags/master/screenshot_create.png) -Then visiting the URL `/mypage?enable_my_flag=True` should show you the flagged `
` in the template. - -### Adding flags - -### Defining flags - -Flags are defined in Django settings with the conditions in which they are enabled. - -```python -FLAGS = { - 'FLAG_WITH_EMPTY_CONDITIONS': {} - 'MY_FLAG': { - 'condition name': 'value flag is expected to match to be enabled', - 'user': 'lady.liberty' - } -} -``` - -The set of conditions can be none (flag will never be enabled), one (only condition that has to be met for the flag to be enabled), or many (all have to be met for the flag to be enabled). - -Additional conditions can be added in the Django or Wagtail admin for any defined flag (illustrated in [Usage](#usage)). Conditions added in the Django or Wagtail admin can be changed without restarting Django, conditions defined in `settings.py` cannot. See below [for a list of built-in conditions](#built-in-conditions). - -### Using flags in code - -Flags can be used in Python code: - -```python -from flags.state import flag_enabled - -if flag_enabled('MY_FLAG', request=a_request): - print("My feature flag is enabled") -``` - -Django templates: - -```django -{% load feature_flags %} -{% flag_enabled 'MY_FLAG' as my_flag %} -{% if my_flag %} -
- I’m the result of a feature flag. -
-{% endif %} -``` - -Jinja2 templates (after [adding `flag_enabled` to the Jinja2 environment](#jinja2-templates)): - -```jinja -{% if flag_enabled('MY_FLAG', request) %} -
- I’m the result of a feature flag. -
-{% endif %} -``` - -Django 2.0 `urls.py`: - -```python -from flags.urls import flagged_path - -urlpatterns = [ - flagged_path('MY_FLAG', 'a-url/', view_requiring_flag, state=True), -] -``` - -And Django 1.x `urls.py`: - -```python -from flags.urls import flagged_url - -urlpatterns = [ - flagged_url('MY_FLAG', r'^a-url$', view_requiring_flag, state=True), -] -``` - -See the [API documentation below](#api) for more details and examples. - - -#### Built-in conditions - -Wagtail-Flags comes with the following conditions built-in: - -##### `boolean` - -A simple boolean true/false intended to enable or disable a flag explicitly. The state of the flag evaluates to the value of the boolean condition. +## Extended conditions -```python -FLAGS = {'MY_FLAG': {'boolean': True}} -``` - -##### `user` - -Allows a flag to be enabled for the username given as the condition's value. - -```python -FLAGS = {'MY_FLAG': {'user': 'jane.doe'}} -``` - -##### `anonymous` - -Allows a flag to be either enabled or disabled depending on the condition's boolean value. - -```python -FLAGS = {'MY_FLAG': {'anonymous: False}} -``` - -##### `parameter` - -Allows a flag to be enabled based on a GET parameter with the name given as the condition's value. - -```python -FLAGS = {'MY_FLAG': {'parameter': 'my_flag_param'}} -``` - -##### `path` - -Allows a flag to be enabled if the request's path matches the condition value. - -```python -FLAGS = {'MY_FLAG': {'path': '/flagged/path'}} -``` +Wagtail-Flags adds the following conditions to Django-Flags: ##### `site` @@ -242,294 +96,6 @@ Allows a flag to be enabled for a Wagtail site that matches the hostname and por FLAGS = {'MY_FLAG': {'site': 'staging.mysite.com'}} ``` -##### `after date` - -Allows a flag to be enabled after a given date (and time) given in [ISO 8601 format](https://en.wikipedia.org/wiki/ISO_8601). The time must be specified either in UTC or as an offset from UTC. - -```python -FLAGS = {'MY_FLAG': {'after date': '2017-06-01T12:00Z'}} -``` - -## API - -### Flag state - -```python -from flags.state import ( - flag_state, - flag_enabled, - flag_disabled, -) -``` - -#### `flag_state(flag_name, **kwargs)` - -Return the value for the flag (`True` or `False`) by passing kwargs to its conditions. - -#### `flag_enabled(flag_name, **kwargs)` - -Returns `True` if a flag is enabled by passing kwargs to its conditions, otherwise returns `False`. - -```python -if flag_enabled('MY_FLAG', request=a_request): - print("My feature flag is enabled") -``` - -#### `flag_disabled(flag_name, **kwargs)` - -Returns `True` if a flag is disabled by passing kwargs to its conditions, otherwise returns `False`. - -```python -if flag_disabled('MY_FLAG', request=a_request): - print(“My feature flag is disabled”) -``` - -### Flag decorators - -Decorators are provided for use with Django views and conditions that take a `request` argument. The default behavior is to return a 404 if a callable fallback is not given. - -```python -from flags.decorators import ( - flag_check, - flag_required, -) -``` - -#### `flag_check(flag_name, state, fallback=None, **kwargs)` - -Check that a given flag has the given state. If the state does not match, perform the fallback. - -**Note**, because flags that do not exist are taken to be `False` by default, `@flag_check('MY_FLAG', False)` and `@flag_check('MY_FLAG', None)` will both succeed if `MY_FLAG` does not exist. - -```python -from flags.decorators import flag_check - -@flag_check('MY_FLAG', True) -def view_requiring_flag(request): - return HttpResponse('flag was set') - -@flag_check('MY_OTHER_FLAG', False) -def view_when_flag_is_not_set(request): - return HttpResponse('flag was set') - -def other_view(request): - return HttpResponse('flag was not set') - -@flag_check('MY_FLAG_WITH_FALLBACK', True, fallback=other_view) -def view_with_fallback(request): - return HttpResponse('flag was set') -``` - -#### `flag_required(flag_name, fallback_view=None, pass_if_set=True)` - -Require the given flag to be enabled. - -```python -from flags.decorators import flag_required - -@flag_required('MY_FLAG') -def view_requiring_flag(request): - return HttpResponse('flag was set') - -def other_view(request): - return HttpResponse('flag was not set') - -@flag_required('MY_FLAG_WITH_FALLBACK', fallback_view=other_view) -def view_with_fallback(request): - return HttpResponse('flag was set') -``` - -### Flagged URL patterns - -Flagged URL patterns are an alternative to [flagging views with decorators](https://github.com/cfpb/wagtail-flags#flag_checkflag_name-state-fallbacknone-kwargs). - -Django 2.0+: - -```python -from flags.urls import flagged_path, flagged_paths, flagged_re_path, flagged_re_paths -``` - -Django 1.x: - -```python -from flags.urls import flagged_url, flagged_urls -``` - -#### `flagged_path(flag_name, route, view, kwargs=None, name=None, state=True, fallback=None)` -#### `flagged_re_path(flag_name, route, view, kwargs=None, name=None, state=True, fallback=None)` -#### `flagged_url(flag_name, regex, view, kwargs=None, name=None, state=True, fallback=None)` - -Make a URL depend on the state of a feature flag. - -`flagged_path()` can be used in place of [Django's `path()`](https://docs.djangoproject.com/en/2.0/ref/urls/#django.urls.path). - -`flagged_re_path()` can be used in place of [Django's `re_path()`](https://docs.djangoproject.com/en/2.0/ref/urls/#django.urls.re_path). - -`flagged_url()` *is only available with Django 1.x* and can be used in place of [Django's `url()`](https://docs.djangoproject.com/en/1.11/ref/urls/#django.conf.urls.url). - -The `view` and the `fallback` can both be a set of `include()`ed patterns but any matching URL patterns in the includes must match *exactly* in terms of regular expression, keyword arguments, and name, otherwise a `404` may be unexpectedly raised. - -If a `fallback` is not given the flagged url will raise a `404` if the flag state does not match the required `state`. - -```python -urlpatterns = [ - flagged_path('MY_FLAG', r'a-url/', view_requiring_flag, state=True), - flagged_re_path('MY_FLAG_WITH_FALLBACK', r'^another-url$', - view_with_fallback, state=True, fallback=other_view) - flagged_path('MY_FLAGGED_INCLUDE', 'myapp/', include('myapp.urls'), - state=True, fallback=other_view) - flagged_re_path('MY_NEW_APP_FLAG', r'^mynewapp$', include('mynewapp.urls'), - state=True, fallback=include('myoldapp.urls')) -] -``` - -#### `flagged_paths(flag_name, state=True, fallback=None)` -#### `flagged_re_paths(flag_name, state=True, fallback=None)` -#### `flagged_urls(flag_name, state=True, fallback=None)` - -Flag multiple URLs in the same context with a context manager. - -`flagged_paths()` returns a function that takes the same arguments as [Django's `path()`](https://docs.djangoproject.com/en/2.0/ref/urls/#django.urls.path) and which will flag the pattern's view. - -`flagged_re_paths()` returns a function that takes the same arguments as [Django's `re_path()`](https://docs.djangoproject.com/en/2.0/ref/urls/#django.urls.re_path) and which will flag the pattern's view. - -`flagged_urls()` *is only available with Django 1.x* and returns a function that takes the same arguments as [Django's `url()`](https://docs.djangoproject.com/en/1.11/ref/urls/#django.conf.urls.url). - -Returns function that can be used in place of Django's `url()` that wraps `flagged_url()`. Can take an optional fallback view that will apply to all urls. - -```python -with flagged_paths('MY_FLAG') as path: - flagged_url_patterns = [ - path('a-url/', view_requiring_flag), - ] - -urlpatterns = urlpatterns + flagged_url_patterns -``` - -### Django templates - -Wagtail-Flags provides a template tag library that can be used to evaluate flags in Django templates. - -```django -{% load feature_flags %} -``` - -#### `flag_enabled` - -Returns `True` if a flag is enabled by passing the current request to its conditions, otherwise returns `False`. - -```django -{% flag_enabled 'MY_FLAG' as my_flag %} -{% if my_flag %} -
- I’m the result of a feature flag. -
-{% endif %} -``` - -#### `flag_disabled` - -Returns `True` if a flag is disabled by passing the current request to its conditions, otherwise returns `False`. - -```django -{% flag_disabled 'MY_FLAG' as my_flag %} -{% if my_flag %} -
- I’m the result of a feature flag that is not enabled. -
-{% endif %} -``` - -### Jinja2 templates - -Wagtail-Flags provides template functions that can be added to a Jinja2 environment and subsequently used in templates. - -```python -from flags.template_functions import ( - flag_enabled, - flag_disabled -) -from jinja2 import Environment - -... - -env = Environment(…) -env.globals.update( - flag_enabled=flag_enabled, - flag_disabled=flag_disabled -) -``` - -#### `flag_enabled` - -Returns `True` if a flag is enabled by for the given request, otherwise returns `False`. - -```jinja -{% if flag_enabled('MY_FLAG', request) %} -
- I’m the result of a feature flag. -
-{% endif %} -``` - -#### `flag_disabled` - -Returns `True` if a flag is disabled by passing the current request to its conditions, otherwise returns `False`. -Returns `True` if a flag is disabled by for the given request, otherwise returns `False`. - -```jinja -{% if flag_disabled('MY_FLAG', request) %} -
- I’m the result of a feature flag that is not enabled. -
-{% endif %} -``` - - -### Conditions - -Conditions are functions that take a configured value and possible keyword arguments and determines whether the given arguments are equivalent to the value. Conditions are registered with a unique name that is exposed to users in Django settings and the Django and Wagtail admin. - -```python -from flags import conditions -``` - -#### `conditions.register(condition_name, fn=None)` - -Register a new condition, either as a decorator: - -```python -from flags import conditions - -@conditions.register('path') -def path_condition(path, request=None, **kwargs): - return request.path.startswith(path) -``` - -Or as a function call: - -```python -def path_condition(path, request=None, **kwargs): - return request.path.startswith(path) - -conditions.register('path', fn=path_condition) -``` - -#### `conditions.RequiredForCondition` - -Exception intended to be raised when a condition is not given a keyword argument it requires for evaluation. - -```python -@conditions.register('path') -def path_condition(path, request=None, **kwargs): - if request is None: - raise conditions.RequiredForCondition( - "request is required for condition 'path'") - - return request.path.startswith(path) -``` - - ## Getting help Please add issues to the [issue tracker](https://github.com/cfpb/wagtail-flags/issues). diff --git a/flags/__init__.py b/flags/__init__.py deleted file mode 100644 index a8212c9..0000000 --- a/flags/__init__.py +++ /dev/null @@ -1 +0,0 @@ -default_app_config = 'flags.apps.WagtailFlagsConfig' diff --git a/flags/admin.py b/flags/admin.py deleted file mode 100644 index 4389ebd..0000000 --- a/flags/admin.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.contrib import admin - -from flags.models import FlagState - - -admin.site.register(FlagState) diff --git a/flags/apps.py b/flags/apps.py deleted file mode 100644 index 9652898..0000000 --- a/flags/apps.py +++ /dev/null @@ -1,11 +0,0 @@ -from django.apps import AppConfig - -from flags.settings import add_flags_from_sources - - -class WagtailFlagsConfig(AppConfig): - name = 'flags' - verbose_name = 'Wagtail Flags' - - def ready(self): - add_flags_from_sources() diff --git a/flags/conditions.py b/flags/conditions.py deleted file mode 100644 index 882f76f..0000000 --- a/flags/conditions.py +++ /dev/null @@ -1,161 +0,0 @@ -import re - -import django -from django.apps import apps -from django.core.exceptions import ObjectDoesNotExist -from django.utils import dateparse, timezone - - -# This will be maintained by register() as a global dictionary of -# condition_name: [list of functions], so we can have multiple conditions -# for a given name and they all must pass when checking. -CONDITIONS = {} - - -class RequiredForCondition(Exception): - """ Raised when a kwarg that is required for a condition is not given """ - - -def register(condition_name, fn=None): - """ Register a condition to test for flag state. Can be decorator. - Conditions can be any callable that takes a value and some number of - required arguments (specified in 'requires') that were passed as kwargs - when checking the flag state. """ - global CONDITIONS - - # Don't be a decorator, just register - if fn is None: - # Be a decorator - def decorator(fn): - register(condition_name, fn=fn) - return fn - return decorator - - if condition_name not in CONDITIONS: - CONDITIONS[condition_name] = [] - - CONDITIONS[condition_name].append(fn) - - -def get_conditions(): - """ Return the names of all available conditions """ - return CONDITIONS.keys() - - -def get_condition(condition_name): - """ Generator to fetch condition checkers from the registry """ - if condition_name not in CONDITIONS: - raise StopIteration - for condition_fn in CONDITIONS[condition_name]: - yield condition_fn - - -@register('boolean') -def boolean_condition(condition, **kwargs): - """ Basic boolean check """ - try: - if condition.lower() == 'true': - return True - return False - except AttributeError: - return bool(condition) - - -@register('user') -def user_condition(username, request=None, **kwargs): - """ Does request.user match the expected username? """ - if request is None: - raise RequiredForCondition("request is required for condition " - "'user'") - - User = apps.get_model('auth', 'User') - try: - return request.user == User.objects.get(username=username) - except ObjectDoesNotExist: - return False - - -@register('anonymous') -def anonymous_condition(boolean_value, request=None, **kwargs): - """ request.user an anonymous user, true or false based on boolean_value - """ - if request is None: - raise RequiredForCondition("request is required for condition " - "'anonymous'") - - if django.VERSION[0] >= 2: - return bool(boolean_value) == bool(request.user.is_anonymous) - else: - return bool(boolean_value) == bool(request.user.is_anonymous()) - - -@register('parameter') -def parameter_condition(param_name, request=None, **kwargs): - """ is the parameter name part of the GET parameters? """ - if request is None: - raise RequiredForCondition("request is required for condition " - "'parameter'") - - return request.GET.get(param_name) == 'True' - - -@register('path matches') -def path_condition(pattern, request=None, **kwargs): - """ Does the request's path match the given regular expression? """ - if request is None: - raise RequiredForCondition("request is required for condition " - "'path'") - - return bool(re.search(pattern, request.path)) - - -@register('site') -def site_condition(site_str, request=None, **kwargs): - """ Does the requests's Wagtail Site match the given site? - site_str should be 'hostname:port', or 'hostname [default]'. """ - if request is None: - raise RequiredForCondition("request is required for condition " - "'site'") - - Site = apps.get_model('wagtailcore.Site') - - if '[default]' in site_str: - # Wagtail Sites on the default port have [default] at the end of - # their str() form. - site_str = site_str.replace(' [default]', ':80') - elif ':' not in site_str: - # Add a default port if one isn't given - site_str += ':80' - - hostname, port = site_str.split(':') - try: - conditional_site = Site.objects.get(hostname=hostname, port=port) - except ObjectDoesNotExist: - return False - - try: - site = Site.find_for_request(request) - except AttributeError: - # We can't do anything with this - return False - - return conditional_site == site - - -@register('after date') -def date_condition(date_or_str, **kwargs): - """ Does the current date match the given date? - date_or_str is either a date object or an ISO 8601 string """ - try: - date = dateparse.parse_datetime(date_or_str) - except TypeError: - date = date_or_str - - now = timezone.now() - - try: - date_test = (now >= date) - except TypeError: - date_test = False - - return date_test diff --git a/flags/decorators.py b/flags/decorators.py deleted file mode 100644 index 8936696..0000000 --- a/flags/decorators.py +++ /dev/null @@ -1,27 +0,0 @@ -from django.http import Http404 -from django.utils.functional import wraps - -from flags.state import flag_state - - -def flag_check(flag_name, state, fallback=None, **fc_kwargs): - """ Check that a given flag has the given state. - If the state does not match, perform the fallback. """ - def decorator(func): - def inner(request, *args, **kwargs): - enabled = flag_state(flag_name, request=request, **fc_kwargs) - - if ((state and enabled) or (not state and not enabled)): - return func(request, *args, **kwargs) - elif fallback is not None: - return fallback(request, *args, **kwargs) - else: - raise Http404 - - return wraps(func)(inner) - - return decorator - - -def flag_required(flag_name, fallback_view=None, pass_if_set=True): - return flag_check(flag_name, pass_if_set, fallback=fallback_view) diff --git a/flags/forms.py b/flags/forms.py deleted file mode 100644 index abfa2b7..0000000 --- a/flags/forms.py +++ /dev/null @@ -1,23 +0,0 @@ -from django import forms - -from flags.conditions import get_conditions -from flags.models import FlagState -from flags.settings import get_flags - - -FLAGS_CHOICES = [(flag, flag) for flag in sorted(get_flags().keys())] -CONDITIONS_CHOICES = [(c, c) for c in sorted(get_conditions())] - - -class FlagStateForm(forms.ModelForm): - name = forms.ChoiceField(choices=FLAGS_CHOICES, - label="Flag", - required=True) - condition = forms.ChoiceField(choices=CONDITIONS_CHOICES, - label="Is enabled when", - required=True) - value = forms.CharField(label="Is", required=True) - - class Meta: - model = FlagState - fields = ('name', 'condition', 'value') diff --git a/flags/migrations/0001_initial.py b/flags/migrations/0001_initial.py deleted file mode 100644 index ba425f0..0000000 --- a/flags/migrations/0001_initial.py +++ /dev/null @@ -1,31 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('wagtailcore', '0019_verbose_names_cleanup'), - ] - - operations = [ - migrations.CreateModel( - name='Flag', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('key', models.CharField(max_length=500)), - ('enabled', models.BooleanField()), - ('site', models.ForeignKey(to='wagtailcore.Site', on_delete=models.CASCADE)), - ], - ), - migrations.CreateModel( - name='SiteSettings', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('default', models.BooleanField()), - ('site', models.ForeignKey(to='wagtailcore.Site', on_delete=models.CASCADE)), - ], - ), - ] diff --git a/flags/migrations/0002_auto_20151030_1401.py b/flags/migrations/0002_auto_20151030_1401.py deleted file mode 100644 index 53dd540..0000000 --- a/flags/migrations/0002_auto_20151030_1401.py +++ /dev/null @@ -1,56 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('wagtailcore', '0019_verbose_names_cleanup'), - ('flags', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='FlagState', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('enabled', models.BooleanField(default=False)), - ], - ), - migrations.RemoveField( - model_name='sitesettings', - name='site', - ), - migrations.RemoveField( - model_name='flag', - name='enabled', - ), - migrations.RemoveField( - model_name='flag', - name='id', - ), - migrations.RemoveField( - model_name='flag', - name='site', - ), - migrations.AlterField( - model_name='flag', - name='key', - field=models.CharField(max_length=255, serialize=False, primary_key=True), - ), - migrations.DeleteModel( - name='SiteSettings', - ), - migrations.AddField( - model_name='flagstate', - name='flag', - field=models.ForeignKey(to='flags.Flag', on_delete=models.CASCADE), - ), - migrations.AddField( - model_name='flagstate', - name='site', - field=models.ForeignKey(to='wagtailcore.Site', on_delete=models.CASCADE), - ), - ] diff --git a/flags/migrations/0003_flag_hidden.py b/flags/migrations/0003_flag_hidden.py deleted file mode 100644 index 6e8146f..0000000 --- a/flags/migrations/0003_flag_hidden.py +++ /dev/null @@ -1,19 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('flags', '0002_auto_20151030_1401'), - ] - - operations = [ - migrations.AddField( - model_name='flag', - name='hidden', - field=models.BooleanField(default=False), - ), - ] diff --git a/flags/migrations/0004_remove_flag_hidden.py b/flags/migrations/0004_remove_flag_hidden.py deleted file mode 100644 index ecf905c..0000000 --- a/flags/migrations/0004_remove_flag_hidden.py +++ /dev/null @@ -1,18 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('flags', '0003_flag_hidden'), - ] - - operations = [ - migrations.RemoveField( - model_name='flag', - name='hidden', - ), - ] diff --git a/flags/migrations/0005_flag_enabled_by_default.py b/flags/migrations/0005_flag_enabled_by_default.py deleted file mode 100644 index 673c84d..0000000 --- a/flags/migrations/0005_flag_enabled_by_default.py +++ /dev/null @@ -1,23 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models - - -def fix_beta_notice(apps, schema_editor): - Flag = apps.get_model('flags','flag') - -class Migration(migrations.Migration): - - dependencies = [ - ('flags', '0004_remove_flag_hidden'), - ] - - operations = [ - migrations.AddField( - model_name='flag', - name='enabled_by_default', - field=models.BooleanField(default=False), - ), - - ] diff --git a/flags/migrations/0006_auto_20151217_2003.py b/flags/migrations/0006_auto_20151217_2003.py deleted file mode 100644 index ee452b3..0000000 --- a/flags/migrations/0006_auto_20151217_2003.py +++ /dev/null @@ -1,21 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models - - -def add_beta_flag(apps,schema_editor): - Flag=apps.get_model('flags','flag') - beta_notice = Flag(key='BETA_NOTICE', enabled_by_default=True) - beta_notice.save() - - -class Migration(migrations.Migration): - - dependencies = [ - ('flags', '0005_flag_enabled_by_default'), - ] - - operations = [ - migrations.RunPython(add_beta_flag) - ] diff --git a/flags/migrations/0007_unique_flag_site.py b/flags/migrations/0007_unique_flag_site.py deleted file mode 100644 index 700caf6..0000000 --- a/flags/migrations/0007_unique_flag_site.py +++ /dev/null @@ -1,28 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('flags', '0006_auto_20151217_2003'), - ] - - operations = [ - migrations.AlterField( - model_name='flagstate', - name='flag', - field=models.ForeignKey(related_name='states', to='flags.Flag', on_delete=models.CASCADE), - ), - migrations.AlterField( - model_name='flagstate', - name='site', - field=models.ForeignKey(related_name='flag_states', to='wagtailcore.Site', on_delete=models.CASCADE), - ), - migrations.AlterUniqueTogether( - name='flagstate', - unique_together=set([('flag', 'site')]), - ), - ] diff --git a/flags/migrations/0008_add_state_conditions.py b/flags/migrations/0008_add_state_conditions.py deleted file mode 100644 index 765a84d..0000000 --- a/flags/migrations/0008_add_state_conditions.py +++ /dev/null @@ -1,29 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('flags', '0007_unique_flag_site'), - ] - - operations = [ - migrations.AddField( - model_name='flagstate', - name='condition', - field=models.CharField(default=b'boolean', max_length=64), - ), - migrations.AddField( - model_name='flagstate', - name='name', - field=models.CharField(default='', max_length=64), - ), - migrations.AddField( - model_name='flagstate', - name='value', - field=models.CharField(default=b'True', max_length=127), - ), - ] diff --git a/flags/migrations/0009_migrate_to_conditional_state.py b/flags/migrations/0009_migrate_to_conditional_state.py deleted file mode 100644 index 1b566f5..0000000 --- a/flags/migrations/0009_migrate_to_conditional_state.py +++ /dev/null @@ -1,47 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models - - -def forwards(apps, schema_editor): - """ Populate the new name, condition, and value fields on FlagState from - old Flag and FlagState fields """ - FlagState = apps.get_model('flags', 'FlagState') - - for state in FlagState.objects.all(): - state.name = state.flag.key - state.condition = 'site' - state.value = "{hostname}:{port}".format( - hostname=state.site.hostname, port=state.site.port) - state.save() - - -def backwards(apps, schema_editor): - """ Populate Flag and FlagState foreign keys from new FlagState fields """ - Flag = apps.get_model('flags', 'Flag') - FlagState = apps.get_model('flags', 'FlagState') - Site = apps.get_model('wagtailcore', 'Site') - - for state in FlagState.objects.all(): - flag = Flag.objects.create(key=state.name) - state.flag = flag - - if ':' not in state.value: - state.value += ':80' - hostname, port = state.value.split(':') - site = Site.objects.get(hostname=hostname, port=port) - state.site = site - - state.save() - - -class Migration(migrations.Migration): - - dependencies = [ - ('flags', '0008_add_state_conditions'), - ] - - operations = [ - migrations.RunPython(forwards, backwards), - ] diff --git a/flags/migrations/0010_delete_flag_site_fk.py b/flags/migrations/0010_delete_flag_site_fk.py deleted file mode 100644 index df1d8e8..0000000 --- a/flags/migrations/0010_delete_flag_site_fk.py +++ /dev/null @@ -1,38 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('flags', '0009_migrate_to_conditional_state'), - ] - - operations = [ - migrations.AlterField( - model_name='flagstate', - name='name', - field=models.CharField(max_length=64), - ), - migrations.AlterUniqueTogether( - name='flagstate', - unique_together=set([('name', 'condition', 'value')]), - ), - migrations.RemoveField( - model_name='flagstate', - name='enabled', - ), - migrations.RemoveField( - model_name='flagstate', - name='flag', - ), - migrations.RemoveField( - model_name='flagstate', - name='site', - ), - migrations.DeleteModel( - name='Flag', - ), - ] diff --git a/flags/migrations/0011_migrate_path_data_startswith_to_matches.py b/flags/migrations/0011_migrate_path_data_startswith_to_matches.py deleted file mode 100644 index abd50c4..0000000 --- a/flags/migrations/0011_migrate_path_data_startswith_to_matches.py +++ /dev/null @@ -1,46 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations - -# Before this migration, it was assumed that the value of the `path` -# condition matched from the start of the requested path. Now, a regex -# can be specified to get as crazy as you want with your matching. - - -def forwards(apps, schema_editor): - modify_flags( - apps, - from_condition='path', - to_condition='path matches', - value_fn=lambda path: '^' + path - ) - - -def backwards(apps, schema_editor): - modify_flags( - apps, - from_condition='path matches', - to_condition='path', - value_fn=lambda path: path.lstrip('^') - ) - - -def modify_flags(apps, from_condition, to_condition, value_fn): - FlagState = apps.get_model('flags', 'flagstate') - - for flag_state in FlagState.objects.filter(condition=from_condition): - flag_state.condition = to_condition - flag_state.value = value_fn(flag_state.value) - flag_state.save() - - -class Migration(migrations.Migration): - - dependencies = [ - ('flags', '0010_delete_flag_site_fk'), - ] - - operations = [ - migrations.RunPython(forwards, backwards), - ] diff --git a/flags/models.py b/flags/models.py deleted file mode 100644 index d7b4144..0000000 --- a/flags/models.py +++ /dev/null @@ -1,15 +0,0 @@ -from django.db import models - - -class FlagState(models.Model): - name = models.CharField(max_length=64) - condition = models.CharField(max_length=64, default='boolean') - value = models.CharField(max_length=127, default='True') - - class Meta: - app_label = 'flags' - unique_together = ('name', 'condition', 'value') - - def __str__(self): - return "{name} is enabled when {condition} is {value}".format( - name=self.name, condition=self.condition, value=self.value) diff --git a/flags/settings.py b/flags/settings.py deleted file mode 100644 index d95f700..0000000 --- a/flags/settings.py +++ /dev/null @@ -1,93 +0,0 @@ -import logging -from importlib import import_module - -from django.apps import apps -from django.conf import settings -from django.utils.functional import cached_property - -from flags.conditions import get_condition - - -logger = logging.getLogger(__name__) - -# Private set of all flags from sources in FLAG_SOURCES. This is -# populated on AppConfig.ready() by add_flags_from_sources below. -SOURCED_FLAGS = {} - - -class DuplicateFlagException(Exception): - """ Raised when flags are defined in multiple places """ - - -class Flag: - """ A simple wrapper around feature flags and their conditions """ - - def __init__(self, name, conditions={}): - self.name = name - self.__conditions = conditions - - def __eq__(self, other): - """ There can be only one feature flag of a given name """ - return other.name == self.name - - @cached_property - def configured_conditions(self): - """ Get all flag conditions configured in settings """ - # Get condition callables for our settings-configured conditions - condition_fns = [(c, fn, v, None) - for c, v in self.__conditions.items() - for fn in get_condition(c)] - return condition_fns - - @cached_property - def dynamic_conditions(self): - """ Get dynamic flag conditions from models.FlagState """ - # Get condition callables for our dynamic-configured conditions - FlagState = apps.get_model('flags', 'FlagState') - condition_fns = [(s.condition, fn, s.value, s) - for s in FlagState.objects.filter(name=self.name) - for fn in get_condition(s.condition)] - return condition_fns - - @cached_property - def conditions(self): - """ Get all flag conditions """ - return self.configured_conditions + self.dynamic_conditions - - def check_state(self, **kwargs): - """ Determine this flag's state based on any of its conditions """ - return any(fn(v, **kwargs) for c, fn, v, o in self.conditions) - - -def add_flags_from_sources(sources=None): - """ Read flags from sources defined in settings.FLAG_SOURCES. - FLAG_SOURCES is expected to be a list of Python module with flags - specified with variable assignment, e.g. MY_FLAG = True """ - global SOURCED_FLAGS - flags = getattr(settings, 'FLAGS', {}) - - if sources is None: - sources = getattr(settings, 'FLAG_SOURCES', ()) - - for source_str in sources: - source = import_module(source_str) - for flag in (f for f in dir(source) - if f.isupper() and isinstance(getattr(source, f), bool)): - if flag in flags or flag in SOURCED_FLAGS: - raise DuplicateFlagException("{} duplicated in {}".format( - flag, source_str)) - - SOURCED_FLAGS[flag] = getattr(source, flag) - - -def get_flags(sourced_flags=None): - """ Get a dictionary of Flag objects for all flags. - This combines FLAGS from settings with all possible FLAG_SOURCES. """ - flags_spec = getattr(settings, 'FLAGS', {}) - if sourced_flags is None: - sourced_flags = SOURCED_FLAGS - flags_spec.update(sourced_flags) - - flags = {name: Flag(name, conditions=conditions) - for name, conditions in flags_spec.items()} - return flags diff --git a/flags/state.py b/flags/state.py deleted file mode 100644 index bdbf02b..0000000 --- a/flags/state.py +++ /dev/null @@ -1,19 +0,0 @@ -from flags.settings import get_flags - - -def flag_state(flag_name, **kwargs): - """ Return the value for the flag by passing kwargs to its conditions """ - try: - return get_flags()[flag_name].check_state(**kwargs) - except KeyError: - return False - - -def flag_enabled(flag_name, **kwargs): - """ Check if a flag is enabled by passing kwargs to its conditions. """ - return flag_state(flag_name, **kwargs) - - -def flag_disabled(flag_name, **kwargs): - """ Check if a flag is disabled by passing kwargs to its conditions. """ - return not flag_state(flag_name, **kwargs) diff --git a/flags/template_functions.py b/flags/template_functions.py deleted file mode 100644 index 628db9f..0000000 --- a/flags/template_functions.py +++ /dev/null @@ -1,14 +0,0 @@ -from flags.state import ( - flag_disabled as base_flag_disabled, - flag_enabled as base_flag_enabled, -) - - -def flag_enabled(flag_name, request): - """ Check if a flag is enabled for a given request """ - return base_flag_enabled(flag_name, request=request) - - -def flag_disabled(flag_name, request): - """ Check if a flag is disabled for a given request """ - return base_flag_disabled(flag_name, request=request) diff --git a/flags/templatetags/feature_flags.py b/flags/templatetags/feature_flags.py deleted file mode 100644 index ba5b7f1..0000000 --- a/flags/templatetags/feature_flags.py +++ /dev/null @@ -1,27 +0,0 @@ -import django -from django import template - -from flags.state import ( - flag_disabled as base_flag_disabled, - flag_enabled as base_flag_enabled, -) - - -register = template.Library() - -if django.VERSION >= (1, 9): - simple_tag = register.simple_tag -else: - simple_tag = register.assignment_tag - - -@simple_tag(takes_context=True) -def flag_enabled(context, flag_name): - request = context['request'] - return base_flag_enabled(flag_name, request=request) - - -@simple_tag(takes_context=True) -def flag_disabled(context, flag_name): - request = context['request'] - return base_flag_disabled(flag_name, request=request) diff --git a/flags/tests/__init__.py b/flags/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/flags/tests/settings.py b/flags/tests/settings.py deleted file mode 100644 index 8b8bbec..0000000 --- a/flags/tests/settings.py +++ /dev/null @@ -1,55 +0,0 @@ -from __future__ import absolute_import, unicode_literals - -import os - -import wagtail - - -SECRET_KEY = 'not needed' - -DATABASES = { - 'default': { - 'ENGINE': os.environ.get( - 'DATABASE_ENGINE', - 'django.db.backends.sqlite3' - ), - 'NAME': os.environ.get('DATABASE_NAME', 'flags'), - 'USER': os.environ.get('DATABASE_USER', None), - 'PASSWORD': os.environ.get('DATABASE_PASS', None), - 'HOST': os.environ.get('DATABASE_HOST', None), - - 'TEST': { - 'NAME': os.environ.get('DATABASE_NAME', None), - }, - }, -} - -INSTALLED_APPS = ( - 'django.contrib.auth', - 'django.contrib.contenttypes', -) - -if wagtail.VERSION[0] >= 2: - INSTALLED_APPS += ( - 'wagtail.core', - ) -else: - INSTALLED_APPS += ( - 'wagtail.wagtailcore', - ) - -INSTALLED_APPS += ( - 'flags', -) - -TEMPLATES = [{ - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'APP_DIRS': True, -}] - -FLAGS = { - 'FLAG_ENABLED': {'boolean': True}, - 'FLAG_ENABLED2': {'boolean': True}, - 'FLAG_DISABLED': {'boolean': False}, - 'DB_FLAG': {}, -} diff --git a/flags/tests/test_conditions.py b/flags/tests/test_conditions.py deleted file mode 100644 index 3523d6d..0000000 --- a/flags/tests/test_conditions.py +++ /dev/null @@ -1,267 +0,0 @@ -import importlib -from datetime import timedelta - -from django.apps import apps -from django.contrib.auth.models import AnonymousUser, User -from django.http import HttpRequest, QueryDict -from django.test import TestCase -from django.utils import timezone - -from flags.conditions import ( - CONDITIONS, - RequiredForCondition, - anonymous_condition, - boolean_condition, - date_condition, - get_condition, - parameter_condition, - path_condition, - register, - site_condition, - user_condition, -) -from flags.models import FlagState - - -try: - from wagtail.core.models import Site -except ImportError: - from wagtail.wagtailcore.models import Site - - -class ConditionRegistryTestCase(TestCase): - - def test_register_decorator(self): - fn = lambda conditional_value: True - register('decorated')(fn) - self.assertIn('decorated', CONDITIONS) - self.assertEqual(CONDITIONS['decorated'], [fn]) - - def test_register_fn(self): - fn = lambda conditional_value: True - register('undecorated', fn=fn) - self.assertIn('undecorated', CONDITIONS) - self.assertEqual(CONDITIONS['undecorated'], [fn]) - - def test_register_required_kwargs(self): - pass - - def test_get_condition(self): - fn = lambda conditional_value: True - register('gettable', fn=fn) - self.assertEqual(list(get_condition('gettable')), [fn]) - - def test_get_condition_none(self): - self.assertEqual(list(get_condition('notgettable')), []) - - -class BooleanConditionTestCase(TestCase): - - def test_boolean_condition_valid(self): - self.assertTrue(boolean_condition(True)) - - def test_boolean_condition_invalid(self): - self.assertFalse(boolean_condition(False)) - - def test_boolean_condition_valid_string(self): - self.assertTrue(boolean_condition('True')) - self.assertTrue(boolean_condition('true')) - - def test_boolean_condition_invalid_string(self): - self.assertFalse(boolean_condition('False')) - self.assertFalse(boolean_condition('false')) - - -class UserConditionTestCase(TestCase): - - def setUp(self): - user = User.objects.create_user(username='testuser', - email='test@user') - self.request = HttpRequest() - self.request.user = user - - def test_user_valid(self): - self.assertTrue(user_condition('testuser', request=self.request)) - - def test_user_invalid(self): - self.assertFalse(user_condition('nottestuser', request=self.request)) - - def test_request_required(self): - with self.assertRaises(RequiredForCondition): - user_condition('testuser') - - -class AnonymousConditionTestCase(TestCase): - - def setUp(self): - self.request = HttpRequest() - - def test_anonymous_valid(self): - self.request.user = AnonymousUser() - self.assertTrue(anonymous_condition(True, request=self.request)) - - def test_anonymous_invalid(self): - user = User.objects.create_user(username='notadminuser', - email='test@user') - self.request.user = user - self.assertFalse(anonymous_condition(True, request=self.request)) - - def test_request_required(self): - with self.assertRaises(RequiredForCondition): - anonymous_condition(True) - - -class ParameterConditionTestCase(TestCase): - - def setUp(self): - self.request = HttpRequest() - - def test_parameter_condition_valid(self): - self.request.GET = QueryDict('query_flag=True') - self.assertTrue(parameter_condition('query_flag', - request=self.request)) - - def test_parameter_condition_invalid(self): - self.request.GET = QueryDict('query_flag=False') - self.assertFalse(parameter_condition('query_flag', - request=self.request)) - - def test_parameter_condition_non_existent(self): - self.request.GET = QueryDict('not_query_flag=True') - self.assertFalse(parameter_condition('query_flag', - request=self.request)) - - def test_request_required(self): - with self.assertRaises(RequiredForCondition): - parameter_condition('query_flag') - - -class PathConditionTestCase(TestCase): - - def setUp(self): - self.request = HttpRequest() - - def test_path_condition_valid_exact(self): - self.request.path = '/my/path' - self.assertTrue(path_condition('/my/path', request=self.request)) - - def test_path_condition_valid_subpath(self): - self.request.path = '/my/path/to/somewhere' - self.assertTrue(path_condition('/my/path', request=self.request)) - - def test_path_condition_valid_not_starting_with(self): - self.request.path = '/subsection/my/path' - self.assertTrue(path_condition('/my/path', request=self.request)) - - def test_path_condition_invalid(self): - self.request.path = '/your/path' - self.assertFalse(path_condition('/my/path', request=self.request)) - - def test_request_required(self): - with self.assertRaises(RequiredForCondition): - path_condition('/my/path') - - -class PathConditionMigrationTestCase(TestCase): - # Before this migration, it was assumed that the value of the `path` - # condition matched from the start of the requested path. Now, a regex - # can be specified to get as crazy as you want with your matching. - - def setUp(self): - self.migration = importlib.import_module( - 'flags.migrations.0011_migrate_path_data_startswith_to_matches' - ) - - def test_migration_startswith_to_matches(self): - state = FlagState.objects.create(name='MY_FLAG', - condition='path', - value='/my/path') - - self.migration.forwards(apps, None) - state.refresh_from_db() - self.assertEqual(state.condition, 'path matches') - self.assertEqual(state.value, '^/my/path') - - def test_migration_startswith_to_matches_backwards(self): - state = FlagState.objects.create(name='MY_FLAG', - condition='path matches', - value='^/my/path') - - self.migration.backwards(apps, None) - state.refresh_from_db() - self.assertEqual(state.condition, 'path') - self.assertEqual(state.value, '/my/path') - - -class SiteConditionTestCase(TestCase): - - def setUp(self): - self.site = Site.objects.get(is_default_site=True) - self.request = HttpRequest() - self.request.site = self.site - - def test_site_valid_string(self): - self.assertTrue(site_condition('localhost:80', request=self.request)) - - def test_site_valid_string_no_port(self): - self.assertTrue(site_condition('localhost', request=self.request)) - - def test_site_valid_string_default_port(self): - self.assertTrue(site_condition('localhost [default]', - request=self.request)) - - def test_site_valid_site(self): - self.assertTrue(site_condition(str(self.site), request=self.request)) - - def test_site_invalid_site(self): - self.assertFalse(site_condition('non.existent.site', - request=self.request)) - - def test_request_required(self): - with self.assertRaises(RequiredForCondition): - site_condition('localhost:80') - - -class DateConditionTestCase(TestCase): - - def setUp(self): - # Set up some datetimes relative to now for testing - delta = timedelta(days=1) - - self.past_datetime_tz = timezone.now() - delta - self.past_datetime_notz = self.past_datetime_tz.replace(tzinfo=None) - self.past_datetime_tz_str = self.past_datetime_tz.isoformat() - self.past_datetime_notz_str = self.past_datetime_notz.isoformat() - - self.future_datetime_tz = timezone.now() + delta - self.future_datetime_notz = self.future_datetime_tz.replace( - tzinfo=None) - self.future_datetime_tz_str = self.future_datetime_tz.isoformat() - self.future_datetime_notz_str = self.future_datetime_notz.isoformat() - - def test_date_timeone_true(self): - self.assertTrue(date_condition(self.past_datetime_tz)) - - def test_date_no_timeone_true(self): - self.assertTrue(date_condition(self.past_datetime_notz)) - - def test_date_str_timeone_true(self): - self.assertTrue(date_condition(self.past_datetime_tz_str)) - - def test_date_str_no_timeone_true(self): - self.assertTrue(date_condition(self.past_datetime_notz_str)) - - def test_date_timeone_false(self): - self.assertFalse(date_condition(self.future_datetime_tz)) - - def test_date_no_timeone_false(self): - self.assertFalse(date_condition(self.future_datetime_notz)) - - def test_date_str_timeone_false(self): - self.assertFalse(date_condition(self.future_datetime_tz_str)) - - def test_date_str_no_timeone_false(self): - self.assertFalse(date_condition(self.future_datetime_notz_str)) - - def test_not_valid_date_str(self): - self.assertFalse(date_condition('I am not a valid date')) diff --git a/flags/tests/test_decorators.py b/flags/tests/test_decorators.py deleted file mode 100644 index 7bb8aa5..0000000 --- a/flags/tests/test_decorators.py +++ /dev/null @@ -1,89 +0,0 @@ -try: - from unittest.mock import Mock -except ImportError: - from mock import Mock - -from django.http import Http404, HttpRequest, HttpResponse -from django.test import TestCase - -from flags.decorators import flag_check, flag_required - - -class FlagCheckTestCase(TestCase): - def setUp(self): - self.request = HttpRequest() - self.request.META['SERVER_NAME'] = 'localhost' - self.request.META['SERVER_PORT'] = 8000 - - self.view = Mock(__name__='view') - - def test_decorated_no_flag_exists(self): - decorated = flag_check('FLAG_DOES_NOT_EXIST', True)(self.view) - with self.assertRaises(Http404): - decorated(self.request) - self.assertEqual(self.view.call_count, 0) - - def test_decorated_flag_disabled(self): - decorated = flag_check('FLAG_DISABLED', True)(self.view) - self.assertRaises(Http404, decorated, self.request) - self.assertEqual(self.view.call_count, 0) - - def test_decorated_flag_enabled(self): - def view(request): - return HttpResponse('ok') - - decorated = flag_check('FLAG_ENABLED', True)(view) - response = decorated(self.request) - self.assertContains(response, 'ok') - - def test_fallback_view(self): - def fallback(request): - return HttpResponse('fallback') - - decorator = flag_check('FLAG_DISABLED', True, fallback=fallback) - decorated = decorator(self.view) - response = decorated(self.request) - self.assertContains(response, 'fallback') - - def test_pass_if_not_set_no_flag_exists(self): - def view(request): - return HttpResponse('ok') - - decorated = flag_check('FLAG_DOES_NOT_EXIST', False)(view) - response = decorated(self.request) - self.assertContains(response, 'ok') - - def test_pass_if_not_set_disabled(self): - def view(request): - return HttpResponse('ok') - - decorated = flag_check('FLAG_DISABLED', False)(view) - response = decorated(self.request) - self.assertContains(response, 'ok') - - def test_pass_if_not_set_enabled(self): - decorated = flag_check('FLAG_ENABLED', False)(self.view) - self.assertRaises(Http404, decorated, self.request) - self.assertEqual(self.view.call_count, 0) - - def test_pass_if_not_set_fallback_view(self): - def fallback(request): - return HttpResponse('fallback') - - decorator = flag_check( - 'FLAG_ENABLED', - False, - fallback=fallback, - ) - - decorated = decorator(self.view) - response = decorated(self.request) - self.assertContains(response, 'fallback') - - def test_flag_required(self): - def view(request): - return HttpResponse('ok') - - decorated = flag_required('FLAG_ENABLED')(view) - response = decorated(self.request) - self.assertContains(response, 'ok') diff --git a/flags/tests/test_forms.py b/flags/tests/test_forms.py deleted file mode 100644 index e000d73..0000000 --- a/flags/tests/test_forms.py +++ /dev/null @@ -1,27 +0,0 @@ -from django.test import TestCase - -from flags.forms import FlagStateForm - - -class FormTestCase(TestCase): - - def test_valid_data(self): - form = FlagStateForm({ - 'name': 'FLAG_ENABLED', - 'condition': 'boolean', - 'value': 'True' - }) - self.assertTrue(form.is_valid()) - state = form.save() - self.assertEqual(state.name, 'FLAG_ENABLED') - self.assertEqual(state.condition, 'boolean') - self.assertEqual(state.value, 'True') - - def test_blank_data(self): - form = FlagStateForm({}) - self.assertFalse(form.is_valid()) - self.assertEqual(form.errors, { - 'name': ['This field is required.'], - 'condition': ['This field is required.'], - 'value': ['This field is required.'], - }) diff --git a/flags/tests/test_models.py b/flags/tests/test_models.py deleted file mode 100644 index 8ad281d..0000000 --- a/flags/tests/test_models.py +++ /dev/null @@ -1,12 +0,0 @@ -from django.test import TestCase - -from flags.models import FlagState - - -class FlagStateTestCase(TestCase): - def test_flag_str(self): - state = FlagState.objects.create(name='MY_FLAG', - condition='boolean', - value='True') - self.assertEqual(str(state), - 'MY_FLAG is enabled when boolean is True') diff --git a/flags/tests/test_settings.py b/flags/tests/test_settings.py deleted file mode 100644 index 424e231..0000000 --- a/flags/tests/test_settings.py +++ /dev/null @@ -1,86 +0,0 @@ -try: - from unittest.mock import Mock -except ImportError: - from mock import Mock - -from django.test import TestCase, override_settings - -import flags.settings -from flags.models import FlagState -from flags.settings import ( - DuplicateFlagException, - Flag, - add_flags_from_sources, - get_flags, -) - - -# Test flag for using this module to test -SOURCED_FLAG = True - - -class FlagTestCase(TestCase): - - def test_eq(self): - flag1 = Flag('MY_FLAG') - flag2 = Flag('MY_FLAG') - self.assertEqual(flag1, flag2) - - def test_configured_conditions(self): - flag = Flag('MY_FLAG', {'boolean': True}) - # Check the conditions length - self.assertEqual(len(list(flag.configured_conditions)), 1) - - def test_dynamic_conditions(self): - # Add a dyanmic (database) condition - flag = Flag('MY_FLAG') - FlagState.objects.create(name='MY_FLAG', - condition='parameter', - value='MY_FLAG') - self.assertEqual(len(list(flag.dynamic_conditions)), 1) - - def test_conditions(self): - flag = Flag('MY_FLAG', {'boolean': True}) - FlagState.objects.create(name='MY_FLAG', - condition='parameter', - value='MY_FLAG') - self.assertEqual(len(list(flag.conditions)), 2) - - def test_check_state(self): - flag = Flag('MY_FLAG', {'boolean': True}) - self.assertTrue(flag.check_state()) - - def test_check_state_no_conditions(self): - flag = Flag('MY_FLAG', {}) - self.assertFalse(flag.check_state()) - - def test_check_state_multiple_conditions(self): - request = Mock(path='/foo') - flag = Flag('MY_FLAG', {'boolean': False, 'path matches': '/foo'}) - self.assertTrue(flag.check_state(request=request)) - - -class SettingsTestCase(TestCase): - - def tearDown(self): - # Reset SOURCED_FLAGS after each test - flags.settings.SOURCED_FLAGS = {} - - def test_add_flags_from_sources(self): - add_flags_from_sources(sources=['flags.tests.test_settings']) - self.assertTrue(flags.settings.SOURCED_FLAGS['SOURCED_FLAG']) - - def test_add_flags_from_sources_non_existent(self): - with self.assertRaises(ImportError): - add_flags_from_sources(sources=['non.existent.module']) - - @override_settings(FLAGS={'GLOBAL_FLAG': {}}) - def test_get_flags(self): - add_flags_from_sources(sources=['flags.tests.test_settings']) - self.assertIn('GLOBAL_FLAG', get_flags()) - self.assertIn('SOURCED_FLAG', get_flags()) - - @override_settings(FLAGS={'SOURCED_FLAG': {}}) - def test_duplicate_global_flags(self): - with self.assertRaises(DuplicateFlagException): - add_flags_from_sources(sources=['flags.tests.test_settings']) diff --git a/flags/tests/test_state.py b/flags/tests/test_state.py deleted file mode 100644 index de44735..0000000 --- a/flags/tests/test_state.py +++ /dev/null @@ -1,92 +0,0 @@ -from django.http import HttpRequest -from django.test import TestCase - -from flags.models import FlagState -from flags.state import flag_disabled, flag_enabled, flag_state - - -try: - from wagtail.core.models import Site -except ImportError: - from wagtail.wagtailcore.models import Site - - -class FlagStateTestCase(TestCase): - def setUp(self): - self.site = Site.objects.get(is_default_site=True) - self.request = HttpRequest() - self.request.site = self.site - - def test_non_existent_flag(self): - """ Non-existent flags always have a default state of False """ - self.assertFalse(flag_state('FLAG_DOES_NOT_EXIST')) - - def test_flag_state_enabled(self): - """ Global flags that are enabled should be True """ - self.assertTrue(flag_state('FLAG_ENABLED')) - - def test_flag_state_disabled(self): - """ Global flags that are disabled should be False """ - self.assertFalse(flag_state('FLAG_DISABLED')) - - def test_flag_state_bool_false_and_db_site_true(self): - """ Test state of multiple conditions, one 'site' in database """ - FlagState.objects.create(name='FLAG_DISABLED', - condition='site', - value=str(self.site)) - self.assertTrue(flag_state('FLAG_DISABLED', request=self.request)) - - def test_flag_state_bool_true_and_db_site_true(self): - """ Test state of multiple conditions, one 'site' in database """ - FlagState.objects.create(name='FLAG_ENABLED', - condition='site', - value=str(self.site)) - self.assertTrue(flag_state('FLAG_ENABLED', request=self.request)) - - def test_flag_state_non_existent_flag_site(self): - """ Given a site non-existent flags should still be False """ - self.assertFalse(flag_state('FLAG_DOES_NOT_EXIST', - request=self.request)) - - def test_flag_state_site_for_other_site(self): - """ A site flag enabled for another site should be False """ - other_site = Site.objects.create( - is_default_site=False, - root_page_id=self.site.root_page_id, - hostname='other.host' - ) - FlagState.objects.create(name='DB_FLAG', - condition='site', - value=str(other_site)) - self.assertFalse(flag_state('DB_FLAG', request=self.request)) - - def test_flag_state_site_for_multiple_sites(self): - """ A site flag enabled for two sites should return True for both """ - other_site = Site.objects.create( - is_default_site=False, - root_page_id=self.site.root_page_id, - hostname='other.host' - ) - FlagState.objects.create(name='DB_FLAG', - condition='site', - value=str(other_site)) - FlagState.objects.create(name='DB_FLAG', - condition='site', - value=str(self.site)) - self.assertTrue(flag_state('DB_FLAG', request=self.request)) - - def test_flag_enabled_enabled(self): - """ Global flags enabled should be True """ - self.assertTrue(flag_enabled('FLAG_ENABLED')) - - def test_flag_enabled_disabled(self): - """ Global flags disabled should be False """ - self.assertFalse(flag_enabled('FLAG_DISABLED')) - - def test_flag_disabled_global_disabled(self): - """ Global flags disabled should be True """ - self.assertTrue(flag_disabled('FLAG_DISABLED')) - - def test_flag_disabled_global_enabled(self): - """ Global flags enabled should be False """ - self.assertFalse(flag_disabled('FLAG_ENABLED')) diff --git a/flags/tests/test_template_functions.py b/flags/tests/test_template_functions.py deleted file mode 100644 index 16c5ad6..0000000 --- a/flags/tests/test_template_functions.py +++ /dev/null @@ -1,29 +0,0 @@ -from django.http import HttpRequest -from django.test import TestCase - -from flags.template_functions import flag_disabled, flag_enabled - - -try: - from wagtail.core.models import Site -except ImportError: - from wagtail.wagtailcore.models import Site - - -class TemplateFunctionsTestCase(TestCase): - def setUp(self): - self.site = Site.objects.get(is_default_site=True) - self.request = HttpRequest() - self.request.site = self.site - - def test_flag_enabled_true(self): - self.assertTrue(flag_enabled('FLAG_ENABLED', request=self.request)) - - def test_flag_enabled_false(self): - self.assertFalse(flag_enabled('FLAG_DISABLED', request=self.request)) - - def test_flag_disabled_true(self): - self.assertTrue(flag_disabled('FLAG_DISABLED', request=self.request)) - - def test_flag_disabled_false(self): - self.assertFalse(flag_disabled('FLAG_ENABLED', request=self.request)) diff --git a/flags/tests/test_templatetags_feature_flags.py b/flags/tests/test_templatetags_feature_flags.py deleted file mode 100644 index 1135f11..0000000 --- a/flags/tests/test_templatetags_feature_flags.py +++ /dev/null @@ -1,96 +0,0 @@ -from django.http import HttpRequest -from django.template import Context, Template -from django.test import TestCase - - -try: - from wagtail.core.models import Site -except ImportError: - from wagtail.wagtailcore.models import Site - - -class FlagsTemplateTagsTestCase(TestCase): - - def setUp(self): - self.site = Site.objects.get(is_default_site=True) - self.request = HttpRequest() - self.request.site = self.site - - def render_template(self, string, context=None): - context = context or {'request': self.request} - context = Context(context) - return Template(string).render(context) - - def test_flag_enabled_disabled(self): - rendered = self.render_template( - '{% load feature_flags %}' - '{% flag_enabled "FLAG_DISABLED" as test_flag %}' - '{% if test_flag %}' - 'flag enabled' - '{% else %}' - 'flag disabled' - '{% endif %}' - ) - self.assertEqual(rendered, 'flag disabled') - - def test_flag_enabled_does_not_exist(self): - # Disabled can also mean non-existent - rendered = self.render_template( - '{% load feature_flags %}' - '{% flag_enabled "FLAG_DOES_NOT_EXIST" as test_flag %}' - '{% if test_flag %}' - 'flag enabled' - '{% else %}' - 'flag disabled' - '{% endif %}' - ) - self.assertEqual(rendered, 'flag disabled') - - def test_flag_enabled_enabled(self): - rendered = self.render_template( - '{% load feature_flags %}' - '{% flag_enabled "FLAG_ENABLED" as test_flag %}' - '{% if test_flag %}' - 'flag enabled' - '{% else %}' - 'flag disabled' - '{% endif %}' - ) - self.assertEqual(rendered, 'flag enabled') - - def test_flag_disabled_disabled(self): - # Disabled can also mean non-existent - rendered = self.render_template( - '{% load feature_flags %}' - '{% flag_disabled "FLAG_DISABLED" as test_flag %}' - '{% if test_flag %}' - 'flag disabled' - '{% else %}' - 'flag enabled' - '{% endif %}' - ) - self.assertEqual(rendered, 'flag disabled') - - def test_flag_disabled_does_not_exist(self): - rendered = self.render_template( - '{% load feature_flags %}' - '{% flag_disabled "FLAG_DOES_NOT_EXIST" as test_flag %}' - '{% if test_flag %}' - 'flag disabled' - '{% else %}' - 'flag enabled' - '{% endif %}' - ) - self.assertEqual(rendered, 'flag disabled') - - def test_flag_disabled_enabled(self): - rendered = self.render_template( - '{% load feature_flags %}' - '{% flag_disabled "FLAG_ENABLED" as test_flag %}' - '{% if test_flag %}' - 'flag disabled' - '{% else %}' - 'flag enabled' - '{% endif %}' - ) - self.assertEqual(rendered, 'flag enabled') diff --git a/flags/tests/test_urls.py b/flags/tests/test_urls.py deleted file mode 100644 index 18b3375..0000000 --- a/flags/tests/test_urls.py +++ /dev/null @@ -1,242 +0,0 @@ -from unittest import skipIf - -from django.http import Http404, HttpResponse -from django.test import RequestFactory, TestCase, override_settings - - -try: - from django.urls import include, path, resolve, re_path -except ImportError: - from django.core.urlresolvers import resolve - from django.conf.urls import include, url as re_path - path = None - -try: - from flags.urls import flagged_path, flagged_re_path, flagged_re_paths -except ImportError: - from flags.urls import ( - flagged_url as flagged_re_path, - flagged_urls as flagged_re_paths - ) - - -def view(request): - return HttpResponse('view') - - -def fallback(request): - return HttpResponse('fallback') - - -extra_patterns = [ - re_path(r'^included-url$', view), - re_path(r'^included-url-with-fallback$', view), -] -fallback_patterns = [ - re_path(r'^included-url-with-fallback$', fallback), - re_path(r'^other-included-url$', fallback), -] - -urlpatterns = [ - flagged_re_path('FLAGGED_URL', r'^url-true-no-fallback$', view, - name='some-view', state=True), - flagged_re_path('FLAGGED_URL', r'^url-false-no-fallback$', view, - name='some-view', state=False), - flagged_re_path('FLAGGED_URL', r'^url-true-fallback$', view, - name='some-view', state=True, fallback=fallback), - flagged_re_path('FLAGGED_URL', r'^url-false-fallback$', view, - name='some-view', state=False, fallback=fallback), - - flagged_re_path('FLAGGED_URL', r'^include/', include(extra_patterns), - state=True), - flagged_re_path('FLAGGED_URL', r'^include-false/', include(extra_patterns), - state=False), - flagged_re_path('FLAGGED_URL', r'^include-fallback/', - include(extra_patterns), state=True, fallback=fallback), - flagged_re_path('FLAGGED_URL', r'^include-false-fallback/', - include(extra_patterns), state=True, fallback=fallback), - flagged_re_path('FLAGGED_URL', r'^include-fallback-include/', - include(extra_patterns), - state=True, fallback=include(fallback_patterns)), -] - -with flagged_re_paths('FLAGGED_URL') as re_path: - flagged_patterns_true_no_fallback = [ - re_path(r'^patterns-true-no-fallback$', view, name='some-view'), - ] -urlpatterns = urlpatterns + flagged_patterns_true_no_fallback - -with flagged_re_paths('FLAGGED_URL', state=False) as re_path: - flagged_patterns_false_no_fallback = [ - re_path(r'^patterns-false-no-fallback$', view, name='some-view'), - ] -urlpatterns = urlpatterns + flagged_patterns_false_no_fallback - -with flagged_re_paths('FLAGGED_URL', fallback=fallback) as re_path: - flagged_patterns_true_fallback = [ - re_path(r'^patterns-true-fallback$', view, name='some-view'), - ] -urlpatterns = urlpatterns + flagged_patterns_true_fallback - -if path: - path_patterns = [ - flagged_path('FLAGGED_URL', 'path-true-no-fallback', view, - name='some-view', state=True), - ] - urlpatterns = urlpatterns + path_patterns - - -@override_settings( - ROOT_URLCONF=__name__, -) -class FlagCheckTestCase(TestCase): - def setUp(self): - self.flag_name = 'FLAGGED_URL' - self.factory = RequestFactory() - - def get_url_response(self, url): - request = self.factory.get(url) - resolved_view, args, kwargs = resolve(url) - response = resolved_view(request) - return response - - @override_settings(FLAGS={'FLAGGED_URL': {'boolean': True}}) - def test_flagged_url_true_no_fallback(self): - response = self.get_url_response('/url-true-no-fallback') - self.assertContains(response, 'view') - - @override_settings(FLAGS={'FLAGGED_URL': {'boolean': False}}) - def test_flagged_url_true_no_fallback_false(self): - with self.assertRaises(Http404): - self.get_url_response('/url-true-no-fallback') - - @skipIf(not path, "Skipping test for Django 2.0 path() patterns") - @override_settings(FLAGS={'FLAGGED_URL': {'boolean': True}}) - def test_flagged_path_true_no_fallback(self): - response = self.get_url_response('/path-true-no-fallback') - self.assertContains(response, 'view') - - @skipIf(not path, "Skipping test for Django 2.0 path() patterns") - @override_settings(FLAGS={'FLAGGED_URL': {'boolean': False}}) - def test_flagged_path_true_no_fallback_false(self): - with self.assertRaises(Http404): - self.get_url_response('/path-true-no-fallback') - - @override_settings(FLAGS={'FLAGGED_URL': {'boolean': False}}) - def test_flagged_url_false_no_fallback(self): - response = self.get_url_response('/url-false-no-fallback') - self.assertContains(response, 'view') - - @override_settings(FLAGS={'FLAGGED_URL': {'boolean': True}}) - def test_flagged_url_false_no_fallback_true(self): - with self.assertRaises(Http404): - self.get_url_response('/url-false-no-fallback') - - @override_settings(FLAGS={'FLAGGED_URL': {'boolean': True}}) - def test_flagged_url_true_fallback(self): - response = self.get_url_response('/url-true-fallback') - self.assertContains(response, 'view') - - @override_settings(FLAGS={'FLAGGED_URL': {'boolean': False}}) - def test_flagged_url_true_fallback_false(self): - response = self.get_url_response('/url-true-fallback') - self.assertContains(response, 'fallback') - - @override_settings(FLAGS={'FLAGGED_URL': {'boolean': False}}) - def test_flagged_url_false_fallback(self): - response = self.get_url_response('/url-false-fallback') - self.assertContains(response, 'view') - - @override_settings(FLAGS={'FLAGGED_URL': {'boolean': True}}) - def test_flagged_url_false_fallback_false(self): - response = self.get_url_response('/url-false-fallback') - self.assertContains(response, 'fallback') - - @override_settings(FLAGS={'FLAGGED_URL': {'boolean': True}}) - def test_flagged_url_true_include_true(self): - response = self.get_url_response('/include/included-url') - self.assertContains(response, 'view') - - @override_settings(FLAGS={'FLAGGED_URL': {'boolean': False}}) - def test_flagged_url_true_include_false(self): - with self.assertRaises(Http404): - self.get_url_response('/include/included-url') - - @override_settings(FLAGS={'FLAGGED_URL': {'boolean': False}}) - def test_flagged_url_false_include(self): - response = self.get_url_response('/include-false/included-url') - self.assertContains(response, 'view') - - @override_settings(FLAGS={'FLAGGED_URL': {'boolean': True}}) - def test_flagged_url_false_include_true(self): - with self.assertRaises(Http404): - self.get_url_response('/include-false/included-url') - - @override_settings(FLAGS={'FLAGGED_URL': {'boolean': False}}) - def test_flagged_url_include_fallback(self): - response = self.get_url_response('/include-fallback/included-url') - self.assertContains(response, 'fallback') - - @override_settings(FLAGS={'FLAGGED_URL': {'boolean': True}}) - def test_flagged_url_true_include_fallback_include(self): - response = self.get_url_response( - '/include-fallback-include/included-url-with-fallback') - self.assertContains(response, 'view') - - @override_settings(FLAGS={'FLAGGED_URL': {'boolean': False}}) - def test_flagged_url_false_include_fallback_include(self): - response = self.get_url_response( - '/include-fallback-include/included-url-with-fallback') - self.assertContains(response, 'fallback') - - @override_settings(FLAGS={'FLAGGED_URL': {'boolean': False}}) - def test_flagged_url_false_include_fallback_none(self): - with self.assertRaises(Http404): - self.get_url_response( - '/include-fallback-include/included-url') - - @override_settings(FLAGS={'FLAGGED_URL': {'boolean': True}}) - def test_flagged_url_true_include_fallback_include_nonmatching_url(self): - with self.assertRaises(Http404): - self.get_url_response( - '/include-fallback-include/other-included-url') - - @override_settings(FLAGS={'FLAGGED_URL': {'boolean': False}}) - def test_flagged_url_false_include_fallback_include_nonmatching_url(self): - response = self.get_url_response( - '/include-fallback-include/other-included-url') - self.assertContains(response, 'fallback') - - def test_flagged_url_not_callable(self): - with self.assertRaises(TypeError): - flagged_re_path('MY_FLAG', r'^my_url/$', 'string') - - @override_settings(FLAGS={'FLAGGED_URL': {'boolean': True}}) - def test_flagged_urls_cm_true_no_fallback(self): - response = self.get_url_response('/patterns-true-no-fallback') - self.assertContains(response, 'view') - - @override_settings(FLAGS={'FLAGGED_URL': {'boolean': False}}) - def test_flagged_urls_cm_true_no_fallback_false(self): - with self.assertRaises(Http404): - self.get_url_response('/patterns-true-no-fallback') - - @override_settings(FLAGS={'FLAGGED_URL': {'boolean': False}}) - def test_flagged_urls_cm_false_no_fallback(self): - response = self.get_url_response('/patterns-false-no-fallback') - self.assertContains(response, 'view') - - @override_settings(FLAGS={'FLAGGED_URL': {'boolean': True}}) - def test_flagged_urls_cm_false_no_fallback_true(self): - with self.assertRaises(Http404): - self.get_url_response('/patterns-false-no-fallback') - - @override_settings(FLAGS={'FLAGGED_URL': {'boolean': True}}) - def test_flagged_urls_cm_true_fallback(self): - response = self.get_url_response('/patterns-true-fallback') - self.assertContains(response, 'view') - - @override_settings(FLAGS={'FLAGGED_URL': {'boolean': False}}) - def test_flagged_urls_cm_true_fallback_false(self): - response = self.get_url_response('/patterns-true-fallback') - self.assertContains(response, 'fallback') diff --git a/flags/tests/test_views.py b/flags/tests/test_views.py deleted file mode 100644 index 45e6d61..0000000 --- a/flags/tests/test_views.py +++ /dev/null @@ -1,110 +0,0 @@ -from django.core.exceptions import ImproperlyConfigured -from django.http import Http404, HttpRequest, HttpResponse -from django.test import TestCase, override_settings -from django.views.generic import View - -from flags.views import FlaggedViewMixin - - -try: - from wagtail.core.models import Page, Site - from wagtail.core.views import serve as wagtail_serve -except ImportError: - from wagtail.wagtailcore.models import Page, Site - from wagtail.wagtailcore.views import serve as wagtail_serve - - -class TestView(FlaggedViewMixin, View): - def get(self, request, *args, **kwargs): - return HttpResponse('ok') - - -class FlaggedViewMixinTestCase(TestCase): - def setUp(self): - self.flag_name = 'FLAGGED_VIEW_MIXIN' - - def request(self, path='/'): - request = HttpRequest() - - request.method = 'GET' - request.path = path - request.META['SERVER_NAME'] = 'localhost' - request.META['SERVER_PORT'] = 8000 - request.site = Site.objects.get(is_default_site=True) - - return request - - def test_no_flag_key_raises_improperly_configured(self): - view = TestView.as_view() - self.assertRaises(ImproperlyConfigured, view, self.request()) - - def test_no_flag_acts_as_disabled(self): - view = TestView.as_view(flag_name=self.flag_name) - self.assertRaises(Http404, view, self.request()) - - @override_settings(FLAGS={'FLAGGED_VIEW_MIXIN': {'boolean': True}}) - def test_flag_set_view_enabled(self): - view = TestView.as_view(flag_name=self.flag_name) - self.assertEqual(view(self.request()).status_code, 200) - - @override_settings(FLAGS={'FLAGGED_VIEW_MIXIN': {'boolean': False}}) - def test_flag_set_view_disabled(self): - view = TestView.as_view(flag_name=self.flag_name) - self.assertRaises(Http404, view, self.request()) - - def test_fallback_view_function_disabled(self): - def test_view_function(request, *args, **kwargs): - return HttpResponse('fallback fn') - - view = TestView.as_view( - flag_name=self.flag_name, - condition=True, - fallback=test_view_function - ) - - response = view(self.request()) - self.assertContains(response, 'fallback fn') - - @override_settings(FLAGS={'FLAGGED_VIEW_MIXIN': {'boolean': True}}) - def test_fallback_view_function_enabled(self): - def test_view_function(request, *args, **kwargs): - return HttpResponse('fallback fn') - - view = TestView.as_view( - flag_name=self.flag_name, - condition=True, - fallback=test_view_function - ) - - response = view(self.request()) - self.assertContains(response, 'ok') - - def test_fallback_class_based_view(self): - class OtherTestView(View): - def get(self, request, *args, **kwargs): - return HttpResponse('fallback cbv') - - view = TestView.as_view( - flag_name=self.flag_name, - condition=True, - fallback=OtherTestView.as_view() - ) - - response = view(self.request()) - self.assertContains(response, 'fallback cbv') - - def test_fallback_wagtail_serve(self): - site = Site.objects.get(is_default_site=True) - root = site.root_page - page = Page(title='wagtail title', slug='title') - root.add_child(instance=page) - page.save() - page.save_revision().publish() - - fail_through = lambda request: wagtail_serve(request, request.path) - view = TestView.as_view(flag_name=self.flag_name, - condition=True, - fallback=fail_through) - - response = view(self.request(path='/title')) - self.assertContains(response, 'wagtail title') diff --git a/flags/urls.py b/flags/urls.py deleted file mode 100644 index a2c2c2b..0000000 --- a/flags/urls.py +++ /dev/null @@ -1,162 +0,0 @@ -from contextlib import contextmanager -from functools import partial - -import django - -from flags.decorators import flag_check - - -try: - from django.urls.resolvers import ( - RegexPattern, - RoutePattern, - URLPattern, - URLResolver - ) -except ImportError: - from django.core.urlresolvers import ( - RegexURLPattern as URLPattern, - RegexURLResolver as URLResolver - ) - - -class FlaggedURLResolver(URLResolver): - def __init__(self, flag_name, regex, urlconf_name, - default_kwargs=None, app_name=None, namespace=None, - state=True, fallback=None): - super(FlaggedURLResolver, self).__init__( - regex, urlconf_name, default_kwargs=default_kwargs, - app_name=app_name, namespace=namespace) - - self.flag_name = flag_name - self.state = state - self.fallback = fallback - self.fallback_patterns = [] - - if isinstance(self.fallback, (list, tuple)): - urlconf_module, app_name, namespace = self.fallback - self.fallback_patterns = URLResolver( - regex, urlconf_module, None, - app_name=app_name, namespace=namespace - ).url_patterns - - @property - def url_patterns(self): - # First, add our "positively" flagged URLs, where when the flag - # matches the defined state, the view is served for the pattern - # and not the fallback. - url_patterns = [] - for pattern in super(FlaggedURLResolver, self).url_patterns: - # Get the fallback view, if there is one, and remove it from - # the list of fallback patterns. - fallback = self.fallback - if isinstance(self.fallback, (list, tuple)): - if django.VERSION[0] >= 2: - fallback = next(( - p.callback for p in self.fallback_patterns - if p.pattern.describe() == pattern.pattern.describe() - ), None) - else: - fallback = next(( - p.callback for p in self.fallback_patterns - if p.regex == pattern.regex - ), None) - - flag_decorator = flag_check(self.flag_name, self.state, - fallback=fallback) - - if django.VERSION[0] >= 2: - route_pattern = pattern.pattern - else: - route_pattern = pattern.regex.pattern - - flagged_pattern = URLPattern( - route_pattern, flag_decorator(pattern.callback), - pattern.default_args, pattern.name) - - url_patterns.append(flagged_pattern) - - # Next, add "negatively" flagged URLs, where the flag does not match - # the defined state, for any remaining fallback patterns that didn't - # match other url patterns. - if django.VERSION[0] >= 2: - # Django >= 2.0 - described_patterns = [p.pattern.describe() for p in url_patterns] - negative_patterns = ( - p for p in self.fallback_patterns - if p.pattern.describe() not in described_patterns - ) - else: - described_patterns = [p.regex for p in url_patterns] - negative_patterns = ( - p for p in self.fallback_patterns - if p.regex not in described_patterns - ) - - for pattern in negative_patterns: - flag_decorator = flag_check(self.flag_name, not self.state) - - if django.VERSION[0] >= 2: - route_pattern = pattern.pattern - else: - route_pattern = pattern.regex.pattern - - flagged_pattern = URLPattern( - route_pattern, flag_decorator(pattern.callback), - pattern.default_args, pattern.name) - - url_patterns.append(flagged_pattern) - - return url_patterns - - -def _flagged_path(flag_name, route, view, kwargs=None, name=None, - state=True, fallback=None, Pattern=None): - """ Make a URL depend on the state of a feature flag """ - if callable(view): - flagged_view = flag_check(flag_name, - state, - fallback=fallback)(view) - - if Pattern: - route_pattern = Pattern(route, name=name, is_endpoint=True) - else: - route_pattern = route - - return URLPattern(route_pattern, flagged_view, kwargs, name) - - elif isinstance(view, (list, tuple)): - urlconf_module, app_name, namespace = view - - if Pattern: - route_pattern = Pattern(route, name=name, is_endpoint=True) - else: - route_pattern = route - - return FlaggedURLResolver( - flag_name, route_pattern, urlconf_module, kwargs, - app_name=app_name, namespace=namespace, - state=state, fallback=fallback) - - else: - raise TypeError('view must be a callable') - - -@contextmanager -def _flagged_paths(flag_name, state=True, fallback=None, Pattern=None): - """ Flag multiple URLs in the same context - Returns a url()-compatible wrapper for flagged_url() """ - def flagged_url_wrapper(route, view, kwargs=None, name=None): - return _flagged_path(flag_name, route, view, kwargs=kwargs, name=name, - state=state, fallback=fallback, Pattern=Pattern) - yield flagged_url_wrapper - - -if django.VERSION[0] >= 2: - flagged_path = partial(_flagged_path, Pattern=RoutePattern) - flagged_re_path = partial(_flagged_path, Pattern=RegexPattern) - flagged_paths = partial(_flagged_paths, Pattern=RoutePattern) - flagged_re_paths = partial(_flagged_paths, Pattern=RegexPattern) -else: - flagged_url = partial(_flagged_path, Pattern=None) - flagged_urls = partial(_flagged_paths, Pattern=None) diff --git a/flags/views.py b/flags/views.py deleted file mode 100644 index 7a45a3d..0000000 --- a/flags/views.py +++ /dev/null @@ -1,69 +0,0 @@ -from collections import OrderedDict - -from django.core.exceptions import ImproperlyConfigured -from django.shortcuts import get_object_or_404, redirect, render -from django.views.generic import TemplateView - -from flags.decorators import flag_check -from flags.forms import FlagStateForm -from flags.models import FlagState -from flags.settings import get_flags - - -def index(request): - flags = OrderedDict(sorted(get_flags().items(), key=lambda x: x[0])) - context = { - 'flag_states': FlagState.objects.order_by('name'), - 'flags': flags, - } - return render(request, 'flagadmin/index.html', context) - - -def create(request): - if request.method == 'POST': - form = FlagStateForm(request.POST) - if form.is_valid(): - form.save() - return redirect('flagadmin:list') - else: - form = FlagStateForm() - - context = dict(form=form) - return render(request, 'flagadmin/flags/create.html', context) - - -def delete(request, state_id): - flag_state = get_object_or_404(FlagState, pk=state_id) - - if request.method == 'POST': - flag_state.delete() - return redirect('flagadmin:list') - - context = dict(state_str=str(flag_state), state_id=flag_state.pk) - return render(request, 'flagadmin/flags/delete.html', context) - - -class FlaggedViewMixin(object): - flag_name = None - fallback = None - condition = True - - def dispatch(self, request, *args, **kwargs): - if self.flag_name is None: - raise ImproperlyConfigured( - "FlaggedViewMixin requires a 'flag_name' argument." - ) - - super_dispatch = super(FlaggedViewMixin, self).dispatch - - decorator = flag_check( - self.flag_name, - self.condition, - fallback=self.fallback, - ) - - return decorator(super_dispatch)(request, *args, **kwargs) - - -class FlaggedTemplateView(FlaggedViewMixin, TemplateView): - pass diff --git a/flags/wagtail_hooks.py b/flags/wagtail_hooks.py deleted file mode 100644 index 3b1acdb..0000000 --- a/flags/wagtail_hooks.py +++ /dev/null @@ -1,35 +0,0 @@ -from django.conf.urls import include, url - -from flags import views - - -try: - from django.urls import reverse -except ImportError: - from django.core.urlresolvers import reverse - -try: - from wagtail.admin.menu import MenuItem - from wagtail.core import hooks -except ImportError: - from wagtail.wagtailadmin.menu import MenuItem - from wagtail.wagtailcore import hooks - - -@hooks.register('register_settings_menu_item') -def register_flags_menu(): - return MenuItem('Flags', reverse('flagadmin:list'), - classnames='icon icon-tag', order=10000) - - -@hooks.register('register_admin_urls') -def register_flag_admin_urls(): - return [ - url(r'^flags/', - include(([ - url(r'^$', views.index, name='list'), - url(r'^(\d+)/delete/$', views.delete, - name='delete'), - url(r'^create/$', views.create, name='create'), - ], 'flags'), namespace='flagadmin')) - ] diff --git a/setup.py b/setup.py index 3c61382..b1fca0e 100644 --- a/setup.py +++ b/setup.py @@ -1,24 +1,17 @@ from setuptools import find_packages, setup -try: - import pypandoc - long_description = pypandoc.convert('README.md', 'rst') -except (IOError, ImportError): - long_description = open('README.md', 'r').read() +long_description = open('README.md', 'r').read() install_requires = [ - 'Django>=1.8,<2.1', - 'wagtail>=1.10,<2.1', + 'wagtail>=1.10,<2.2', + 'django-flags>=3.0,<4.0' ] - testing_extras = [ - 'mock>=2.0.0', 'coverage>=3.7.0', ] - setup( name='wagtail-flags', url='https://github.com/cfpb/wagtail-flags', @@ -26,8 +19,9 @@ author_email='tech@cfpb.gov', description='Feature flags for Wagtail sites', long_description=long_description, + long_description_content_type='text/markdown', license='CC0', - version='2.1.0', + version='3.0.0', include_package_data=True, packages=find_packages(), install_requires=install_requires, diff --git a/tox.ini b/tox.ini index e5778a9..c06ba51 100644 --- a/tox.ini +++ b/tox.ini @@ -1,14 +1,15 @@ [tox] skipsdist=True -envlist=lint,py{27,36}-dj{18,111}-wag{112,113},py{36}-dj{20}-wag{20} +envlist=lint,py{27,36}-dj{18,111}-wag{113},py{36}-dj{20}-wag{20,21} [testenv] install_command=pip install -e ".[testing]" -U {opts} {packages} commands= coverage erase - coverage run --source='flags' {envbindir}/django-admin.py test {posargs} + coverage run --source='wagtailflags' {envbindir}/django-admin.py test {posargs} + coverage report -m setenv= - DJANGO_SETTINGS_MODULE=flags.tests.settings + DJANGO_SETTINGS_MODULE=wagtailflags.tests.settings basepython= py27: python2.7 @@ -18,9 +19,9 @@ deps= dj18: Django>=1.8,<1.9 dj111: Django>=1.11,<1.12 dj20: Django>=2.0,<2.1 - wag112: wagtail>=1.12,<1.13 wag113: wagtail>=1.13,<1.14 wag20: wagtail>=2.0,<2.1 + wag21: wagtail>=2.1,<2.2 [testenv:lint] basepython=python3.6 @@ -29,14 +30,13 @@ deps= isort>=4.2.15 commands= flake8 . - isort --check-only --diff --recursive flags + isort --check-only --diff --recursive wagtailflags [flake8] ignore=E731,W503 exclude= .tox, - __pycache__, - flags/migrations/* + __pycache__ [isort] diff --git a/flags/migrations/__init__.py b/wagtailflags/__init__.py similarity index 100% rename from flags/migrations/__init__.py rename to wagtailflags/__init__.py diff --git a/wagtailflags/conditions.py b/wagtailflags/conditions.py new file mode 100644 index 0000000..27ca0ff --- /dev/null +++ b/wagtailflags/conditions.py @@ -0,0 +1,33 @@ +from django.apps import apps +from django.core.exceptions import ObjectDoesNotExist + +from flags.conditions import RequiredForCondition, register + + +@register('site') +def site_condition(site_str, request=None, **kwargs): + """ Does the requests's Wagtail Site match the given site? + site_str should be 'hostname:port', or 'hostname [default]'. """ + if request is None: + raise RequiredForCondition("request is required for condition " + "'site'") + + Site = apps.get_model('wagtailcore.Site') + + if '[default]' in site_str: + # Wagtail Sites on the default port have [default] at the end of + # their str() form. + site_str = site_str.replace(' [default]', ':80') + elif ':' not in site_str: + # Add a default port if one isn't given + site_str += ':80' + + hostname, port = site_str.split(':') + try: + conditional_site = Site.objects.get(hostname=hostname, port=port) + except ObjectDoesNotExist: + return False + + site = Site.find_for_request(request) + + return conditional_site == site diff --git a/flags/templates/flagadmin/flags/create.html b/wagtailflags/templates/wagtailflags/flags/create.html similarity index 89% rename from flags/templates/flagadmin/flags/create.html rename to wagtailflags/templates/wagtailflags/flags/create.html index 27271c7..edfd1e0 100644 --- a/flags/templates/flagadmin/flags/create.html +++ b/wagtailflags/templates/wagtailflags/flags/create.html @@ -1,6 +1,5 @@ {% extends "wagtailadmin/base.html" %} {% load i18n %} -{% load gravatar %} {% block titletag %}{% trans "Create a flag condition" as flags_str %}{% endblock %} {% block content %} @@ -8,7 +7,7 @@ {% include "wagtailadmin/shared/header.html" with title=flags_str icon="tag" %}

New flag condition:

-
+ {% csrf_token %}
diff --git a/flags/templates/flagadmin/flags/delete.html b/wagtailflags/templates/wagtailflags/flags/delete.html similarity index 70% rename from flags/templates/flagadmin/flags/delete.html rename to wagtailflags/templates/wagtailflags/flags/delete.html index ad24600..aba5de6 100644 --- a/flags/templates/flagadmin/flags/delete.html +++ b/wagtailflags/templates/wagtailflags/flags/delete.html @@ -1,17 +1,16 @@ {% extends "wagtailadmin/base.html" %} {% load i18n %} -{% load gravatar %} {% block titletag %}{% trans "Delete" as delete_str %}: {{ flag_id }}{% endblock %} {% block content %} {% trans "Delete flag condition:" as delete_str %} {% include "wagtailadmin/shared/header.html" with title=delete_str subtitle=state_str %} -
+ {% csrf_token %}

Are you sure you want to delete this flag condition?

- No, don't delete it + No, don't delete it
{% endblock %} diff --git a/flags/templates/flagadmin/index.html b/wagtailflags/templates/wagtailflags/index.html similarity index 88% rename from flags/templates/flagadmin/index.html rename to wagtailflags/templates/wagtailflags/index.html index 38926b9..3ee167c 100644 --- a/flags/templates/flagadmin/index.html +++ b/wagtailflags/templates/wagtailflags/index.html @@ -1,6 +1,5 @@ {% extends "wagtailadmin/base.html" %} {% load i18n %} -{% load gravatar %} {% block titletag %}{% trans "Flags" %}{% endblock %} {% block content %} @@ -44,7 +43,7 @@

Available Flags

{% if state %} - Delete + Delete {% endif %} @@ -60,7 +59,7 @@

Available Flags

- Add a condition + Add a condition

Unconfigured flags

@@ -94,7 +93,7 @@

Unconfigured flags

{{ state.value }} - Delete + Delete {% endif %} diff --git a/flags/templatetags/__init__.py b/wagtailflags/tests/__init__.py similarity index 100% rename from flags/templatetags/__init__.py rename to wagtailflags/tests/__init__.py diff --git a/wagtailflags/tests/settings.py b/wagtailflags/tests/settings.py new file mode 100644 index 0000000..11aa987 --- /dev/null +++ b/wagtailflags/tests/settings.py @@ -0,0 +1,150 @@ +from __future__ import absolute_import, unicode_literals + +import os + +import django + +import wagtail + + +ALLOWED_HOSTS = ['*'] + +SECRET_KEY = 'not needed' + +ROOT_URLCONF = 'wagtailflags.tests.urls' + +DATABASES = { + 'default': { + 'ENGINE': os.environ.get( + 'DATABASE_ENGINE', + 'django.db.backends.sqlite3' + ), + 'NAME': os.environ.get('DATABASE_NAME', 'wagtailflags'), + 'USER': os.environ.get('DATABASE_USER', None), + 'PASSWORD': os.environ.get('DATABASE_PASS', None), + 'HOST': os.environ.get('DATABASE_HOST', None), + + 'TEST': { + 'NAME': os.environ.get('DATABASE_NAME', None), + }, + }, +} + +INSTALLED_APPS = ( + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', +) + +if wagtail.VERSION >= (2, 0): # pragma: no cover + WAGTAIL_APPS = ( + 'wagtail.contrib.forms', + 'wagtail.contrib.modeladmin', + 'wagtail.contrib.settings', + 'wagtail.tests.testapp', + 'wagtail.admin', + 'wagtail.core', + 'wagtail.documents', + 'wagtail.images', + 'wagtail.sites', + 'wagtail.users', + ) + + WAGTAIL_MIDDLEWARE = ( + 'wagtail.core.middleware.SiteMiddleware', + ) + + WAGTAILADMIN_RICH_TEXT_EDITORS = { + 'default': { + 'WIDGET': 'wagtail.admin.rich_text.DraftailRichTextArea' + }, + 'custom': { + 'WIDGET': 'wagtail.tests.testapp.rich_text.CustomRichTextArea' + }, + } +else: # pragma: no cover; fallback for Wagtail < 2.0 + WAGTAIL_APPS = ( + 'wagtail.contrib.modeladmin', + 'wagtail.contrib.settings', + 'wagtail.tests.testapp', + 'wagtail.wagtailadmin', + 'wagtail.wagtailcore', + 'wagtail.wagtaildocs', + 'wagtail.wagtailforms', + 'wagtail.wagtailimages', + 'wagtail.wagtailsites', + 'wagtail.wagtailusers', + ) + + WAGTAIL_MIDDLEWARE = ( + 'wagtail.wagtailcore.middleware.SiteMiddleware', + ) + + WAGTAILADMIN_RICH_TEXT_EDITORS = { + 'default': { + 'WIDGET': 'wagtail.wagtailadmin.rich_text.HalloRichTextArea', + }, + 'custom': { + 'WIDGET': 'wagtail.tests.testapp.rich_text.CustomRichTextArea' + }, + } + +if django.VERSION >= (1, 10): # pragma: no cover + MIDDLEWARE = ( + 'django.middleware.common.CommonMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + ) + WAGTAIL_MIDDLEWARE +else: # pragma: no cover; fallback for Django >= 1.10 + MIDDLEWARE_CLASSES = ( + 'django.middleware.common.CommonMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + ) + WAGTAIL_MIDDLEWARE + +INSTALLED_APPS = ( + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.staticfiles', + 'taggit', +) + WAGTAIL_APPS + ( + 'flags', + 'wagtailflags', +) + +STATIC_URL = '/static/' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + 'django.template.context_processors.request', + ], + 'debug': True, + }, + }, +] + +WAGTAIL_SITE_NAME = 'Test Site' + +FLAGS = { + 'FLAG_ENABLED': {'boolean': True}, + 'FLAG_DISABLED': {}, + 'DB_FLAG': {}, +} diff --git a/wagtailflags/tests/test_conditions.py b/wagtailflags/tests/test_conditions.py new file mode 100644 index 0000000..9e6810e --- /dev/null +++ b/wagtailflags/tests/test_conditions.py @@ -0,0 +1,40 @@ +from django.http import HttpRequest +from django.test import TestCase + +from flags.conditions import RequiredForCondition +from wagtailflags.conditions import site_condition + + +try: + from wagtail.core.models import Site +except ImportError: # pragma: no cover; fallback for Wagtail < 2.0 + from wagtail.wagtailcore.models import Site + + +class SiteConditionTestCase(TestCase): + + def setUp(self): + self.site = Site.objects.get(is_default_site=True) + self.request = HttpRequest() + self.request.site = self.site + + def test_site_valid_string(self): + self.assertTrue(site_condition('localhost:80', request=self.request)) + + def test_site_valid_string_no_port(self): + self.assertTrue(site_condition('localhost', request=self.request)) + + def test_site_valid_string_default_port(self): + self.assertTrue(site_condition('localhost [default]', + request=self.request)) + + def test_site_valid_site(self): + self.assertTrue(site_condition(str(self.site), request=self.request)) + + def test_site_invalid_site(self): + self.assertFalse(site_condition('non.existent.site', + request=self.request)) + + def test_request_required(self): + with self.assertRaises(RequiredForCondition): + site_condition('localhost:80') diff --git a/wagtailflags/tests/test_views.py b/wagtailflags/tests/test_views.py new file mode 100644 index 0000000..a95610a --- /dev/null +++ b/wagtailflags/tests/test_views.py @@ -0,0 +1,67 @@ +from django.test import TestCase + +from wagtail.tests.utils import WagtailTestUtils + +from flags.models import FlagState + + +class TestWagtailFlagsViews(TestCase, WagtailTestUtils): + + def setUp(self): + self.login() + + def test_flags_index(self): + self.orphaned_flag = FlagState.objects.create( + name='ORPHANED_FLAG', + condition='boolean', + value='True' + ) + + response = self.client.get('/admin/flags/') + self.assertEqual(response.status_code, 200) + + self.assertContains(response, 'FLAG_ENABLED') + self.assertContains(response, 'is enabled when') + + self.assertContains(response, 'FLAG_DISABLED') + self.assertContains(response, 'is never enabled') + + self.assertIn( + self.orphaned_flag, + response.context['flag_states'] + ) + self.assertNotIn( + self.orphaned_flag.name, + response.context['flags'] + ) + + def test_flags_delete(self): + state_obj = FlagState.objects.create( + name='', + condition='boolean', + value='True' + ) + self.assertEqual(len(FlagState.objects.all()), 1) + response = self.client.get( + '/admin/flags/' + str(state_obj.pk) + '/delete/' + ) + self.assertEqual(response.status_code, 200) + + response = self.client.post( + '/admin/flags/' + str(state_obj.pk) + '/delete/' + ) + self.assertRedirects(response, '/admin/flags/') + self.assertEqual(len(FlagState.objects.all()), 0) + + def test_flags_create(self): + response = self.client.get('/admin/flags/create/') + self.assertEqual(response.status_code, 200) + + params = { + 'name': 'DB_FLAG', + 'condition': 'boolean', + 'value': 'True', + } + response = self.client.post('/admin/flags/create/', params) + self.assertRedirects(response, '/admin/flags/') + self.assertEqual(len(FlagState.objects.all()), 1) diff --git a/wagtailflags/tests/urls.py b/wagtailflags/tests/urls.py new file mode 100644 index 0000000..4e0025e --- /dev/null +++ b/wagtailflags/tests/urls.py @@ -0,0 +1,12 @@ +from django.conf.urls import include, url + + +try: # pragma: no cover; Wagtail >= 2.0 + from wagtail.admin import urls as wagtailadmin_urls +except ImportError: # pragma: no cover; fallback for Wagtail < 2.0 + from wagtail.wagtailadmin import urls as wagtailadmin_urls + + +urlpatterns = [ + url(r'^admin/', include(wagtailadmin_urls)), +] diff --git a/wagtailflags/views.py b/wagtailflags/views.py new file mode 100644 index 0000000..925b54f --- /dev/null +++ b/wagtailflags/views.py @@ -0,0 +1,40 @@ +from collections import OrderedDict + +from django.shortcuts import get_object_or_404, redirect, render + +from flags.forms import FlagStateForm +from flags.models import FlagState +from flags.settings import get_flags + + +def index(request): + flags = OrderedDict(sorted(get_flags().items(), key=lambda x: x[0])) + context = { + 'flag_states': FlagState.objects.order_by('name'), + 'flags': flags, + } + return render(request, 'wagtailflags/index.html', context) + + +def create(request): + if request.method == 'POST': + form = FlagStateForm(request.POST) + if form.is_valid(): + form.save() + return redirect('wagtailflags:list') + else: + form = FlagStateForm() + + context = dict(form=form) + return render(request, 'wagtailflags/flags/create.html', context) + + +def delete(request, state_id): + flag_state = get_object_or_404(FlagState, pk=state_id) + + if request.method == 'POST': + flag_state.delete() + return redirect('wagtailflags:list') + + context = dict(state_str=str(flag_state), state_id=flag_state.pk) + return render(request, 'wagtailflags/flags/delete.html', context) diff --git a/wagtailflags/wagtail_hooks.py b/wagtailflags/wagtail_hooks.py new file mode 100644 index 0000000..2c8e364 --- /dev/null +++ b/wagtailflags/wagtail_hooks.py @@ -0,0 +1,47 @@ +import django +from django.conf.urls import include, url + +from wagtailflags import views + + +try: # pragma: no cover; >= 2.0 + from django.urls import reverse +except ImportError: # pragma: no cover; fallback for Django < 2.0 + from django.core.urlresolvers import reverse + +try: # pragma: no cover; Wagtail >= 2.0 + from wagtail.admin.menu import MenuItem + from wagtail.core import hooks +except ImportError: # pragma: no cover; fallback for Wagtail < 2.0 + from wagtail.wagtailadmin.menu import MenuItem + from wagtail.wagtailcore import hooks + + +@hooks.register('register_settings_menu_item') +def register_flags_menu(): + return MenuItem('Flags', reverse('wagtailflags:list'), + classnames='icon icon-tag', order=10000) + + +@hooks.register('register_admin_urls') +def register_flag_admin_urls(): + flagpatterns = [ + url(r'^$', views.index, name='list'), + url(r'^(\d+)/delete/$', views.delete, + name='delete'), + url(r'^create/$', views.create, name='create'), + ] + + if django.VERSION >= (1, 10): # pragma: no cover + urlpatterns = [ + url(r'^flags/', + include((flagpatterns, 'wagtailflags'), + namespace='wagtailflags')) + ] + else: # pragma: no cover; fallback for Django < 1.10 + urlpatterns = [ + url(r'^flags/', + include((flagpatterns, 'wagtailflags', 'wagtailflags'))) + ] + + return urlpatterns