Django percentage field

Question:

I’m trying to create a percentage field in Django, where the user just fills in 40 for 40%. There will be a percentage sign on the right of the input box so that they know they should fill in a percentage. 0.4 must be stored in the DB. So far I’ve tried the following:

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):
            return str((float(val)*100))
        return val

def is_number(s):
    if s is None:
        return False
    try:
        float(s)
        return True
    except ValueError:
        return False

It works, but the problem is, when I post invalid data and the form is rendered again, it displays the 40 as 4000. In other words it multiplies the number again with 100 without dividing it as well.

Any suggestions how I can fix it?

I’ve tried this solution, but it repeats the value 100 times. It also has the same problem after I’ve corrected that.

I’m using Python3.5

Asked By: Kritz

||

Answers:

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
Answered By: Kritz

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.

Answered By: elachere

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)
Answered By: Wilfried FUTCHEA

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
Answered By: scūriolus
Categories: questions Tags: ,
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.