[Fixed]-Django percentage field

11👍

I found the solution. I have to check whether the incoming value is a string. If it is, I don’t multiply by 100 since it came from the form. See below:

class PercentageField(fields.FloatField):
    widget = fields.TextInput(attrs={"class": "percentInput"})

    def to_python(self, value):
        val = super(PercentageField, self).to_python(value)
        if is_number(val):
            return val/100
        return val

    def prepare_value(self, value):
        val = super(PercentageField, self).prepare_value(value)
        if is_number(val) and not isinstance(val, str):
            return str((float(val)*100))
        return val
👤Kritz

15👍

There’s an easy alternative for this task. You can use MaxValueValidator and MinValueValidator for this.

Here’s how you can do this:

from django.db import models    
from django.core.validators import MinValueValidator, MaxValueValidator
        
PERCENTAGE_VALIDATOR = [MinValueValidator(0), MaxValueValidator(100)]
        
class RatingModel(models.Model):
    ...
    rate_field = models.DecimalField(max_digits=3, decimal_places=0, default=Decimal(0), validators=PERCENTAGE_VALIDATOR)

3👍

My solution

Based on @elachere answer and the Django documentation, this is the code I am using:

from decimal import Decimal

from django import forms
from django.db import models

def ft_strip(d: Decimal) -> Decimal:
    return d.quantize(Decimal(1)) if d == d.to_integral() else d.normalize()

class PercentageField(models.DecimalField):
    def from_db_value(self, value, expression, connection) -> Decimal | None:
        return value if value is None else ft_strip(Decimal(str(value)) * 100)

    def get_db_prep_save(self, value, connection):
        if value is not None:
            value = Decimal(str(value)) / 100
        return super(PercentageField, self).get_db_prep_save(value, connection)

Why the accepted answer did not work for me

I ran into a similar issue but I wanted my PercentageField to be based on a DecimalField instead of a FloatField, in accordance with recommendations when it comes to currencies. In this context, the currently accepted answer did not work for me with Django 4.0, for 2 reasons:

  • to_python is called twice, once by the clean method of the form (as stated in the documentation) and one more time by get_db_prep_save (mentioned here in the documentation). Indeed, it turns out (in Django source code) that the DecimalField and the FloatField differ on this point.
  • prepare_value isn’t run at all for forms based on an existing instance (that users might be willing to edit, for instance).

Overriding django.db.models.fields.Field.pre_save could have been an alternative, but there is still an issue with the following code: the attribute of a current instance that has just been saved is 100x too small (due to the division in pre_save) and you’ll have to call instance.refresh_from_db(), should you require any further use of it.

class PercentageField(models.DecimalField):
    def from_db_value(self, value, expression, connection) -> Decimal | None:
        return value if value is None else ft_strip(Decimal(str(value)) * 100)

    def pre_save(self, model_instance, add):
        value = super().pre_save(model_instance, add)
        if value is not None:
            updated_value = Decimal(str(value)) / 100
            setattr(model_instance, self.attname, updated_value)
            return updated_value
        return None

2👍

From the documentation, to_python() is supposed to be used in case you’re dealing with complex data types, to help you interact with your database. A more accurate approach I think is to override the pre_save() Field method. From the documentation:

pre_save(model_instance, add)

Method called prior to get_db_prep_save() to prepare the value before being saved (e.g. for DateField.auto_now).

In the end, it looks like this:

def validate_ratio(value):
    try:
        if not (0 <= value <= 100):
            raise ValidationError(
                f'{value} must be between 0 and 100', params={'value': value}
            )
    except TypeError:
        raise ValidationError(
            f'{value} must be a number', params={'value': value}
        )


class RatioField(FloatField):
    description = 'A ratio field to represent a percentage value as a float'

    def __init__(self, *args, **kwargs):
        kwargs['validators'] = [validate_ratio]
        super().__init__(*args, **kwargs)

    def pre_save(self, model_instance, add):
        value = getattr(model_instance, self.attname)
        if value > 1:
            value /= 100
        setattr(model_instance, self.attname, value)
        return value

My case is a bit different, I want a ratio and not a percentage so I’m allowing only values between 0 and 100, that’s why I need a validator, but the idea is here.

Leave a comment