Generic Multi-Field Django Model Custom Validation

Generic Multi-Field Django Model Custom Validation

In this article, you will learn how you can create a generic multi-field model custom validation mechanism for your django projects. By default, django provides a way for you to validate your model fields by passing a list of validator functions to the validators argument. What if we have to apply validations to multiple model fields? For example, in an e-commerce application, say we have to ensure that when a user selects a discount of type percentage, then the discount value should be less than or equal to 100. We will in the following sections, with detailed code and explanations, see how we can do this in Django.

Creating the model

Let’s assume we are implementing a Coupon model that keeps track of coupons we offer to customers. Our coupon model looks like this

class Coupon(models.Model):
    class DiscountTypes(models.TextChoices):
        PERCENTAGE_DISCOUNT = "percentage_discount"
        FIXED_CART_DISCOUNT = "fixed_cart_discount"
        FIXED_PRODUCT_DISCOUNT = "fixed_product_discount"
        BOGO = "bogo", _("BOGO (Buy X GET X/Y) Offer")

    class DisplayIn(models.TextChoices):
        MY_ACCOUNT = "my_account"
        CHECKOUT = "checkout"
        CART = "cart"

    code = models.CharField(max_length=20, unique=True, null=True)
    amount = models.FloatField(default=0)
    start_date = models.DateTimeField(auto_now=True)
    expiry_date = models.DateTimeField()
    free_shipping = models.BooleanField(default=False)
    description = models.TextField(max_length=200)
    discount_type = models.CharField(max_length=40, choices=DiscountTypes.choices)
    apply_automatically = models.BooleanField(default=False)
    display_in = MultiSelectField(max_length=20, max_choices=3, choices=DisplayIn.choices, null=True)
    minimum_spend = models.IntegerField(default=0, null=True)
    maximum_spend = models.IntegerField(default=0, null=True)
    individual_use_only = models.BooleanField(default=False)
    exclude_sale_items = models.BooleanField(default=False)
    email_restrictions = models.TextField(help_text=
                                          "Comma separated list of emails to restrict for access to this coupon",
                                          null=True, blank=True)
    def __str__(self):
        return self.code

The coupon has different discount types including:

  • Percentage discount
  • Fixed cart discount
  • fixed product discount
  • Bogo discount

Validation

Let’s say we want to ensure that, if an admin creates a percentage discount coupon, the amount field should not be above 100. To make this generic and reusable, let’s create a Validator abstract base class. This is will provide two methods:

register — A class method that will be used to register the fields that we want to validate and the error message that should be shown.

run — An abstract method that will be implemented by child classes to provide the actual validation functionality.

Our Validator abstract base class should look like this:

from abc import ABC, abstractmethod
from django.db import models
import typing as t


class Validator(ABC):
    @classmethod
    def register(cls, fields: t.List[str] = [], message="Model is not valid"):
        cls.fields = fields
        cls.message = message
        return cls()

    @abstractmethod
    def run(self, model: models.Model):
        pass

Now let’s create a mixin called ModelValidationMixin. This will override the clean and the save methods of the django model. It will loop through a validators (a list of Validator implementations) property and check for validation errors. Our ModelValidationMixin should look like this:

class ModelValidationMixin:
    validators: t.List[Validator] = []

    def clean(self):
        for validator in self.validators:
            if not validator.run(self):
                raise ValidationError(validator.message)

    def save(self, *args, **kwargs):
        self.full_clean()
        super(ModelValidationMixin, self).save(*args, **kwargs)

Next, let’s implement a validation class (LessThan100IfPercentageDiscount) which will answer our initial question above — Ensure that if the discount type is percentage_discount, the discount amount inputted should not go above 100.

class LessThan100IfPercentageDiscount(Validator):
    def run(self, model):
        return not (model.discount_type == Coupon.DiscountTypes.PERCENTAGE_DISCOUNT and model.amount > 100.0)

As a bonus let’s also implement another validation class that will ensure that the start_date is never ahead of the expiry_date (LessThanValidator) — Usable for comparing other fields that need a less-than comparism.

class LessThanValidator(Validator):
    def run(self, model: models.Model):
        return getattr(model, self.fields[0]) < getattr(model, self.fields[1])

Let’s wrap it up

To use the above validators, our Coupon model class will then look like:

class Coupon(ModelValidationMixin, models.Model):
    class DiscountTypes(models.TextChoices):
        PERCENTAGE_DISCOUNT = "percentage_discount"
        FIXED_CART_DISCOUNT = "fixed_cart_discount"
        FIXED_PRODUCT_DISCOUNT = "fixed_product_discount"
        BOGO = "bogo", _("BOGO (Buy X GET X/Y) Offer")

    class DisplayIn(models.TextChoices):
        MY_ACCOUNT = "my_account"
        CHECKOUT = "checkout"
        CART = "cart"

    code = models.CharField(max_length=20, unique=True, null=True)
    amount = models.FloatField(default=0)
    start_date = models.DateTimeField(auto_now=True)
    expiry_date = models.DateTimeField()
    free_shipping = models.BooleanField(default=False)
    description = models.TextField(max_length=200)
    discount_type = models.CharField(max_length=40, choices=DiscountTypes.choices)
    apply_automatically = models.BooleanField(default=False)
    display_in = MultiSelectField(max_length=20, max_choices=3, choices=DisplayIn.choices, null=True)
    minimum_spend = models.IntegerField(default=0, null=True)
    maximum_spend = models.IntegerField(default=0, null=True)
    individual_use_only = models.BooleanField(default=False)
    exclude_sale_items = models.BooleanField(default=False)
    email_restrictions = models.TextField(help_text=
                                          "Comma separated list of emails to restrict for access to this coupon",
                                          null=True, blank=True)
    validators = [
        LessThanValidator.register(["start_date", "expiry_date"],
                                   message={"expiry_date": "Should be greater than start date"}),
        LessThan100IfPercentageDiscount.register(message={"amount": "Amount should be less that 100 if coupon type is"
                                                                    "percentage_discount"})
    ]

    def __str__(self):
        return self.code

After running this, in the django admin interface, you’ll notice an error that appears while trying to input a discount amount of 120 while the discount type is percentage_discount