Plain is headed towards 1.0! Subscribe for development updates →

  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