Per user timezones in Django

A common requirement for sites that deal with dates (most of them), is to display date and time values in the individual user's timezones. Luckily, there are a few good ready-made solutions for Python/Django. They don't solve the problem completely seamlessly. You will still need to decide when to invoke the datetime translation, but most of the heavy lifting will be done for you.

Before I go any further, it's worth noting that there is a work-around for some subset of sites. Instead of collecting and showing absolute dates and times, you may be able to get away with relative dates and times. For example, if you just need to display the time a record was created, you could say "13 minutes ago" instead of "Dec 10, 2010 at 8:55pm". In fact, here is a handy decorator to that effect:

from datetime import datetime
from django.template.defaultfilters import timesince

@register.filter
def ago(date_time):
    diff = abs(date_time - datetime.today())
    if diff.days <= 0:
        span = timesince(date_time)
        span = span.split(",")[0] # just the most significant digit
        if span == "0 minutes":
            return "seconds ago"
        return "%s ago" % span
    return date(date_time)

Assuming you do need to support absolute times, you would start by downloading django-timezones, which itself relies on pytz. You can get both via pip:

sudo pip install django-timezones
sudo pip install pytz

However, there is a bug in the latest stable release of django-timezones which prevents you from using their model and form definitions. If you do, you will get a "Value XXX is not a valid choice" validation error trying to submit the form. You can either install the trunk release, where the bug is fixed, or use the following work-around models definition:

from timezones.forms import PRETTY_TIMEZONE_CHOICES

class UserProfile(models.Model):
   ...
   timezone = models.CharField(max_length=255, choices=PRETTY_TIMEZONE_CHOICES, blank=True, null=True, )
   ...

With this definition, you can use a standard ModelForm, without overriding the field or widget of timezone. Once you have your form rendering in HTML, you can use the following jQuery snippet to default the timezone selection to the correct choice for that user, using getTimezoneOffset. It will only fire if there is no timezone selected already.

var selects = $("select#id_timezone");
if (selects.length > 0 && selects.val() == "") {
 var offset_minutes = new Date().getTimezoneOffset();
 var offset = 100 * offset_minutes / 60;
 var default_value = _first_timezone_match(selects, offset);
 selects.val(default_value);
}

function _first_timezone_match(selects, offset) {
 var match = "";
 selects.find("option").each(function() {
  // ex: "(GMT-0500) America/New_York"
  if ($(this).text().indexOf(offset) > 0) {
   match = $(this).val();
  }
 });
 return match;
}

Finally, here are some helper methods to translate to and from the server timezone and the user's timezone.

import datetime
import settings
import pytz

# need to translate to a non-naive timezone, even if timezone == settings.TIME_ZONE, so we can compare two dates
def to_user_timezone(date, profile):
    timezone = profile.timezone if profile.timezone else settings.TIME_ZONE
    return date.replace(tzinfo=pytz.timezone(settings.TIME_ZONE)).astimezone(pytz.timezone(timezone))

def to_system_timezone(date, profile):
    timezone = profile.timezone if profile.timezone else settings.TIME_ZONE
    return date.replace(tzinfo=pytz.timezone(timezone)).astimezone(pytz.timezone(settings.TIME_ZONE))

def now_timezone():
    return datetime.datetime.now().replace(tzinfo=pytz.timezone(settings.TIME_ZONE)).astimezone(pytz.timezone(settings.TIME_ZONE))

Note: I'm careful to always return a datetime object w/ the timezone information. This is because otherwise, you could get a "can't compare offset-naive and offset-aware datetimes" error when you compare two datetime objects.



I'm currently working at NerdWallet, a startup in San Francisco trying to bring clarity to all of life's financial decisions. We're hiring like crazy. Hit me up on Twitter, I would love to talk.

Follow @chase_seibert on Twitter