16
Django Custom Model Field - Positive Decimal 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]
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)
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()
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)
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)
All done!
16