12

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

Kritz
  • 7,099
  • 12
  • 43
  • 73
  • 2
    If you're treating `40` as `40%` and `0.4` as `0.4%` then why use a separate field? Why not a simple FloatField() with min and max value validator? – v1k45 Apr 07 '16 at 13:41
  • The users can fill in 120 as well for 120%. If they fill in 1.2, I won't know if it is 120% of 1.2% – Kritz Apr 07 '16 at 13:43
  • .. So, you want users to be able to fill 120 for 120%? – v1k45 Apr 07 '16 at 13:51
  • 1
    Why not save the values as it's submitted then convert to float when displaying to the user? – ahmed Apr 07 '16 at 14:29
  • 1
    @ahmed: I did consider that, but the numbers are used for calculations, so I don't want to remember every time to divide by 100 before I use the numbers. For me it seems much more user friendly to fill in 40 for 40%, so I find it strange that there isn't a simple solution available. – Kritz Apr 07 '16 at 15:01
  • `prepare_value` <- don't you want to use `get_prep_value` instead? – Maxime Lorant Apr 07 '16 at 15:36
  • Are you suggesting I make a custom model field? I would think this should be possible without touching the model layer? – Kritz Apr 07 '16 at 15:59

4 Answers4

14

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)
  • Why not use an `IntegerField` instead of `DecimalField`? This would eliminate the need for `decimal_places=0`. Also `max_digits=3` isn't needed with the validator. – jenniwren Jul 05 '22 at 02:05
  • @jenniwren : I preferred a more generic writing to allow if necessary to have numbers with decimal point in percentage (15,2% for example). Obviously, the max_digit & decimal_places parameters will be updated accordingly. In the practical, we can have : >>> rate_field = models.DecimalField(max_digits=3, decimal_places=1, default=Decimal(0), validators=PERCENTAGE_VALIDATOR) – Wilfried FUTCHEA Aug 31 '22 at 13:06
  • The problem with this simple solution, is that you store the wrong value in database. Even if 30% is displayed to the user, you should store Decimal(0.30). Otherwise, for any calculation, you will have to div 100, which is unnatural. – David Dahan Sep 13 '22 at 08:37
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
  • 7,099
  • 12
  • 43
  • 73
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
scūriolus
  • 657
  • 2
  • 5
  • 15
  • This does not work for me. Saving a field to a value like `40` from the admin will create a `Decimal('40.00000000000000222044604925')` – David Dahan Sep 13 '22 at 08:22
  • @DavidDahan you are running into the old problem with floating point numbers that not all numbers can be represented exactly. What if you convert the value to string before converting it to Decimal everywhere: `Decimal(str(value))`? I will update my answer above if that fixes the issue. Cf. this page if you want to understand what's probably happening: https://docs.python.org/3/library/decimal.html – scūriolus Sep 15 '22 at 11:58
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.

Community
  • 1
  • 1
elachere
  • 595
  • 2
  • 8
  • 20