DEV Community

Serhat Teker
Serhat Teker

Posted on • Originally published at tech.serhatteker.com on

Django Custom Model Field - Positive Decimal Field

Custom Model Field

We saw in the article Custom Model Field Validator - Django, you can use a custom validator for your model fields. However if it's a common field for your models it's better to create a custom field.

In this article we're going to create PositiveDecimalField which only allows positive values.

# src/apps/models.py
from django.db.models import DecimalField
from django.core.exceptions import ValidationError
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _

def validate_positive(value):
    if value < 0:
        raise ValidationError(
            _("%(value)s is not positive."),
            params={"value": value}
        )


class PositiveDecimalField(DecimalField):
    description = _("Positive decimal number")

    @cached_property
    def validators(self):
        return super().validators + [validate_positive]
Enter fullscreen mode Exit fullscreen mode

With PositiveDecimalField we inherit DecimalField class and overwrite it's description attribute and validators method with adding our custom validator.

You can also use Django's built in MinValueValidator like
:super().validators + [MinValueValidator("0.0")].

In adddition to that you can also inherit __init__ method of DecimalField and add your custom validator to validators attribute:

# src/apps/models.py
class PositiveDecimalField(DecimalField):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.validators.append(validate_positive)
Enter fullscreen mode Exit fullscreen mode

Usage

You can use your custom field as below:

# src/apps/models.py
from django.db import models


class Book(models.Model):
    author = models.ForeignKey(Author, on_delete=models.CASCADE)
    title = models.CharField(max_length=120)
    price = models.PositiveDecimalField()
Enter fullscreen mode Exit fullscreen mode

Unittest

It's very important to write unittest for your code. Here is the test for PositiveDecimalField:

# tests/test_models.py
from django.core.exceptions import ValidationError
from django.test import TestCase

from src.apps.models import PositiveDecimalField


class PositiveDecimalFieldTests(TestCase):
    def test_negative_value(self):
        field = PositiveDecimalField(max_digits=4, decimal_places=2)
        msg = "%s is not positive."
        tests = [
            "-1.3",
            "-0.23",
        ]
        for value in tests:
            with self.subTest(value):
                with self.assertRaisesMessage(ValidationError, msg % (value,)):
                    field.clean(value, None)
Enter fullscreen mode Exit fullscreen mode

You don't need to test DecimalField since Django already has tests for default DecimalField but if you want to make whole test suite:

# tests/test_models.py
from decimal import Decimal

from django.core.exceptions import ValidationError
from django.test import TestCase

from src.apps.models import PositiveDecimalField

class PositiveDecimalFieldTests(TestCase):
    def test_to_python(self):
        f = PositiveDecimalField(max_digits=4, decimal_places=2)
        self.assertEqual(f.to_python(3), Decimal("3"))
        self.assertEqual(f.to_python("3.14"), Decimal("3.14"))
        # to_python() converts floats and honors max_digits.
        self.assertEqual(f.to_python(3.1415926535897), Decimal("3.142"))
        self.assertEqual(f.to_python(2.4), Decimal("2.400"))
        # Uses default rounding of ROUND_HALF_EVEN.
        self.assertEqual(f.to_python(2.0625), Decimal("2.062"))
        self.assertEqual(f.to_python(2.1875), Decimal("2.188"))

    def test_invalid_value(self):
        field = PositiveDecimalField(max_digits=4, decimal_places=2)
        msg = "“%s” value must be a decimal number."
        tests = [
            (),
            [],
            {},
            set(),
            object(),
            complex(),
            "non-numeric string",
            b"non-numeric byte-string",
        ]
        for value in tests:
            with self.subTest(value):
                with self.assertRaisesMessage(ValidationError, msg % (value,)):
                    field.clean(value, None)

    def test_default(self):
        f = PositiveDecimalField(default=Decimal("0.00"))
        self.assertEqual(f.get_default(), Decimal("0.00"))

    def test_get_prep_value(self):
        f = PositiveDecimalField(max_digits=5, decimal_places=1)
        self.assertIsNone(f.get_prep_value(None))
        self.assertEqual(f.get_prep_value("2.4"), Decimal("2.4"))

    def test_negative_value(self):
        field = PositiveDecimalField(max_digits=4, decimal_places=2)
        msg = "%s is not positive."
        tests = [
            "-1.3",
            "-0.23",
        ]
        for value in tests:
            with self.subTest(value):
                with self.assertRaisesMessage(ValidationError, msg % (value,)):
                    field.clean(value, None)
Enter fullscreen mode Exit fullscreen mode

All done!

Discussion (0)