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
19@models.register_model
20class FlagResult(models.Model):
21 uuid = models.UUIDField(default=uuid.uuid4)
22 created_at = models.DateTimeField(auto_now_add=True)
23 updated_at = models.DateTimeField(auto_now=True)
24 flag = models.ForeignKey("Flag", on_delete=models.CASCADE)
25 key = models.CharField(max_length=255)
26 value = models.JSONField()
27
28 class Meta:
29 constraints = [
30 models.UniqueConstraint(
31 fields=["flag", "key"], name="plainflags_flagresult_unique_key"
32 ),
33 models.UniqueConstraint(
34 fields=["uuid"], name="plainflags_flagresult_unique_uuid"
35 ),
36 ]
37
38 def __str__(self):
39 return self.key
40
41
42@models.register_model
43class Flag(models.Model):
44 uuid = models.UUIDField(default=uuid.uuid4)
45 created_at = models.DateTimeField(auto_now_add=True)
46 updated_at = models.DateTimeField(auto_now=True)
47 name = models.CharField(max_length=255, validators=[validate_flag_name])
48
49 # Optional description that can be filled in after the flag is used/created
50 description = models.TextField(required=False)
51
52 # To manually disable a flag before completing deleting
53 # (good to disable first to make sure the code doesn't use the flag anymore)
54 enabled = models.BooleanField(default=True)
55
56 # To provide an easier way to see if a flag is still being used
57 used_at = models.DateTimeField(required=False, allow_null=True)
58
59 class Meta:
60 constraints = [
61 models.UniqueConstraint(
62 fields=["name"], name="plainflags_flag_unique_name"
63 ),
64 models.UniqueConstraint(
65 fields=["uuid"], name="plainflags_flag_unique_uuid"
66 ),
67 ]
68
69 def __str__(self):
70 return self.name
71
72 @classmethod
73 def check(cls, **kwargs):
74 """
75 Check for flags that are in the database, but no longer defined in code.
76
77 Only returns Info errors because it is valid to leave them if you're worried about
78 putting the flag back, but they should probably be deleted eventually.
79 """
80 errors = super().check(**kwargs)
81
82 databases = kwargs["databases"]
83 if not databases:
84 return errors
85
86 for database in databases:
87 flag_names = (
88 cls.objects.using(database).all().values_list("name", flat=True)
89 )
90
91 try:
92 flag_names = set(flag_names)
93 except ProgrammingError:
94 # The table doesn't exist yet
95 # (migrations probably haven't run yet),
96 # so we can't check it.
97 continue
98
99 for flag_name in flag_names:
100 try:
101 get_flag_class(flag_name)
102 except FlagImportError:
103 errors.append(
104 Info(
105 f"Flag {flag_name} is not used.",
106 hint=f"Remove the flag from the database or define it in the {settings.FLAGS_MODULE} module.",
107 id="plain.flags.I001",
108 )
109 )
110
111 return errors