plain.flags 0.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plain_flags-0.0.0/LICENSE +28 -0
- plain_flags-0.0.0/PKG-INFO +10 -0
- plain_flags-0.0.0/plain/flags/README.md +103 -0
- plain_flags-0.0.0/plain/flags/__init__.py +3 -0
- plain_flags-0.0.0/plain/flags/admin.py +75 -0
- plain_flags-0.0.0/plain/flags/bridge.py +28 -0
- plain_flags-0.0.0/plain/flags/config.py +6 -0
- plain_flags-0.0.0/plain/flags/default_settings.py +1 -0
- plain_flags-0.0.0/plain/flags/exceptions.py +10 -0
- plain_flags-0.0.0/plain/flags/flags.py +105 -0
- plain_flags-0.0.0/plain/flags/jinja.py +5 -0
- plain_flags-0.0.0/plain/flags/migrations/0001_initial.py +78 -0
- plain_flags-0.0.0/plain/flags/migrations/__init__.py +0 -0
- plain_flags-0.0.0/plain/flags/models.py +93 -0
- plain_flags-0.0.0/plain/flags/py.typed +0 -0
- plain_flags-0.0.0/plain/flags/templates/admin/plainflags/flagresult_form.html +8 -0
- plain_flags-0.0.0/plain/flags/utils.py +17 -0
- plain_flags-0.0.0/pyproject.toml +17 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023, Dropseed, LLC
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: plain.flags
|
|
3
|
+
Version: 0.0.0
|
|
4
|
+
Summary:
|
|
5
|
+
Author: Dave Gaeddert
|
|
6
|
+
Author-email: dave.gaeddert@dropseed.dev
|
|
7
|
+
Requires-Python: >=3.11,<4.0
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# plain-flags
|
|
2
|
+
|
|
3
|
+
Local feature flags via database models.
|
|
4
|
+
|
|
5
|
+
Custom flags are written as subclasses of [`Flag`](./flags.py).
|
|
6
|
+
You define the flag's "key" and initial value,
|
|
7
|
+
and the results will be stored in the database for future reference.
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
# app/flags.py
|
|
11
|
+
from plain.flags import Flag
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FooEnabled(Flag):
|
|
15
|
+
def __init__(self, user):
|
|
16
|
+
self.user = user
|
|
17
|
+
|
|
18
|
+
def get_key(self):
|
|
19
|
+
return self.user
|
|
20
|
+
|
|
21
|
+
def get_value(self):
|
|
22
|
+
# Initially all users will have this feature disabled
|
|
23
|
+
# and we'll enable them manually in the admin
|
|
24
|
+
return False
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Use flags in HTML templates:
|
|
28
|
+
|
|
29
|
+
```html
|
|
30
|
+
{% if flags.FooEnabled(request.user) %}
|
|
31
|
+
<p>Foo is enabled for you!</p>
|
|
32
|
+
{% else %}
|
|
33
|
+
<p>Foo is disabled for you.</p>
|
|
34
|
+
{% endif %}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Or in Python:
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
import flags
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
print(flags.FooEnabled(user).value)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Installation
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
INSTALLED_PACKAGES = [
|
|
50
|
+
...
|
|
51
|
+
"plain.flags",
|
|
52
|
+
]
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Create a `flags.py` at the top of your `app` (or point `settings.FLAGS_MODULE` to a different location).
|
|
56
|
+
|
|
57
|
+
## Advanced usage
|
|
58
|
+
|
|
59
|
+
Ultimately you can do whatever you want inside of `get_key` and `get_value`.
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
class OrganizationFeature(Flag):
|
|
63
|
+
url_param_name = ""
|
|
64
|
+
|
|
65
|
+
def __init__(self, request=None, organization=None):
|
|
66
|
+
# Both of these are optional, but will usually both be given
|
|
67
|
+
self.request = request
|
|
68
|
+
self.organization = organization
|
|
69
|
+
|
|
70
|
+
def get_key(self):
|
|
71
|
+
if (
|
|
72
|
+
self.url_param_name
|
|
73
|
+
and self.request
|
|
74
|
+
and self.url_param_name in self.request.GET
|
|
75
|
+
):
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
if not self.organization:
|
|
79
|
+
# Don't save the flag result for PRs without an organization
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
return self.organization
|
|
83
|
+
|
|
84
|
+
def get_value(self):
|
|
85
|
+
if self.url_param_name and self.request:
|
|
86
|
+
if self.request.GET.get(self.url_param_name) == "1":
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
if self.request.GET.get(self.url_param_name) == "0":
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
if not self.organization:
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
# All organizations will start with False,
|
|
96
|
+
# and I'll override in the DB for the ones that should be True
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class AIEnabled(OrganizationFeature):
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
```
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from functools import cached_property
|
|
2
|
+
|
|
3
|
+
from plain.models.forms import ModelForm
|
|
4
|
+
from plain.staff.admin.cards import Card
|
|
5
|
+
from plain.staff.admin.views import (
|
|
6
|
+
AdminModelDetailView,
|
|
7
|
+
AdminModelListView,
|
|
8
|
+
AdminModelUpdateView,
|
|
9
|
+
AdminModelViewset,
|
|
10
|
+
register_viewset,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
from .models import Flag, FlagResult
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UnusedFlagsCard(Card):
|
|
17
|
+
title = "Unused Flags"
|
|
18
|
+
|
|
19
|
+
@cached_property
|
|
20
|
+
def flag_errors(self):
|
|
21
|
+
return Flag.check(databases=["default"])
|
|
22
|
+
|
|
23
|
+
def get_number(self):
|
|
24
|
+
return len(self.flag_errors)
|
|
25
|
+
|
|
26
|
+
def get_text(self):
|
|
27
|
+
return "\n".join(str(e.msg) for e in self.flag_errors)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@register_viewset
|
|
31
|
+
class FlagAdmin(AdminModelViewset):
|
|
32
|
+
class ListView(AdminModelListView):
|
|
33
|
+
model = Flag
|
|
34
|
+
fields = ["name", "enabled", "created_at__date", "used_at__date", "uuid"]
|
|
35
|
+
search_fields = ["name", "description"]
|
|
36
|
+
cards = [UnusedFlagsCard]
|
|
37
|
+
nav_section = "Feature flags"
|
|
38
|
+
|
|
39
|
+
class DetailView(AdminModelDetailView):
|
|
40
|
+
model = Flag
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class FlagResultForm(ModelForm):
|
|
44
|
+
class Meta:
|
|
45
|
+
model = FlagResult
|
|
46
|
+
fields = ["key", "value"]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@register_viewset
|
|
50
|
+
class FlagResultAdmin(AdminModelViewset):
|
|
51
|
+
class ListView(AdminModelListView):
|
|
52
|
+
model = FlagResult
|
|
53
|
+
title = "Flag results"
|
|
54
|
+
fields = [
|
|
55
|
+
"flag",
|
|
56
|
+
"key",
|
|
57
|
+
"value",
|
|
58
|
+
"created_at__date",
|
|
59
|
+
"updated_at__date",
|
|
60
|
+
"uuid",
|
|
61
|
+
]
|
|
62
|
+
search_fields = ["flag__name", "key"]
|
|
63
|
+
nav_section = "Feature flags"
|
|
64
|
+
|
|
65
|
+
def get_initial_queryset(self):
|
|
66
|
+
return self.model.objects.all().select_related("flag")
|
|
67
|
+
|
|
68
|
+
class DetailView(AdminModelDetailView):
|
|
69
|
+
model = FlagResult
|
|
70
|
+
title = "Flag result"
|
|
71
|
+
|
|
72
|
+
class UpdateView(AdminModelUpdateView):
|
|
73
|
+
model = FlagResult
|
|
74
|
+
title = "Update flag result"
|
|
75
|
+
form_class = FlagResultForm
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from plain.runtime import settings
|
|
2
|
+
|
|
3
|
+
from . import exceptions
|
|
4
|
+
from .flags import Flag
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_flags_module():
|
|
8
|
+
flags_module = settings.FLAGS_MODULE
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
return __import__(flags_module)
|
|
12
|
+
except ImportError as e:
|
|
13
|
+
raise exceptions.FlagImportError(
|
|
14
|
+
f"Could not import {flags_module} module"
|
|
15
|
+
) from e
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_flag_class(flag_name: str) -> Flag:
|
|
19
|
+
flags_module = get_flags_module()
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
flag_class = getattr(flags_module, flag_name)
|
|
23
|
+
except AttributeError as e:
|
|
24
|
+
raise exceptions.FlagImportError(
|
|
25
|
+
f"Could not find {flag_name} in {flags_module} module"
|
|
26
|
+
) from e
|
|
27
|
+
|
|
28
|
+
return flag_class
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
FLAGS_MODULE: str = "flags"
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from plain.runtime import settings
|
|
5
|
+
from plain.utils import timezone
|
|
6
|
+
from plain.utils.functional import cached_property
|
|
7
|
+
|
|
8
|
+
from . import exceptions
|
|
9
|
+
from .utils import coerce_key
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Flag:
|
|
15
|
+
def get_key(self) -> Any:
|
|
16
|
+
"""
|
|
17
|
+
Determine a unique key for this instance of the flag.
|
|
18
|
+
This should be a quick operation, as it will be called on every use of the flag.
|
|
19
|
+
|
|
20
|
+
For convenience, you can return an instance of a Plain Model
|
|
21
|
+
and it will be converted to a string automatically.
|
|
22
|
+
|
|
23
|
+
Return a falsy value if you don't want to store the flag result.
|
|
24
|
+
"""
|
|
25
|
+
raise NotImplementedError
|
|
26
|
+
|
|
27
|
+
def get_value(self) -> Any:
|
|
28
|
+
"""
|
|
29
|
+
Compute the resulting value of the flag.
|
|
30
|
+
|
|
31
|
+
The value needs to be JSON serializable.
|
|
32
|
+
|
|
33
|
+
If get_key() returns a value, this will only be called once per key
|
|
34
|
+
and then subsequent calls will return the saved value from the DB.
|
|
35
|
+
"""
|
|
36
|
+
raise NotImplementedError
|
|
37
|
+
|
|
38
|
+
def get_db_name(self) -> str:
|
|
39
|
+
"""
|
|
40
|
+
Should basically always be the name of the class.
|
|
41
|
+
But this is overridable in case of renaming/refactoring/importing.
|
|
42
|
+
"""
|
|
43
|
+
return self.__class__.__name__
|
|
44
|
+
|
|
45
|
+
def retrieve_or_compute_value(self) -> Any:
|
|
46
|
+
"""
|
|
47
|
+
Retrieve the value from the DB if it exists,
|
|
48
|
+
otherwise compute the value and save it to the DB.
|
|
49
|
+
"""
|
|
50
|
+
from .models import Flag, FlagResult # So Plain app is ready...
|
|
51
|
+
|
|
52
|
+
# Create an associated DB Flag that we can use to enable/disable
|
|
53
|
+
# and tie the results to
|
|
54
|
+
flag_obj, _ = Flag.objects.update_or_create(
|
|
55
|
+
name=self.get_db_name(),
|
|
56
|
+
defaults={"used_at": timezone.now()},
|
|
57
|
+
)
|
|
58
|
+
if not flag_obj.enabled:
|
|
59
|
+
msg = f"The {flag_obj} flag has been disabled and should either not be called, or be re-enabled."
|
|
60
|
+
if settings.DEBUG:
|
|
61
|
+
raise exceptions.FlagDisabled(msg)
|
|
62
|
+
else:
|
|
63
|
+
logger.exception(msg)
|
|
64
|
+
# Might not be the type of return value expected! Better than totally crashing now though.
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
key = self.get_key()
|
|
68
|
+
if not key:
|
|
69
|
+
# No key, so we always recompute the value and return it
|
|
70
|
+
return self.get_value()
|
|
71
|
+
|
|
72
|
+
key = coerce_key(key)
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
flag_result = FlagResult.objects.get(flag=flag_obj, key=key)
|
|
76
|
+
return flag_result.value
|
|
77
|
+
except FlagResult.DoesNotExist:
|
|
78
|
+
value = self.get_value()
|
|
79
|
+
flag_result = FlagResult.objects.create(flag=flag_obj, key=key, value=value)
|
|
80
|
+
return flag_result.value
|
|
81
|
+
|
|
82
|
+
@cached_property
|
|
83
|
+
def value(self) -> Any:
|
|
84
|
+
"""
|
|
85
|
+
Cached version of retrieve_or_compute_value()
|
|
86
|
+
"""
|
|
87
|
+
return self.retrieve_or_compute_value()
|
|
88
|
+
|
|
89
|
+
def __bool__(self) -> bool:
|
|
90
|
+
"""
|
|
91
|
+
Allow for use in boolean expressions.
|
|
92
|
+
"""
|
|
93
|
+
return bool(self.value)
|
|
94
|
+
|
|
95
|
+
def __contains__(self, item) -> bool:
|
|
96
|
+
"""
|
|
97
|
+
Allow for use in `in` expressions.
|
|
98
|
+
"""
|
|
99
|
+
return item in self.value
|
|
100
|
+
|
|
101
|
+
def __eq__(self, other) -> bool:
|
|
102
|
+
"""
|
|
103
|
+
Allow for use in `==` expressions.
|
|
104
|
+
"""
|
|
105
|
+
return self.value == other
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Generated by Plain 4.1.7 on 2023-03-21 19:54
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
import plain.flags.models
|
|
6
|
+
import plain.models.deletion
|
|
7
|
+
from plain import models
|
|
8
|
+
from plain.models import migrations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Migration(migrations.Migration):
|
|
12
|
+
initial = True
|
|
13
|
+
|
|
14
|
+
dependencies = []
|
|
15
|
+
|
|
16
|
+
operations = [
|
|
17
|
+
migrations.CreateModel(
|
|
18
|
+
name="Flag",
|
|
19
|
+
fields=[
|
|
20
|
+
(
|
|
21
|
+
"id",
|
|
22
|
+
models.BigAutoField(
|
|
23
|
+
auto_created=True,
|
|
24
|
+
primary_key=True,
|
|
25
|
+
serialize=False,
|
|
26
|
+
),
|
|
27
|
+
),
|
|
28
|
+
(
|
|
29
|
+
"uuid",
|
|
30
|
+
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
|
|
31
|
+
),
|
|
32
|
+
("created_at", models.DateTimeField(auto_now_add=True)),
|
|
33
|
+
("updated_at", models.DateTimeField(auto_now=True)),
|
|
34
|
+
(
|
|
35
|
+
"name",
|
|
36
|
+
models.CharField(
|
|
37
|
+
max_length=255,
|
|
38
|
+
unique=True,
|
|
39
|
+
validators=[plain.flags.models.validate_flag_name],
|
|
40
|
+
),
|
|
41
|
+
),
|
|
42
|
+
("description", models.TextField(blank=True)),
|
|
43
|
+
("enabled", models.BooleanField(default=True)),
|
|
44
|
+
("used_at", models.DateTimeField(blank=True, null=True)),
|
|
45
|
+
],
|
|
46
|
+
),
|
|
47
|
+
migrations.CreateModel(
|
|
48
|
+
name="FlagResult",
|
|
49
|
+
fields=[
|
|
50
|
+
(
|
|
51
|
+
"id",
|
|
52
|
+
models.BigAutoField(
|
|
53
|
+
auto_created=True,
|
|
54
|
+
primary_key=True,
|
|
55
|
+
serialize=False,
|
|
56
|
+
),
|
|
57
|
+
),
|
|
58
|
+
(
|
|
59
|
+
"uuid",
|
|
60
|
+
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
|
|
61
|
+
),
|
|
62
|
+
("created_at", models.DateTimeField(auto_now_add=True)),
|
|
63
|
+
("updated_at", models.DateTimeField(auto_now=True)),
|
|
64
|
+
("key", models.CharField(max_length=255)),
|
|
65
|
+
("value", models.JSONField()),
|
|
66
|
+
(
|
|
67
|
+
"flag",
|
|
68
|
+
models.ForeignKey(
|
|
69
|
+
on_delete=plain.models.deletion.CASCADE,
|
|
70
|
+
to="plainflags.flag",
|
|
71
|
+
),
|
|
72
|
+
),
|
|
73
|
+
],
|
|
74
|
+
options={
|
|
75
|
+
"unique_together": {("flag", "key")},
|
|
76
|
+
},
|
|
77
|
+
),
|
|
78
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import uuid
|
|
3
|
+
|
|
4
|
+
from plain.exceptions import ValidationError
|
|
5
|
+
from plain.models import ProgrammingError, models
|
|
6
|
+
from plain.preflight import Info
|
|
7
|
+
from plain.runtime import settings
|
|
8
|
+
|
|
9
|
+
from .bridge import get_flag_class
|
|
10
|
+
from .exceptions import FlagImportError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def validate_flag_name(value):
|
|
14
|
+
if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", value):
|
|
15
|
+
raise ValidationError(f"{value} is not a valid Python identifier name")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FlagResult(models.Model):
|
|
19
|
+
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
|
|
20
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
21
|
+
updated_at = models.DateTimeField(auto_now=True)
|
|
22
|
+
flag = models.ForeignKey("Flag", on_delete=models.CASCADE)
|
|
23
|
+
key = models.CharField(max_length=255)
|
|
24
|
+
value = models.JSONField()
|
|
25
|
+
|
|
26
|
+
class Meta:
|
|
27
|
+
unique_together = ("flag", "key")
|
|
28
|
+
|
|
29
|
+
def __str__(self):
|
|
30
|
+
return self.key
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Flag(models.Model):
|
|
34
|
+
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
|
|
35
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
36
|
+
updated_at = models.DateTimeField(auto_now=True)
|
|
37
|
+
name = models.CharField(
|
|
38
|
+
max_length=255, unique=True, validators=[validate_flag_name]
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Optional description that can be filled in after the flag is used/created
|
|
42
|
+
description = models.TextField(blank=True)
|
|
43
|
+
|
|
44
|
+
# To manually disable a flag before completing deleting
|
|
45
|
+
# (good to disable first to make sure the code doesn't use the flag anymore)
|
|
46
|
+
enabled = models.BooleanField(default=True)
|
|
47
|
+
|
|
48
|
+
# To provide an easier way to see if a flag is still being used
|
|
49
|
+
used_at = models.DateTimeField(blank=True, null=True)
|
|
50
|
+
|
|
51
|
+
def __str__(self):
|
|
52
|
+
return self.name
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def check(cls, **kwargs):
|
|
56
|
+
"""
|
|
57
|
+
Check for flags that are in the database, but no longer defined in code.
|
|
58
|
+
|
|
59
|
+
Only returns Info errors because it is valid to leave them if you're worried about
|
|
60
|
+
putting the flag back, but they should probably be deleted eventually.
|
|
61
|
+
"""
|
|
62
|
+
errors = super().check(**kwargs)
|
|
63
|
+
|
|
64
|
+
databases = kwargs["databases"]
|
|
65
|
+
if not databases:
|
|
66
|
+
return errors
|
|
67
|
+
|
|
68
|
+
for database in databases:
|
|
69
|
+
flag_names = (
|
|
70
|
+
cls.objects.using(database).all().values_list("name", flat=True)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
flag_names = set(flag_names)
|
|
75
|
+
except ProgrammingError:
|
|
76
|
+
# The table doesn't exist yet
|
|
77
|
+
# (migrations probably haven't run yet),
|
|
78
|
+
# so we can't check it.
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
for flag_name in flag_names:
|
|
82
|
+
try:
|
|
83
|
+
get_flag_class(flag_name)
|
|
84
|
+
except FlagImportError:
|
|
85
|
+
errors.append(
|
|
86
|
+
Info(
|
|
87
|
+
f"Flag {flag_name} is not used.",
|
|
88
|
+
hint=f"Remove the flag from the database or define it in the {settings.FLAGS_MODULE} module.",
|
|
89
|
+
id="plain.flags.I001",
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
return errors
|
|
File without changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from plain import models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def coerce_key(key: Any) -> str:
|
|
7
|
+
"""
|
|
8
|
+
Converts a flag key to a string for storage in the DB
|
|
9
|
+
(special handling of model instances)
|
|
10
|
+
"""
|
|
11
|
+
if isinstance(key, str):
|
|
12
|
+
return key
|
|
13
|
+
|
|
14
|
+
if isinstance(key, models.Model):
|
|
15
|
+
return f"{key._meta.package_label}.{key._meta.model_name}:{key.pk}"
|
|
16
|
+
|
|
17
|
+
return str(key)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "plain.flags"
|
|
3
|
+
packages = [
|
|
4
|
+
{ include = "plain" },
|
|
5
|
+
]
|
|
6
|
+
version = "0.0.0"
|
|
7
|
+
description = ""
|
|
8
|
+
authors = ["Dave Gaeddert <dave.gaeddert@dropseed.dev>"]
|
|
9
|
+
# readme = "README.md"
|
|
10
|
+
|
|
11
|
+
[tool.poetry.dependencies]
|
|
12
|
+
python = "^3.11"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["poetry-core"]
|
|
17
|
+
build-backend = "poetry.core.masonry.api"
|