class RecipeRevisionSerializer(serializers.ModelSerializer): action = serializers.SerializerMethodField(read_only=True) approval_request = ApprovalRequestSerializer(read_only=True) capabilities = serializers.ListField(read_only=True) comment = serializers.CharField(required=False) creator = UserSerializer(source="user", read_only=True) date_created = serializers.DateTimeField(source="created", read_only=True) enabled_states = EnabledStateSerializer(many=True, exclude_fields=["revision_id"]) filter_object = serializers.ListField(child=FilterObjectField()) recipe = serializers.SerializerMethodField(read_only=True) class Meta: model = RecipeRevision fields = [ "action", "approval_request", "arguments", "experimenter_slug", "capabilities", "comment", "creator", "date_created", "enabled_states", "enabled", "extra_capabilities", "extra_filter_expression", "filter_expression", "filter_object", "id", "identicon_seed", "name", "recipe", "updated", ] def get_recipe(self, instance): serializer = RecipeLinkSerializer(instance.recipe) return serializer.data def get_action(self, instance): serializer = ActionSerializer( instance.action, read_only=True, context={"request": self.context.get("request")}) return serializer.data
class RecipeSerializer(CustomizableSerializerMixin, serializers.ModelSerializer): # read-only fields approved_revision = RecipeRevisionSerializer(read_only=True) latest_revision = RecipeRevisionSerializer(read_only=True) signature = SignatureSerializer(read_only=True) uses_only_baseline_capabilities = serializers.BooleanField( source="latest_revision.uses_only_baseline_capabilities", read_only=True ) # write-only fields action_id = serializers.PrimaryKeyRelatedField( source="action", queryset=Action.objects.all(), write_only=True ) arguments = serializers.JSONField(write_only=True) extra_filter_expression = serializers.CharField( required=False, allow_blank=True, write_only=True ) filter_object = serializers.ListField( child=FilterObjectField(), required=False, write_only=True ) name = serializers.CharField(write_only=True) identicon_seed = serializers.CharField(required=False, write_only=True) comment = serializers.CharField(required=False, write_only=True) experimenter_slug = serializers.CharField( required=False, write_only=True, allow_null=True, allow_blank=True ) extra_capabilities = serializers.ListField(required=False, write_only=True) class Meta: model = Recipe fields = [ # read-only "approved_revision", "id", "latest_revision", "signature", "uses_only_baseline_capabilities", # write-only "action_id", "arguments", "extra_filter_expression", "filter_object", "name", "identicon_seed", "comment", "experimenter_slug", "extra_capabilities", ] def get_action(self, instance): serializer = ActionSerializer( instance.latest_revision.action, read_only=True, context={"request": self.context.get("request")}, ) return serializer.data def update(self, instance, validated_data): request = self.context.get("request") if request and request.user: validated_data["user"] = request.user instance.revise(**validated_data) return instance def create(self, validated_data): request = self.context.get("request") if request and request.user: validated_data["user"] = request.user if "identicon_seed" not in validated_data: validated_data["identicon_seed"] = f"v1:{FuzzyText().fuzz()}" recipe = Recipe.objects.create() return self.update(recipe, validated_data) def validate_extra_filter_expression(self, value): if value: jexl = JEXL() # Add mock transforms for validation. See # https://mozilla.github.io/normandy/user/filters.html#transforms # for a list of what transforms we expect to be available. jexl.add_transform("date", lambda x: x) jexl.add_transform("stableSample", lambda x: x) jexl.add_transform("bucketSample", lambda x: x) jexl.add_transform("preferenceValue", lambda x: x) jexl.add_transform("preferenceIsUserSet", lambda x: x) jexl.add_transform("preferenceExists", lambda x: x) errors = list(jexl.validate(value)) if errors: raise serializers.ValidationError(errors) return value def validate(self, data): data = super().validate(data) action = data.get("action") if action is None: action = self.instance.latest_revision.action arguments = data.get("arguments") if arguments is not None: # Ensure the value is a dict if not isinstance(arguments, dict): raise serializers.ValidationError({"arguments": "Must be an object."}) # Get the schema associated with the selected action schema = action.arguments_schema schemaValidator = JSONSchemaValidator(schema) errorResponse = {} errors = sorted(schemaValidator.iter_errors(arguments), key=lambda e: e.path) # Loop through ValidationErrors returned by JSONSchema # Each error contains a message and a path attribute # message: string human-readable error explanation # path: list containing path to offending element for error in errors: currentLevel = errorResponse # Loop through the path of the current error # e.g. ['surveys'][0]['weight'] for index, path in enumerate(error.path): # If this key already exists in our error response, step into it if path in currentLevel: currentLevel = currentLevel[path] continue else: # If we haven't reached the end of the path, add this path # as a key in our error response object and step into it if index < len(error.path) - 1: currentLevel[path] = {} currentLevel = currentLevel[path] continue # If we've reached the final path, set the error message else: currentLevel[path] = error.message if errorResponse: raise serializers.ValidationError({"arguments": errorResponse}) if self.instance is None: if data.get("extra_filter_expression", "").strip() == "": if not data.get("filter_object"): raise serializers.ValidationError( "one of extra_filter_expression or filter_object is required" ) else: if "extra_filter_expression" in data or "filter_object" in data: # If either is attempted to be updated, at least one of them must be truthy. if not data.get("extra_filter_expression", "").strip() and not data.get( "filter_object" ): raise serializers.ValidationError( "if extra_filter_expression is blank, " "at least one filter_object is required" ) return data def validate_filter_object(self, value): if not isinstance(value, list): raise serializers.ValidationError( {"non field errors": ["filter_object must be a list."]} ) errors = {} for i, obj in enumerate(value): if not isinstance(obj, dict): errors[i] = {"non field errors": ["filter_object members must be objects."]} continue if "type" not in obj: errors[i] = {"type": ["This field is required."]} break Filter = filters.by_type.get(obj["type"]) if Filter is not None: filter = Filter(data=obj) if not filter.is_valid(): errors[i] = filter.errors else: errors[i] = {"type": [f'Unknown filter object type "{obj["type"]}".']} if errors: raise serializers.ValidationError(errors) return value