January 9, 2014
by Dan Poirier
0 comments
Categories:
Technical
Tags:
django, python, pytz

Managing Events with Explicit Time Zones

Recently we wanted a way to let users create real-life events which could occur in any time zone that the user desired. By default, Django interprets any date/time that the user enters as being in the user’s time zone, but it never displays that time zone, and it converts the time zone to UTC before storing it, so there is no record of what time zone the user initially chose. This is fine for most purposes, but if you want to specifically give the user the ability to choose different time zones for different events, this won’t work.

One idea I had was to create a custom model field type. It would store both the date/time (in UTC) and the preferred time zone in the database, and provide a form field and some kind of compound widget to let the user set and see the date/time with its proper time zone.

We ended up with a simpler solution. It hinged on considering the time zone separately from a time. In our case, we would set a time zone for an event. Any date/time fields in that event form would then be interpreted to be in that time zone.

Now, displaying a time in any time zone you want isn't too hard, and we weren't worried about that. More troublesome was letting a user enter an arbitrary time zone in one form field, and some date and time in other fields, and interpreting that date and time using the chosen time zone when the form was validated. Normally, Django parses a date/time form field using the user's time zone and gives you back a UTC - all time zone information is lost.

We started by defining a custom form to validate entry of time zone names:

class TimeZoneForm(forms.Form):
    """
    Form just to validate the event timezone field
    """
    event_time_zone = fields.ChoiceField(choices=TIME_ZONE_CHOICES)

Then in our view, we processed the submitted form in two steps. First, we got the time zone the user entered.

from django.utils import timezone

def view(request):

    if request.method == 'POST':
        tz_form = TimeZoneForm(request.POST)
        if tz_form.is_valid():
            tz = tz_form.cleaned_data['event_time_zone']

Then, before handling the complete form, we activated that time zone in Django, so the complete form would be processed in the context of that event's time zone:

from django.utils import timezone

def view(request):

    if request.method == 'POST':
        tz_form = TimeZoneForm(request.POST)
        if tz_form.is_valid():
            tz = tz_form.cleaned_data['event_time_zone']
            timezone.activate(tz)
            # Process the full form now

When displaying the initial form, we activate the event's time zone before constructing the form, so those times are displayed using the event's time zone:

else:
    # assuming we have an event object already
    timezone.activate(event.event_time_zone)
    # Continue to create form for display on the web page

Admin

What we just showed is a simplification of our actual solution, because we were using the Django admin to add and edit events, not custom forms. Here's how we customized the admin.

First, we wanted to display event times in a column in the admin change list, in their proper time zones. That kind of thing is pretty easy in the admin:

from pytz import timezone as pytz_timezone


class EventAdmin(admin.ModelAdmin):
    list_display = [..., 'event_datetime_in_timezone', ...]

    def event_datetime_in_timezone(self, event):
        """Display each event time on the changelist in its own timezone"""
        fmt = '%Y-%m-%d %H:%M:%S %Z'
        dt = event.event_datetime.astimezone(pytz_timezone(event.event_time_zone))
        return dt.strftime(fmt)
    event_datetime_in_timezone.short_description = _('Event time')

This uses pytz to convert the event's time into the event's time zone, then strftime to format it the way we wanted it, including the timezone.

Next, when adding a new event, we wanted to interpret the times in the event's time zone. The admin's add view is just a method on the model admin class, so it's not hard to override it, and insert the same logic we showed above:

class EventAdmin(admin.ModelAdmin):
    # ...

    # Override add view so we can peek at the timezone they've entered and
    # set the current time zone accordingly before the form is processed
    def add_view(self, request, form_url='', extra_context=None):
        if request.method == 'POST':
            tz_form = TimeZoneForm(request.POST)
            if tz_form.is_valid():
                timezone.activate(tz_form.cleaned_data['event_time_zone'])
        return super(EventAdmin, self).add_view(request, form_url, extra_context)

That handles submitting a new event. When editing an existing event, we also need to display the existing time values according to the event's time zone. To do that, we override the change view:

class EventAdmin(admin.ModelAdmin):
    # ...

    # Override change view so we can peek at the timezone they've entered and
    # set the current time zone accordingly before the form is processed
    def change_view(self, request, object_id, form_url='', extra_context=None):
        if request.method == 'POST':
            tz_form = TimeZoneForm(request.POST)
            if tz_form.is_valid():
                timezone.activate(tz_form.cleaned_data['event_time_zone'])
        else:
            obj = self.get_object(request, unquote(object_id))
            timezone.activate(obj.event_time_zone)
        return super(EventAdmin, self).change_view(request, object_id, form_url, extra_context)

One more thing. Since the single time zone field is applied to all the times in the event, if someone changes the time zone, they might need to also adjust one or more of the times. As a reminder, we added help text to the time zone field:

event_time_zone = models.CharField(
    choices=TIME_ZONE_CHOICES,
    max_length=32,
    default=settings.TIME_ZONE,
    help_text=_('All times for this event are in this time zone. If you change it, '
                'be sure all the times are correct for the new time zone.')
)

Thanks to Vinod Kurup for his help with this post.

blog comments powered by Disqus