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

    Есть несколько обыденных вещей, которые время от времени портят кровь нашему брату: падежи, числительные и часовые пояса, с проклятым переходом на летнее/зимнее время. Невольно позавидуешь китайцам у которых на всю страну всего один часовой пояс, а падежей нет и в помине. Будет совсем неплохо раз и навсегда разобраться с часовыми поясами и преобразованиями между ними хотя бы для 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 понадобится допилить в соответствии с инструкцией по обновлению
    Поделиться публикацией

    Комментарии 11

      +6
      Я делаю через фильтр, просто передаю ему request и всё.

      @register.filter
      def local_time(date, request):
          if request.user.is_anonymous() or not isinstance(date, datetime.datetime):
              return date
          timezone = pytz.timezone(request.user.profile.timezone.title)
          date = pytz.datetime.datetime(date.year, date.month, date.day, date.hour, date.minute, tzinfo=pytz.utc)
          return date.astimezone(timezone)
       
        +2
        Тоже вариант. Пара недостатков:
        — каждый раз передавать request не особо красиво
        — request-а может просто не быть, если мы, например, выполняем что-то по крону
        — если понадобиться работать во вьюхе, то придётся преобразовывать время вручную, то же с передачей в форму
        — твой фильтр зависит от приложения, поэтому придётся его переписывать если ты, к примеру, станешь использовать другой профиль, захочешь определять время по ip или выбраному городу

        Я-то хотел универсальный вариант, да и просто мне нравится немного магии :)
          +1
          Когда я найду силы разобраться в вашем коде, то наверное переведу свой проект на него, а то действительно не очень удобнов вне шаблонов получается.

          Кстати, хотел спросить, а на какой timezone вы устанавливаете сам проект? Имеется ввиду настройка settings.TIME_ZONE
            +1
            settings.TIME_ZONE по часовому поясу сервера, впрочем, это не так важно. В базе (PostgreSql) они хранятся в типе timestamp with timezone, т.е. UTC + часовой пояс, и база сама его переводит в текущий (для базы) пояс при выдаче
        +1
        эстетическое удовольтвие получено!
          0
          А как вам вариант, ханить на сервере все в UTC формате. А все преобразования даты-времени делать в браузере на строне клиента с помощью Javascript?
            0
            Зачем такие извращения? Все что можно делать на сервере, лучше делать на сервере. Кроме того, как насчет версии сайта для мобильных устройств без яваскрипта?
              0
              Просто возможно ли всегда на сервере определить часовой пояс клиента корректно? А в браузере клиента определишь всегда правильно и достаточно просто (все нужные функции для конвертации и показа есть). И еще, я не противопоставляю свое предложение вашей статье, а просто предлагаю альтернативный вариант. Мне самому интересно, применяет ли его кто-то и в чем минусы.

              Про мобильные устройства без ява-скрипта: давайте их не будем рассматривать в данном вопросе.
                0
                Всё-таки на сервере частенько нужно знать часовой пояс текущего посетителя, а Javascript-ом можно заполнять значение по-умолчанию в профиле или писать в куку для незарегистрированных
            0
            Если javascript отключен, то времена слетают, на это, конечно, можно забить.
            Остаются вьюхи и email-ы.
              0
              Что-то джангисты активизировались в последние дни. Радует, что Хабр стал похож на блог про Django.

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

              Самое читаемое