views:

579

answers:

4

I have a custom field called HourField, which lets you enter "1:30" to represent one and a half hours. The clean() method turns it into 1.5 for the FloatField in the model. This all works fine. Now I'm having a problem where when I want to create a formset, that field gets rendered as "1.5" instead of "1:30" which is what I want.

Is there some kind of method similar to clean() but works the other way around?

edit: using another type of storage method is out of the question. I have a few other custom fields I have made which are stored as one type of object, but are entered by the user as a string. I used the example above because it was the most straight forward.

A: 

I think you might want to experiment with the date template tag or possibly creating your own template tag. This is band-aid though- the real issue is that you should store that time in Python as a time using the datetime module. This will guarantee that it's formatted well.

Vince
He's not storing a date/time value though, he's storing a duration.
Andre Miller
Which is why datetime's time delta module should work perfectly http://docs.python.org/library/datetime.html#timedelta-objects
Vince
+1  A: 

How data gets displayed in a form field is the responsibility of the widget. It looks like you will need to define a custom widget that implements a render() method that takes the decimal field value and outputs it as you want.

Unfortunately there doesn't seem to be any documentation on creating custom widgets, but you can look at the code in django.forms.extras.widgets for an example of how to do it.

Daniel Roseman
+1  A: 
from django.forms.widgets import TextInput

class TimeInput(TextInput):
     def _format_value(self, value):
         """Convert float to time"""
         if not value:
             return ''
         fraction, integer = math.modf(value)
         minutes = 0 if fraction == 0 else .6 / fraction
         return "%d:%02d" % (int(integer), int(minutes))

     def render(self, name, value, attrs=None):
        value = self._format_value(value)
        return super(TimeInput, self).render(name, value, attrs)
Jason Christa
It should be TextInput, not just Text. Also, how do I use this technique on a ModelChoice Field? value always seems to be None...
nbv4
Actually, I take that back, its not none, it's just always the pk of the object...
nbv4
ModelChoice fields generate options based the pk and string representation of that model instance. Is this still related to the HourField? If so would you clarify the table setup more?
Jason Christa
+1  A: 

To customize the way a field renders, you need to define a widget as well.

The following code shows some responsibilities of widgets in django :

  • render the form tag ( superclass TextInput takes care of this )
  • format the value for displaying inside the form field. there's an gotcha here : the value's class may vary, since you may render cleaned values coming from the field, or uncleaned data coming from a form POST. Hence the test if we have a numeric value in _format_value ; if it's a string, just leave it as-is.
  • tell if the value has changed, compared to initial data. Very important when you use formsets with default values.

Please note that the widget's code is inspired by widgets.TimeInput from the sourcecode of django, which helps being consistent.

from django import forms
from django.forms import widgets
from django.forms.util import ValidationError

class HourWidget(widgets.TextInput):

    def _format_value(self, value):
        if isinstance(value, float) or isinstance(value, int):
            import math

            hours = math.floor(value)
            minutes = (value - hours) * 60
            value = "%d:%02d" % (hours, minutes)

        return value

    def render(self, name, value, attrs=None):
        value = self._format_value(value)
        return super(HourWidget, self).render(name, value, attrs)

    def _has_changed(self, initial, data):
        return super(HourWidget, self)._has_changed(self._format_value(initial), data)

class HourField(forms.Field):
    widget = HourWidget

    def clean(self, value):
        super(HourField, self).clean(value)

        import re
        match = re.match("^([0-9]{1,2}):([0-9]{2})$", value)
        if not match:
            raise ValidationError("Please enter a valid hour ( ex: 12:34 )")

        groups = match.groups()
        hour = float(groups[0])
        minutes = float(groups[1])

        if minutes >= 60:
            raise ValidationError("Invalid value for minutes")

        return hour + minutes/60

Please note that 1:20 becomes 1:19, which is due to the loss of precision induced by the use of a float. You might want to change the data type if you don't want to lose some precision.

vincent
Won't the %1d in _format_value() truncate hours like 10, 11, and 12?
Jason Christa
"1" is the 'width' parameter, which is only used for padding, not for truncating ; that is, characters are added to the left, never removed.The % operator behaves like sprintf ( http://en.wikipedia.org/wiki/Printf#printf_format_placeholders ). You can't truncate the integer part of a number this way. You can only truncate the decimal part using the precision parameter ( "%.2f" % 0.123 == "0.12" ).Truncation can occur using the precision parameter in a string format : "%.1s" % 123 == "1"
vincent
By the way, while it does not introduce a bug, I agree this '%1d' is useless, since any number always yields at least a character in the integer part :-) I wrote this because I started with "%2d:%2d" and figured only 1 char is required in the hour part.
vincent