Reputation: 7301
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
Upvotes: 13
Views: 25428
Reputation: 211
There's an easy alternative for this task. You can use MaxValueValidator
and MinValueValidator
for this.
Here's how you can do this:
from decimal import Decimal
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)
Upvotes: 16
Reputation: 968
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)
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
Upvotes: 3
Reputation: 815
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.
Upvotes: 2
Reputation: 7301
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
Upvotes: 12