Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Django-Flags

[![Build Status](https://github.com/cfpb/django-flags/workflows/test/badge.svg)](https://github.com/cfpb/django-flags/actions)
[![Coverage Status](https://coveralls.io/repos/github/cfpb/django-flags/badge.svg?branch=master)](https://coveralls.io/github/cfpb/django-flags?branch=master)
[![Coverage Status](https://coveralls.io/repos/github/cfpb/django-flags/badge.svg?branch=main)](https://coveralls.io/github/cfpb/django-flags?branch=main)
[![Ethical open source](https://img.shields.io/badge/open-ethical-%234baaaa)](https://ethicalsource.dev/definition/)

Feature flags allow you to toggle functionality in both Django code and the Django templates based on configurable conditions. Flags can be useful for staging feature deployments, for A/B testing, or for any time you need an on/off switch for blocks of code. The toggle can be by date, user, URL value, or a number of [other conditions](https://cfpb.github.io/django-flags/conditions/), editable in the admin or in definable in settings.
Expand Down
19 changes: 19 additions & 0 deletions docs/api/state.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ from flags.state import (
flag_state,
flag_enabled,
flag_disabled,
enable_flag,
disable_flag,
)
```

Expand All @@ -19,6 +21,7 @@ from flags.state import (

Return the value for the flag (`True` or `False`) by passing kwargs to its conditions. If the flag does not exist, this will return `None` so that existence can be introspected but will still evaluate to `False`.


## Requiring state

### `flag_enabled(flag_name, **kwargs)`
Expand All @@ -38,3 +41,19 @@ Returns `True` if a flag is disabled by passing kwargs to its conditions, otherw
if flag_disabled('MY_FLAG', request=a_request):
print(“My feature flag is disabled”)
```


## Setting state

### `enable_flag(flag_name, create_boolean_condition=True, request=None)`

Enable a flag by adding or setting an existing database boolean condition to `True`. If the flag has other required conditions, those will take precedence.

If `create_boolean_condition` is `False`, and a boolean database condition does not already exist, a `ValueError` will be raised.

### `disable_flag(flag_name, create_boolean_condition=True, request=None)`

Disable a flag by adding or setting an existing database boolean condition to `False`. If the flag has other required conditions, those will take precedence.

If `create_boolean_condition` is `False`, and a boolean database condition does not already exist, a `ValueError` will be raised.

23 changes: 23 additions & 0 deletions docs/management_commands.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Management Commands

Django-Flags provides two management commands that allow for enabling and disabling of feature flags from the command line.

## `enable_flag FLAG_NAME`

Enable a flag by adding or setting an existing database boolean condition to `True`. If the flag has other required conditions, those will take precedence.

This command calls the [`flags.state.enable_flag` function](../api/state#enable_flagflag_name-create_boolean_conditiontrue-requestnone) function.

```
./manage.py enable_flag MY_FLAG
```

## `disable_flag FLAG_NAME`

Disable a flag by adding or setting an existing database boolean condition to `False`. If the flag has other required conditions, those will take precedence.

This command calls the [`flags.state.enable_flag` function](../api/state#disable_flagflag_name-create_boolean_conditiontrue-requestnone) function.

```
./manage.py disable_flag MY_FLAG
```
8 changes: 8 additions & 0 deletions docs/releasenotes.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Release Notes

## Unreleased

### What's new?

- Added [`enable_flag`](../api/state/#enable_flagflag_name-create_boolean_conditiontrue-requestnone) and [`disable_flag`](../api/state/#disable_flagflag_name-create_boolean_conditiontrue-requestnone) functions.
- Added [`enable_flag`](../management_commands/#enable_flag-flag_name) and [`disable_flag`](../management_commands/#disable_flag-flag_name) management commands.


## 5.0.2

### What's new?
Expand Down
Empty file added flags/management/__init__.py
Empty file.
Empty file.
25 changes: 25 additions & 0 deletions flags/management/commands/disable_flag.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from django.core.management.base import BaseCommand, CommandError

from flags.state import disable_flag


class Command(BaseCommand):
help = (
"Disables the given feature flag "
"unless any required conditions (if defined) are met"
)

def add_arguments(self, parser):
parser.add_argument(
"flag_name", help="The name of the feature flag to disable"
)

def handle(self, *args, **options):
try:
disable_flag(options["flag_name"])
except KeyError as e:
raise CommandError(e)

self.stdout.write(
self.style.SUCCESS(f"Successfully disabled {options['flag_name']}")
)
25 changes: 25 additions & 0 deletions flags/management/commands/enable_flag.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from django.core.management.base import BaseCommand, CommandError

from flags.state import enable_flag


class Command(BaseCommand):
help = (
"Enables the given feature flag "
"when any required conditions (if defined) are met"
)

def add_arguments(self, parser):
parser.add_argument(
"flag_name", help="The name of the feature flag to enable"
)

def handle(self, *args, **options):
try:
enable_flag(options["flag_name"])
except KeyError as e:
raise CommandError(e)

self.stdout.write(
self.style.SUCCESS(f"Successfully enabled {options['flag_name']}")
)
6 changes: 3 additions & 3 deletions flags/panels.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

logger = logging.getLogger(__name__)

_original_flag_state = state._flag_state
_original_flag_state = state._get_flag_state


class FlagsPanel(Panel):
Expand Down Expand Up @@ -56,11 +56,11 @@ def recording_flag_state(flag_name, **kwargs):

return result

state._flag_state = recording_flag_state
state._get_flag_state = recording_flag_state

def disable_instrumentation(self):
# Restore the original functions
state._flag_state = _original_flag_state
state._get_flag_state = _original_flag_state

def generate_stats(self, request, response):
self.record_stats({"request": request, "checks": self.checks})
60 changes: 57 additions & 3 deletions flags/state.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from django.apps import apps
from django.core.exceptions import AppRegistryNotReady

from flags.models import FlagState
from flags.sources import get_flags


def _flag_state(flag_name, **kwargs):
""" This is a private function that performs the actual state checking """
def _get_flag_state(flag_name, **kwargs):
""" A private function that performs the actual state checking """
flags = get_flags(request=kwargs.get("request"))

flag = flags.get(flag_name)
Expand All @@ -15,6 +16,39 @@ def _flag_state(flag_name, **kwargs):
return None


def _set_flag_state(
flag_name, state, create_boolean_condition=True, request=None
):
""" A private function to set a boolean condition to the desired state """
flags = get_flags(request=request)
flag = flags.get(flag_name)
if flag is None:
raise KeyError(f"No flag with name {flag_name} exists")

db_boolean_condition = next(
(
c
for c in flag.conditions
if c.condition == "boolean" and getattr(c, "obj", None) is not None
),
None,
)

if db_boolean_condition is not None:
# We already have a boolean condition
boolean_condition_obj = db_boolean_condition.obj
elif db_boolean_condition is None and create_boolean_condition:
# We can create a boolean condition and we need to.
boolean_condition_obj = FlagState.objects.create(
name=flag_name, condition="boolean", value="True"
)
else:
raise ValueError(f"Flag {flag_name} does not have a boolean condition")

boolean_condition_obj.value = state
boolean_condition_obj.save()


def flag_state(flag_name, **kwargs):
""" Return the value for the flag by passing kwargs to its conditions """
if not apps.ready:
Expand All @@ -23,7 +57,7 @@ def flag_state(flag_name, **kwargs):
"is ready."
)

return _flag_state(flag_name, **kwargs)
return _get_flag_state(flag_name, **kwargs)


def flag_enabled(flag_name, **kwargs):
Expand All @@ -34,3 +68,23 @@ def flag_enabled(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)


def enable_flag(flag_name, create_boolean_condition=True, request=None):
""" Add or set a boolean condition to `True` """
_set_flag_state(
flag_name,
True,
create_boolean_condition=create_boolean_condition,
request=request,
)


def disable_flag(flag_name, create_boolean_condition=True, request=None):
""" Add or set a boolean condition to `False` """
_set_flag_state(
flag_name,
False,
create_boolean_condition=create_boolean_condition,
request=request,
)
24 changes: 24 additions & 0 deletions flags/tests/test_management_commands_disable_flag.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from io import StringIO

from django.core.management import call_command
from django.core.management.base import CommandError
from django.test import TestCase

from flags.models import FlagState
from flags.state import flag_disabled


class disableFlagTestCase(TestCase):
def test_disable_flag(self):
FlagState.objects.create(
name="DB_FLAG", condition="boolean", value="True"
)
out = StringIO()
self.assertFalse(flag_disabled("DB_FLAG"))
call_command("disable_flag", "DB_FLAG", stdout=out)
self.assertTrue(flag_disabled("DB_FLAG"))
self.assertIn("Successfully disabled", out.getvalue())

def test_disable_flag_non_existent_flag(self):
with self.assertRaises(CommandError):
call_command("disable_flag", "FLAG_DOES_NOT_EXIST")
20 changes: 20 additions & 0 deletions flags/tests/test_management_commands_enable_flag.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from io import StringIO

from django.core.management import call_command
from django.core.management.base import CommandError
from django.test import TestCase

from flags.state import flag_enabled


class EnableFlagTestCase(TestCase):
def test_enable_flag(self):
out = StringIO()
self.assertFalse(flag_enabled("DB_FLAG"))
call_command("enable_flag", "DB_FLAG", stdout=out)
self.assertTrue(flag_enabled("DB_FLAG"))
self.assertIn("Successfully enabled", out.getvalue())

def test_enable_flag_non_existent_flag(self):
with self.assertRaises(CommandError):
call_command("enable_flag", "FLAG_DOES_NOT_EXIST")
38 changes: 37 additions & 1 deletion flags/tests/test_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@
from django.core.exceptions import AppRegistryNotReady
from django.test import RequestFactory, TestCase

from flags.state import flag_disabled, flag_enabled, flag_state
from flags.models import FlagState
from flags.state import (
disable_flag,
enable_flag,
flag_disabled,
flag_enabled,
flag_state,
)


class FlagStateTestCase(TestCase):
Expand Down Expand Up @@ -52,3 +59,32 @@ def test_flag_disabled_global_disabled(self):
def test_flag_disabled_global_enabled(self):
""" Global flags enabled should be False """
self.assertFalse(flag_disabled("FLAG_ENABLED"))

def test_enable_flag_boolean_exists(self):
FlagState.objects.create(
name="DB_FLAG", condition="boolean", value="False"
)
self.assertFalse(flag_enabled("DB_FLAG"))
enable_flag("DB_FLAG")
self.assertTrue(flag_enabled("DB_FLAG"))

def test_enable_flag_creates_boolean(self):
self.assertFalse(flag_enabled("DB_FLAG"))
enable_flag("DB_FLAG")
self.assertTrue(flag_enabled("DB_FLAG"))

def test_enable_flag_without_creating_boolean(self):
with self.assertRaises(ValueError):
enable_flag("DB_FLAG", create_boolean_condition=False)

def test_enable_flag_non_existent_flag(self):
with self.assertRaises(KeyError):
enable_flag("FLAG_DOES_NOT_EXIST")

def test_disable_flag(self):
FlagState.objects.create(
name="DB_FLAG", condition="boolean", value="True"
)
self.assertFalse(flag_disabled("DB_FLAG"))
disable_flag("DB_FLAG")
self.assertTrue(flag_disabled("DB_FLAG"))
4 changes: 3 additions & 1 deletion mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ pages:
- Debugging: debugging.md
- Settings: settings.md
- Conditions: conditions.md
- Management commands: management_commands.md
- API Reference:
- Flag sources: api/sources.md
- Flag state: api/state.md
Expand All @@ -15,7 +16,8 @@ pages:
- Django templates: api/django.md
- Jinja2 templates: api/jinja2.md
- Conditions: api/conditions.md
- Release notes: releasenotes.md
- Release notes:
- Release notes: releasenotes.md
theme:
name: 'readthedocs'
custom_dir: docs/theme-overrides/
Expand Down