Plain is headed towards 1.0! Subscribe for development updates →

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