Pull to refresh

Django и часовые пояса

Reading time 8 min
Views 20K
Есть несколько обыденных вещей, которые время от времени портят кровь нашему брату: падежи, числительные и часовые пояса, с проклятым переходом на летнее/зимнее время. Невольно позавидуешь китайцам у которых на всю страну всего один часовой пояс, а падежей нет и в помине. Будет совсем неплохо раз и навсегда разобраться с часовыми поясами и преобразованиями между ними хотя бы для Django-приложений.

В самом питоне с этим всё неплохо, есть отличный модуль pytz, а встроенный объект datetime корректно работает с часовыми поясами. Дело за малым — реализовать удобную обвязку. Первое что приходит в голову написать фильтр шаблона localtime и вызывать его таким образом:

 {{ comment.time_added|localtime }} или 
 {{ comment.time_added|localtime|date:"d.m.Y" }}

Но сразу возникает пара проблем. Во-первых, функция фильтра не принимает контекст, а значит не сможет определить к какому часовому поясу приводить время. Во-вторых, текущий часовой пояс может пониматься по разному: браться из настроек текущего пользователя, определяться по выбранному городу или по IP. Т.е. то, что сейчас является локальным часовым поясом зависит от приложения и соответственно такой фильтр нужно будет постоянно переписывать. И в-третьих, все эти параметры нужно передавать в контекст.

Первая проблема решается использованием тега вместо фильтра (он может получать контекст), правда выглядеть это будет уже не так красиво:

 {% localtime comment.time_added %} или 
 {% localtime comment.time_added as time_added %}{{ time_added|date:"d.m.Y" }}

Особо рисковые и нетерпеливые ценители красоты могут воспользоваться патчем, он как раз позволяет передавать контекст в фильтр.

Остались хлопоты с контекстом, можно написать свой context processor, а можно использовать стандартный django.core.context_processors.request и заполнять его свойство timezone с помощью Middleware:

class TimezoneMiddleware(object):
    """
    Записывает в request свойство timezone
    """

    def process_request(self, request):
        assert hasattr(request, 'session'), "*.TimezoneMiddleware requires session middleware to be installed."
        request.timezone = get_timezone(request)
        request.session['timezone'= request.timezone
        return None

Зависимость от session middleware можно убрать, если вы не собираетесь кэшировать часовой пояс в сессии. Функция get_timezone() будет зависеть от приложения и может выглядеть, например, так:

def get_timezone(request):
    # Конструируем по пользователю
    if hasattr(request, 'user'and request.user.is_authenticated():
        profile = request.user.get_profile()
        if profile.timezone:
            return profile.timezone

    # Берём из сессии
    if request.session.has_key('timezone'):
        return request.session['timezone']

    # Определяем город по IP, а по городу определяем часовой пояс
    city_id = ip_to_city_id(request.META['REMOTE_ADDR'])
    if city_id:
        try:
            city = City.objects.get(pk=city_id)
            if city.timezone:
                return city.timezone
        except City.DoesNotExist:
            pass

    # Берём значение по умолчанию из настроек
    return pytz.timezone(settings.FALLBACK_TIMEZONE)

Собственно, можно было бы привести код для тега и фильтра шаблона и на этом закруглится, но профессионально ленивый программист, вроде меня, решит, что писать тег или фильтр localtime каждый раз это хлопотно, плюс при выдаче форм нужно вручную преобразовывать время в полях туда и обратно, плюс в отсутствие контекста запроса (рассылка писем по крону, например) это без дополнительных телодвижений не заработает, плюс при работе в видах нужно быть постоянно начеку — календарик с событиями может выглядеть по-разному для разных часовых поясов. Что ж трудолюбивые ребята могут взять код фильтра в примере к вышеупомянутому патчу и быть таковы, остальные пусть будут готовы к небольшому колдовству.

Очевидно, если мы хотим, чтобы даты и времена автоматически переводились в текущий временной пояс, то без некоторой магии тут действительно не обойтись. Все данные из моделей мы получаем через их поля — отлично, преобразовывая время после выборки и перед вставкой можно получить требуемый эффект. Однако, поля ничего не знают ни о контексте шаблона, ни о объекте запроса, их вообще может не быть. Очевидно, активный часовой пояс должен быть глобальным. Можно посмотреть как аналогичная ситуация разрешена в django.utils.translation и реализовать то же для часовых поясов:

import pytz
from django.utils.thread_support import currentThread

_active = {}

def default_timezone():
    """
    Возвращает часовой пояс сервера.
    Функция подменяет себя во время первого вызова
    """

    from django.conf import settings
    _default_timezone = pytz.timezone(settings.TIME_ZONE)
    global default_timezone
    default_timezone = lambda: _default_timezone
    return _default_timezone

def activate(tz):
    if isinstance(tz, pytz.tzinfo.BaseTzInfo):
        _active[currentThread()] = tz
    else:
        _active[currentThread()] = pytz.timezone(tz)

def deactivate():
    global _active
    if currentThread() in _active:
        del _active[currentThread()]

def get_timezone():
    tz = _active.get(currentThread(), None)
    if tz is not None:
        return tz
    return default_timezone()

def to_active(dt):
    tz = get_timezone()
    if dt.tzinfo is None:
        dt = default_timezone().localize(dt)
    return dt.astimezone(tz)

def to_default(dt):
    if dt.tzinfo is None:
        return default_timezone().localize(dt)
    else:
        return dt.astimezone(default_timezone())

Функция activate() устанавливают текущий часовой пояс, deactivate() возвращает пояс по-умолчанию. to_default() и to_active() преобразуют время к поясу сервера либо текущему. Осталось написать собственное поле модели:

class TimezoneDateTimeField(models.DateTimeField):
    __metaclass__ = models.SubfieldBase

    def _to_python(self, value):
        """
        Немагический метод преобразования дерьма в питоновый datetime
        """

        return super(TimezoneDateTimeField, self).to_python(value)

    def to_python(self, value):
        """
        Метод преобразования дерьма в питоновый datetime.
        Преобразовывает из времени сервера в текущий часовой пояс
        """

        if value is None:
            return value
        return timezone.to_active(self._to_python(value))

    def get_db_prep_value(self, value):
        """
        Преобразовывает во время сервера для вставки в базу
        """

        if value is not None:
            value = timezone.to_default(self._to_python(value))
        return connection.ops.value_to_db_datetime(value)

И устанавливать активный часовой пояс для каждого запроса, например, дописав TimezoneMiddleware:

class TimezoneMiddleware(object):
    def process_request(self, request):
        ...
        timezone.activate(request.timezone)
        return None

    def process_response(self, request, response):
        timezone.deactivate()
        return response

Готово, просто заменяем стандартный DateTimeField на наше поле и время преобразовывается магически везде: и в шаблонах, и в формах, и в админке. Конечно, нет предела совершенству, можно реализовать своё поле формы для навешивания активного часового пояса на время, получаемое от пользователя, можно написать-таки фильтр для тех случаев когда используется сторонние приложения и их модели с не нашими полями. Что я и сделал в одном проекте о горнолыжном отдыхе, для которого и был написан весь этот код.

Надеюсь, все кто дочитал до этого места получили или практическую пользу, или эстетическое удовольствие, которое, полагаю, многим пишущим на Django знакомо.

P. S. В недавно вышедшем Django 1.2 изменился интерфейс полей модели, поэтому приведённый код для TimezoneDateTimeField понадобится допилить в соответствии с инструкцией по обновлению
Tags:
Hubs:
+39
Comments 11
Comments Comments 11

Articles