plain.flags
Local feature flags via database models.
Custom flags are written as subclasses of Flag
.
You define the flag's "key" and initial value,
and the results will be stored in the database for future reference.
# app/flags.py
from plain.flags import Flag
class FooEnabled(Flag):
def __init__(self, user):
self.user = user
def get_key(self):
return self.user
def get_value(self):
# Initially all users will have this feature disabled
# and we'll enable them manually in the admin
return False
Use flags in HTML templates:
{% if flags.FooEnabled(request.user) %}
<p>Foo is enabled for you!</p>
{% else %}
<p>Foo is disabled for you.</p>
{% endif %}
Or in Python:
import flags
print(flags.FooEnabled(user).value)
Installation
INSTALLED_PACKAGES = [
...
"plain.flags",
]
Create a flags.py
at the top of your app
(or point settings.FLAGS_MODULE
to a different location).
Advanced usage
Ultimately you can do whatever you want inside of get_key
and get_value
.
class OrganizationFeature(Flag):
url_param_name = ""
def __init__(self, request=None, organization=None):
# Both of these are optional, but will usually both be given
self.request = request
self.organization = organization
def get_key(self):
if (
self.url_param_name
and self.request
and self.url_param_name in self.request.GET
):
return None
if not self.organization:
# Don't save the flag result for PRs without an organization
return None
return self.organization
def get_value(self):
if self.url_param_name and self.request:
if self.request.GET.get(self.url_param_name) == "1":
return True
if self.request.GET.get(self.url_param_name) == "0":
return False
if not self.organization:
return False
# All organizations will start with False,
# and I'll override in the DB for the ones that should be True
return False
class AIEnabled(OrganizationFeature):
pass
1import re
2import uuid
3
4from plain import models
5from plain.exceptions import ValidationError
6from plain.models import ProgrammingError
7from plain.preflight import Info
8from plain.runtime import settings
9
10from .bridge import get_flag_class
11from .exceptions import FlagImportError
12
13
14def validate_flag_name(value):
15 if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", value):
16 raise ValidationError(f"{value} is not a valid Python identifier name")
17
18
19class FlagResult(models.Model):
20 uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
21 created_at = models.DateTimeField(auto_now_add=True)
22 updated_at = models.DateTimeField(auto_now=True)
23 flag = models.ForeignKey("Flag", on_delete=models.CASCADE)
24 key = models.CharField(max_length=255)
25 value = models.JSONField()
26
27 class Meta:
28 constraints = [
29 models.UniqueConstraint(
30 fields=["flag", "key"], name="unique_flag_result_key"
31 )
32 ]
33
34 def __str__(self):
35 return self.key
36
37
38class Flag(models.Model):
39 uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
40 created_at = models.DateTimeField(auto_now_add=True)
41 updated_at = models.DateTimeField(auto_now=True)
42 name = models.CharField(
43 max_length=255, unique=True, validators=[validate_flag_name]
44 )
45
46 # Optional description that can be filled in after the flag is used/created
47 description = models.TextField(blank=True)
48
49 # To manually disable a flag before completing deleting
50 # (good to disable first to make sure the code doesn't use the flag anymore)
51 enabled = models.BooleanField(default=True)
52
53 # To provide an easier way to see if a flag is still being used
54 used_at = models.DateTimeField(blank=True, null=True)
55
56 def __str__(self):
57 return self.name
58
59 @classmethod
60 def check(cls, **kwargs):
61 """
62 Check for flags that are in the database, but no longer defined in code.
63
64 Only returns Info errors because it is valid to leave them if you're worried about
65 putting the flag back, but they should probably be deleted eventually.
66 """
67 errors = super().check(**kwargs)
68
69 databases = kwargs["databases"]
70 if not databases:
71 return errors
72
73 for database in databases:
74 flag_names = (
75 cls.objects.using(database).all().values_list("name", flat=True)
76 )
77
78 try:
79 flag_names = set(flag_names)
80 except ProgrammingError:
81 # The table doesn't exist yet
82 # (migrations probably haven't run yet),
83 # so we can't check it.
84 continue
85
86 for flag_name in flag_names:
87 try:
88 get_flag_class(flag_name)
89 except FlagImportError:
90 errors.append(
91 Info(
92 f"Flag {flag_name} is not used.",
93 hint=f"Remove the flag from the database or define it in the {settings.FLAGS_MODULE} module.",
94 id="plain.flags.I001",
95 )
96 )
97
98 return errors