Пример #1
0
class RecipeRevision(models.Model):
    APPROVED = 'approved'
    REJECTED = 'rejected'
    PENDING = 'pending'

    id = models.CharField(max_length=64, primary_key=True)
    parent = models.OneToOneField('self',
                                  null=True,
                                  on_delete=models.CASCADE,
                                  related_name='child')
    recipe = models.ForeignKey(Recipe, related_name='revisions')
    created = models.DateTimeField(default=timezone.now)
    updated = models.DateTimeField(default=timezone.now)
    user = models.ForeignKey(User,
                             on_delete=models.SET_NULL,
                             related_name='recipe_revisions',
                             null=True)
    comment = models.TextField()

    name = models.CharField(max_length=255)
    action = models.ForeignKey('Action', related_name='recipe_revisions')
    arguments_json = models.TextField(default='{}', validators=[validate_json])
    extra_filter_expression = models.TextField(blank=False)
    channels = models.ManyToManyField(Channel)
    countries = models.ManyToManyField(Country)
    locales = models.ManyToManyField(Locale)
    identicon_seed = IdenticonSeedField(max_length=64)

    class Meta:
        ordering = ('-created', )

    @property
    def data(self):
        return {
            'name': self.name,
            'action': self.action,
            'arguments_json': self.arguments_json,
            'extra_filter_expression': self.extra_filter_expression,
            'channels': list(self.channels.all()),
            'countries': list(self.countries.all()),
            'locales': list(self.locales.all()),
            'identicon_seed': self.identicon_seed,
        }

    @property
    def filter_expression(self):
        parts = []

        if self.locales.count():
            locales = ', '.join(
                ["'{}'".format(l.code) for l in self.locales.all()])
            parts.append('normandy.locale in [{}]'.format(locales))

        if self.countries.count():
            countries = ', '.join(
                ["'{}'".format(c.code) for c in self.countries.all()])
            parts.append('normandy.country in [{}]'.format(countries))

        if self.channels.count():
            channels = ', '.join(
                ["'{}'".format(c.slug) for c in self.channels.all()])
            parts.append('normandy.channel in [{}]'.format(channels))

        if self.extra_filter_expression:
            parts.append(self.extra_filter_expression)

        expression = ') && ('.join(parts)

        return '({})'.format(expression) if len(parts) > 1 else expression

    @property
    def arguments(self):
        return json.loads(self.arguments_json)

    @arguments.setter
    def arguments(self, value):
        self.arguments_json = json.dumps(value)

    @property
    def serializable_recipe(self):
        """Returns an unsaved recipe object with this revision's data to be serialized."""
        recipe = self.recipe
        recipe.approved_revision = self if self.approval_status == self.APPROVED else None
        recipe.latest_revision = self
        return recipe

    @property
    def approval_status(self):
        try:
            if self.approval_request.approved is True:
                return self.APPROVED
            elif self.approval_request.approved is False:
                return self.REJECTED
            else:
                return self.PENDING
        except ApprovalRequest.DoesNotExist:
            return None

    def hash(self):
        data = '{}{}{}{}{}{}'.format(self.recipe.id, self.created, self.name,
                                     self.action.id, self.arguments_json,
                                     self.filter_expression)
        return hashlib.sha256(data.encode()).hexdigest()

    def save(self, *args, **kwargs):
        if self.parent:
            old_arguments = self.parent.arguments
        else:
            old_arguments = None
        self.action.validate_arguments(self.arguments, old_arguments)

        if not self.created:
            self.created = timezone.now()
        self.id = self.hash()
        self.updated = timezone.now()
        super().save(*args, **kwargs)

    def request_approval(self, creator):
        approval_request = ApprovalRequest(revision=self, creator=creator)
        approval_request.save()
        self.recipe.update_signature()
        self.recipe.save()
        return approval_request
Пример #2
0
class RecipeRevision(DirtyFieldsMixin, models.Model):
    APPROVED = "approved"
    REJECTED = "rejected"
    PENDING = "pending"

    # Bookkeeping fields
    parent = models.OneToOneField("self",
                                  null=True,
                                  on_delete=models.CASCADE,
                                  related_name="child")
    recipe = models.ForeignKey(Recipe,
                               related_name="revisions",
                               on_delete=models.CASCADE)
    created = models.DateTimeField(default=timezone.now)
    updated = models.DateTimeField(default=timezone.now)
    user = models.ForeignKey(User,
                             on_delete=models.SET_NULL,
                             related_name="recipe_revisions",
                             null=True)

    # Recipe fields
    name = models.CharField(max_length=255)
    action = models.ForeignKey("Action",
                               related_name="recipe_revisions",
                               on_delete=models.CASCADE)
    arguments_json = models.TextField(default="{}", validators=[validate_json])
    extra_filter_expression = models.TextField(blank=False)
    filter_object_json = models.TextField(validators=[validate_json],
                                          null=True)
    channels = models.ManyToManyField(Channel)
    countries = models.ManyToManyField(Country)
    locales = models.ManyToManyField(Locale)
    identicon_seed = IdenticonSeedField(max_length=64)
    enabled_state = models.ForeignKey("EnabledState",
                                      null=True,
                                      on_delete=models.SET_NULL,
                                      related_name="current_for_revision")
    comment = models.TextField()
    experimenter_slug = models.CharField(null=True, max_length=255, blank=True)
    extra_capabilities = ArrayField(models.CharField(max_length=255),
                                    default=list)

    class Meta:
        ordering = ("-created", )

    @property
    def data(self):
        return {
            "name": self.name,
            "action": self.action,
            "arguments_json": self.arguments_json,
            "extra_filter_expression": self.extra_filter_expression,
            "filter_object_json": self.filter_object_json,
            "channels": list(self.channels.all()) if self.id else [],
            "countries": list(self.countries.all()) if self.id else [],
            "locales": list(self.locales.all()) if self.id else [],
            "identicon_seed": self.identicon_seed,
            "comment": self.comment,
            "experimenter_slug": self.experimenter_slug,
            "extra_capabilities": self.extra_capabilities,
        }

    @property
    def filter_expression(self):
        parts = []

        if self.locales.count():
            locales = ", ".join(
                ["'{}'".format(l.code) for l in self.locales.all()])
            parts.append("normandy.locale in [{}]".format(locales))

        if self.countries.count():
            countries = ", ".join(
                ["'{}'".format(c.code) for c in self.countries.all()])
            parts.append("normandy.country in [{}]".format(countries))

        if self.channels.count():
            channels = ", ".join(
                ["'{}'".format(c.slug) for c in self.channels.all()])
            parts.append("normandy.channel in [{}]".format(channels))

        parts.extend(filter.to_jexl() for filter in self.filter_object)

        if self.extra_filter_expression:
            parts.append(self.extra_filter_expression)

        expression = ") && (".join(parts)

        return "({})".format(expression) if len(parts) > 1 else expression

    @property
    def filter_object(self):
        if self.filter_object_json is not None:
            return [
                filters.from_data(obj)
                for obj in json.loads(self.filter_object_json)
            ]
        else:
            return []

    @filter_object.setter
    def filter_object(self, value):
        if value is None:
            self.filter_object_json = None
        else:
            self.filter_object_json = json.dumps(
                [filter.initial_data for filter in value])

    @property
    def arguments(self):
        return json.loads(self.arguments_json)

    @arguments.setter
    def arguments(self, value):
        self.arguments_json = json.dumps(value)

    @property
    def serializable_recipe(self):
        """Returns an unsaved recipe object with this revision's data to be serialized."""
        recipe = self.recipe
        recipe.approved_revision = self if self.approval_status == self.APPROVED else None
        recipe.latest_revision = self
        return recipe

    @property
    def approval_status(self):
        try:
            if self.approval_request.approved is True:
                return self.APPROVED
            elif self.approval_request.approved is False:
                return self.REJECTED
            else:
                return self.PENDING
        except ApprovalRequest.DoesNotExist:
            return None

    @property
    def enabled(self):
        return self.enabled_state.enabled if self.enabled_state else False

    @property
    def capabilities(self):
        """Calculates the set of capabilities required for this recipe."""
        capabilities = set(self.extra_capabilities) | self.action.capabilities
        for filter in self.filter_object:
            capabilities.update(filter.capabilities)

        # "capabilities-v1" is not a baseline capability. If all of the other
        # capabilities are baseline capabilities, don't add it to the recipe.
        # Otherwise, do.
        if capabilities - settings.BASELINE_CAPABILITIES:
            capabilities.add("capabilities-v1")

        return capabilities

    def uses_only_baseline_capabilities(self):
        return self.capabilities <= settings.BASELINE_CAPABILITIES

    def save(self, *args, **kwargs):
        self.action.validate_arguments(self.arguments, self)

        if not self.created:
            self.created = timezone.now()
        self.updated = timezone.now()
        super().save(*args, **kwargs)

    def request_approval(self, creator):
        approval_request = ApprovalRequest(revision=self, creator=creator)
        approval_request.save()
        self.recipe.update_signature()
        self.recipe.save()
        return approval_request

    def _create_new_enabled_state(self, **kwargs):
        if self.recipe.approved_revision != self:
            raise EnabledState.NotActionable(
                "You cannot change the enabled state of a revision"
                "that is not the latest approved revision.")

        self.enabled_state = EnabledState.objects.create(revision=self,
                                                         **kwargs)
        self.save()

        self.recipe.approved_revision.refresh_from_db()
        self.recipe.update_signature()
        self.recipe.save()

    @transaction.atomic
    def enable(self, user, carryover_from=None):
        if self.enabled:
            raise EnabledState.NotActionable(
                "This revision is already enabled.")

        self._validate_preference_rollout_rollback_enabled_invariance()

        self._create_new_enabled_state(creator=user,
                                       enabled=True,
                                       carryover_from=carryover_from)

        RemoteSettings().publish(self.recipe)

    @transaction.atomic
    def disable(self, user):
        if not self.enabled:
            raise EnabledState.NotActionable(
                "This revision is already disabled.")

        self._create_new_enabled_state(creator=user, enabled=False)

        RemoteSettings().unpublish(self.recipe)

    def _validate_preference_rollout_rollback_enabled_invariance(self):
        """Raise ValidationError if you're trying to enable a preference-rollback
        whose preference-rollout is still enabled. Same if you're trying to enable a
        preference-rollout whose preference-rollback is still enabled.

        If not applicable or not a problem, do nothing.
        """
        if self.action.name == "preference-rollback":
            slug = self.arguments["rolloutSlug"]
            rollout_recipes = Recipe.objects.filter(
                approved_revision__action__name="preference-rollout",
                approved_revision__enabled_state__enabled=True,
            )
            for recipe in rollout_recipes:
                if recipe.approved_revision.arguments["slug"] == slug:
                    raise ValidationError(
                        f"Rollout recipe {recipe.approved_revision.name!r} is currently enabled"
                    )
        elif self.action.name == "preference-rollout":
            slug = self.arguments["slug"]
            rollback_recipes = Recipe.objects.filter(
                approved_revision__action__name="preference-rollback",
                approved_revision__enabled_state__enabled=True,
            )
            for recipe in rollback_recipes:
                if recipe.approved_revision.arguments["rolloutSlug"] == slug:
                    raise ValidationError(
                        f"Rollback recipe {recipe.approved_revision.name!r} is currently enabled"
                    )
Пример #3
0
class RecipeRevision(DirtyFieldsMixin, models.Model):
    APPROVED = "approved"
    REJECTED = "rejected"
    PENDING = "pending"

    # Bookkeeping fields
    parent = models.OneToOneField("self",
                                  null=True,
                                  on_delete=models.CASCADE,
                                  related_name="child")
    recipe = models.ForeignKey(Recipe,
                               related_name="revisions",
                               on_delete=models.CASCADE)
    created = models.DateTimeField(default=timezone.now)
    updated = models.DateTimeField(default=timezone.now)
    user = models.ForeignKey(User,
                             on_delete=models.SET_NULL,
                             related_name="recipe_revisions",
                             null=True)

    # Recipe fields
    name = models.CharField(max_length=255)
    action = models.ForeignKey("Action",
                               related_name="recipe_revisions",
                               on_delete=models.CASCADE)
    arguments_json = models.TextField(default="{}", validators=[validate_json])
    extra_filter_expression = models.TextField(blank=False)
    filter_object_json = models.TextField(validators=[validate_json],
                                          null=True)
    channels = models.ManyToManyField(Channel)
    countries = models.ManyToManyField(Country)
    locales = models.ManyToManyField(Locale)
    identicon_seed = IdenticonSeedField(max_length=64)
    enabled_state = models.ForeignKey("EnabledState",
                                      null=True,
                                      on_delete=models.SET_NULL,
                                      related_name="current_for_revision")
    comment = models.TextField()
    bug_number = models.IntegerField(null=True)

    class Meta:
        ordering = ("-created", )

    @property
    def data(self):
        return {
            "name": self.name,
            "action": self.action,
            "arguments_json": self.arguments_json,
            "extra_filter_expression": self.extra_filter_expression,
            "filter_object_json": self.filter_object_json,
            "channels": list(self.channels.all()) if self.id else [],
            "countries": list(self.countries.all()) if self.id else [],
            "locales": list(self.locales.all()) if self.id else [],
            "identicon_seed": self.identicon_seed,
            "comment": self.comment,
            "bug_number": self.bug_number,
        }

    @property
    def filter_expression(self):
        parts = []

        if self.locales.count():
            locales = ", ".join(
                ["'{}'".format(l.code) for l in self.locales.all()])
            parts.append("normandy.locale in [{}]".format(locales))

        if self.countries.count():
            countries = ", ".join(
                ["'{}'".format(c.code) for c in self.countries.all()])
            parts.append("normandy.country in [{}]".format(countries))

        if self.channels.count():
            channels = ", ".join(
                ["'{}'".format(c.slug) for c in self.channels.all()])
            parts.append("normandy.channel in [{}]".format(channels))

        for obj in self.filter_object:
            filter = filters.from_data(obj)
            parts.append(filter.to_jexl())

        if self.extra_filter_expression:
            parts.append(self.extra_filter_expression)

        expression = ") && (".join(parts)

        return "({})".format(expression) if len(parts) > 1 else expression

    @property
    def filter_object(self):
        if self.filter_object_json is not None:
            return json.loads(self.filter_object_json)
        else:
            return []

    @property
    def arguments(self):
        return json.loads(self.arguments_json)

    @arguments.setter
    def arguments(self, value):
        self.arguments_json = json.dumps(value)

    @property
    def serializable_recipe(self):
        """Returns an unsaved recipe object with this revision's data to be serialized."""
        recipe = self.recipe
        recipe.approved_revision = self if self.approval_status == self.APPROVED else None
        recipe.latest_revision = self
        return recipe

    @property
    def approval_status(self):
        try:
            if self.approval_request.approved is True:
                return self.APPROVED
            elif self.approval_request.approved is False:
                return self.REJECTED
            else:
                return self.PENDING
        except ApprovalRequest.DoesNotExist:
            return None

    @property
    def enabled(self):
        return self.enabled_state.enabled if self.enabled_state else False

    def save(self, *args, **kwargs):
        self.action.validate_arguments(self.arguments, self)

        if not self.created:
            self.created = timezone.now()
        self.updated = timezone.now()
        super().save(*args, **kwargs)

    def request_approval(self, creator):
        approval_request = ApprovalRequest(revision=self, creator=creator)
        approval_request.save()
        self.recipe.update_signature()
        self.recipe.save()
        return approval_request

    def _create_new_enabled_state(self, **kwargs):
        if self.recipe.approved_revision != self:
            raise EnabledState.NotActionable(
                "You cannot change the enabled state of a revision"
                "that is not the latest approved revision.")

        self.enabled_state = EnabledState.objects.create(revision=self,
                                                         **kwargs)
        self.save()

        self.recipe.approved_revision.refresh_from_db()
        self.recipe.update_signature()
        self.recipe.save()

    @transaction.atomic
    def enable(self, user, carryover_from=None):
        if self.enabled:
            raise EnabledState.NotActionable(
                "This revision is already enabled.")

        self._create_new_enabled_state(creator=user,
                                       enabled=True,
                                       carryover_from=carryover_from)

        RemoteSettings().publish(self.recipe)

    @transaction.atomic
    def disable(self, user):
        if not self.enabled:
            raise EnabledState.NotActionable(
                "This revision is already disabled.")

        self._create_new_enabled_state(creator=user, enabled=False)

        RemoteSettings().unpublish(self.recipe)