Skip to content

Commit

Permalink
Add API and management commands for enabling/disabling flags
Browse files Browse the repository at this point in the history
This change adds two flag state functions, `enable_flag` and `disable_flag`, that will either add or modify an existing boolean condition to either enable or disable a flag.

It also adds an `enable_flag` and `disable_flag` management commands which call those functions. This will enable easier enabling/disabling of a flag through automations, etc.

If a flag has required conditions, those will take precedence over the boolean that's added/modified.
  • Loading branch information
willbarton committed Feb 23, 2021
1 parent 88692db commit fd4ddf1
Show file tree
Hide file tree
Showing 13 changed files with 236 additions and 8 deletions.
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.

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

## `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.

## `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.

9 changes: 9 additions & 0 deletions docs/releasenotes.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# 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 sets 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

0 comments on commit fd4ddf1

Please sign in to comment.