Monday, April 22, 2013

SplitSelectDateTimeWidget

Django only provides us SelectDateWidget to render DateField in Django's Template (django.forms.extras.widgets.SelectDateWidget) . What if you have a DateTimeField and want to have a Select Date and Select Time widget in your template (not a poor input textbox)? Web design means making beautiful UIs, right?

Luckily,  a guy here has written a custom widget name SplitSelectDateTimeWidget to save the world! Here is how I make use of Brad's widget in my project:

- Create a file name widgets.py in your app directory (e.g. myapp.widgets.py). Note that SplitDateTimeWidget also uses SelectTimeWidget which is also written by Brad:

....

import re from django.forms.extras.widgets import SelectDateWidget from django.forms.widgets import Widget, Select, MultiWidget from django.utils.safestring import mark_safe__all__ = ('SelectTimeWidget', 'SplitSelectDateTimeWidget') # Attempt to match many time formats: # Example: "12:34:56 P.M."  matches: # ('12', '34', ':56', '56', 'P.M.', 'P', '.', 'M', '.') # ('12', '34', ':56', '56', 'P.M.') # Note that the colon ":" before seconds is optional, but only if seconds are omitted time_pattern = r'(\d\d?):(\d\d)(:(\d\d))? *([aApP]\.?[mM]\.?)?$' RE_TIME = re.compile(time_pattern) # The following are just more readable ways to access re.matched groups: HOURS = 0 MINUTES = 1 SECONDS = 3 MERIDIEM = 4 class SelectTimeWidget(Widget): """ A Widget that splits time input into <select> elements. Allows form to show as 24hr: <hour>:<minute>:<second>, (default) or as 12hr: <hour>:<minute>:<second> <am|pm>   Also allows user-defined increments for minutes/seconds """ hour_field = '%s_hour' minute_field = '%s_minute' second_field = '%s_second'  meridiem_field = '%s_meridiem' twelve_hr = False # Default to 24hr.  def __init__(self, attrs=None, hour_step=None, minute_step=None, second_step=None, twelve_hr=False): """ hour_step, minute_step, second_step are optional step values for for the range of values for the associated select element twelve_hr: If True, forces the output to be in 12-hr format (rather than 24-hr) """ self.attrs = attrs or {}  if twelve_hr: self.twelve_hr = True # Do 12hr (rather than 24hr) self.meridiem_val = 'a.m.' # Default to Morning (A.M.)  if hour_step and twelve_hr: self.hours = range(1,13,hour_step)  elif hour_step: # 24hr, with stepping. self.hours = range(0,24,hour_step) elif twelve_hr: # 12hr, no stepping self.hours = range(1,13) else: # 24hr, no stepping self.hours = range(0,24)  if minute_step: self.minutes = range(0,60,minute_step) else: self.minutes = range(0,60) if second_step: self.seconds = range(0,60,second_step) else: self.seconds = range(0,60) def render(self, name, value, attrs=None): try: # try to get time values from a datetime.time object (value) hour_val, minute_val, second_val = value.hour, value.minute, value.second if self.twelve_hr: if hour_val >= 12: self.meridiem_val = 'p.m.' else: self.meridiem_val = 'a.m.' except AttributeError: hour_val = minute_val = second_val = 0 if isinstance(value, basestring): match = RE_TIME.match(value) if match: time_groups = match.groups(); hour_val = int(time_groups[HOURS]) % 24 # force to range(0-24) minute_val = int(time_groups[MINUTES])  if time_groups[SECONDS] is None: second_val = 0 else: second_val = int(time_groups[SECONDS])  # check to see if meridiem was passed in if time_groups[MERIDIEM] is not None: self.meridiem_val = time_groups[MERIDIEM] else: # otherwise, set the meridiem based on the time if self.twelve_hr: if hour_val >= 12: self.meridiem_val = 'p.m.' else: self.meridiem_val = 'a.m.' else: self.meridiem_val = None  # If we're doing a 12-hr clock, there will be a meridiem value, so make sure the # hours get printed correctly if self.twelve_hr and self.meridiem_val: if self.meridiem_val.lower().startswith('p') and hour_val > 12 and hour_val < 24: hour_val = hour_val % 12 elif hour_val == 0: hour_val = 12  output = [] if 'id' in self.attrs:id_ = self.attrs['id'] else: id_ = 'id_%s' % name # For times to get displayed correctly, the values MUST be converted to unicode # When Select builds a list of options, it checks against Unicode values hour_val = u"%.2d" % hour_val minute_val = u"%.2d" % minute_val second_val = u"%.2d" % second_val hour_choices = [("%.2d"%i, "%.2d"%i) for i in self.hours] local_attrs = self.build_attrs(id=self.hour_field % id_) select_html = Select(choices=hour_choices).render(self.hour_field % name, hour_val, local_attrs) output.append(select_html) minute_choices = [("%.2d"%i, "%.2d"%i) for i in self.minutes] local_attrs['id'] = self.minute_field % id_ select_html = Select(choices=minute_choices).render(self.minute_field % name, minute_val, local_attrs) output.append(select_html) second_choices = [("%.2d"%i, "%.2d"%i) for i in self.seconds] local_attrs['id'] = self.second_field % id_ select_html = Select(choices=second_choices).render(self.second_field % name, second_val, local_attrs) output.append(select_html)  if self.twelve_hr: #  If we were given an initial value, make sure the correct meridiem gets selected. if self.meridiem_val is not None and  self.meridiem_val.startswith('p'): meridiem_choices = [('p.m.','p.m.'), ('a.m.','a.m.')] else: meridiem_choices = [('a.m.','a.m.'), ('p.m.','p.m.')] local_attrs['id'] = local_attrs['id'] = self.meridiem_field % id_ select_html = Select(choices=meridiem_choices).render(self.meridiem_field % name, self.meridiem_val, local_attrs) output.append(select_html) return mark_safe(u'\n'.join(output)) def id_for_label(self, id_): return '%s_hour' % id_ id_for_label = classmethod(id_for_label) def value_from_datadict(self, data, files, name): # if there's not h:m:s data, assume zero: h = data.get(self.hour_field % name, 0) # hour m = data.get(self.minute_field % name, 0) # minute  s = data.get(self.second_field % name, 0) # second meridiem = data.get(self.meridiem_field % name, None) #NOTE: if meridiem is None, assume 24-hr if meridiem is not None: if meridiem.lower().startswith('p') and int(h) != 12: h = (int(h)+12)%24  elif meridiem.lower().startswith('a') and int(h) == 12: h = 0  if (int(h) == 0 or h) and m and s: return '%s:%s:%s' % (h, m, s) return data.get(name, None) ###################################################################### class SplitSelectDateTimeWidget(MultiWidget): """ MultiWidget = A widget that is composed of multiple widgets. This class combines SelectTimeWidget and SelectDateWidget so we have something      like SpliteDateTimeWidget (in django.forms.widgets), but with Select elements.     """ def __init__(self, attrs=None, hour_step=None, minute_step=None, second_step=None, twelve_hr=None, years=None): """ pass all these parameters to their respective widget constructors...""" widgets = (SelectDateWidget(attrs=attrs, years=years), SelectTimeWidget(attrs=attrs, hour_step=hour_step, minute_step=minute_step, second_step=second_step, twelve_hr=twelve_hr)) super(SplitSelectDateTimeWidget, self).__init__(widgets, attrs) def decompress(self, value): if value: return [value.date(), value.time().replace(microsecond=0)] return [None, None] def format_output(self, rendered_widgets): """ Given a list of rendered widgets (as strings), it inserts an HTML linebreak between them.  Returns a Unicode string representing the HTML for the whole lot. """ rendered_widgets.insert(-1, '<br/>') return u''.join(rendered_widgets)

And in your forms.py:

from django.forms import ModelForm, DateTimeField from myapp.models import MyModel from myapp.widgets import *  class PtcEventForm(ModelForm):  mydatetime_field = DateTimeField(widget=SplitSelectDateTimeWidget(hour_step=1, \ minute_step=15, second_step=60, twelve_hr=True)) class Meta: model = PtcEvent

In mytemplate.html:

...

{{ form.mydatetime_field }}

...

And the result will look like this one:

21

Pretty cool huh?

Trinh 

P/S: at this time, I'm still using Django 1.1.1, so this is my solution. If in the newer version of Django, they implement something like that, developer will be very happy!