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
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" )
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)