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        database = kwargs.get("database", False)
 83        if not database:
 84            return errors
 85
 86        flag_names = cls.objects.all().values_list("name", flat=True)
 87
 88        try:
 89            flag_names = set(flag_names)
 90        except ProgrammingError:
 91            # The table doesn't exist yet
 92            # (migrations probably haven't run yet),
 93            # so we can't check it.
 94            return errors
 95
 96        for flag_name in flag_names:
 97            try:
 98                get_flag_class(flag_name)
 99            except FlagImportError:
100                errors.append(
101                    Info(
102                        f"Flag {flag_name} is not used.",
103                        hint=f"Remove the flag from the database or define it in the {settings.FLAGS_MODULE} module.",
104                        id="plain.flags.I001",
105                    )
106                )
107
108        return errors