Есть несколько обыденных вещей, которые время от времени портят кровь нашему брату: падежи, числительные и часовые пояса, с проклятым переходом на летнее/зимнее время. Невольно позавидуешь китайцам у которых на всю страну всего один часовой пояс, а падежей нет и в помине. Будет совсем неплохо раз и навсегда разобраться с часовыми поясами и преобразованиями между ними хотя бы для Django-приложений.
В самом питоне с этим всё неплохо, есть отличный модуль pytz, а встроенный объект datetime корректно работает с часовыми поясами. Дело за малым — реализовать удобную обвязку. Первое что приходит в голову написать фильтр шаблона localtime и вызывать его таким образом:
Но сразу возникает пара проблем. Во-первых, функция фильтра не принимает контекст, а значит не сможет определить к какому часовому поясу приводить время. Во-вторых, текущий часовой пояс может пониматься по разному: браться из настроек текущего пользователя, определяться по выбранному городу или по IP. Т.е. то, что сейчас является локальным часовым поясом зависит от приложения и соответственно такой фильтр нужно будет постоянно переписывать. И в-третьих, все эти параметры нужно передавать в контекст.
Первая проблема решается использованием тега вместо фильтра (он может получать контекст), правда выглядеть это будет уже не так красиво:
Особо рисковые и нетерпеливые ценители красоты могут воспользоваться патчем, он как раз позволяет передавать контекст в фильтр.
Остались хлопоты с контекстом, можно написать свой context processor, а можно использовать стандартный django.core.context_processors.request и заполнять его свойство timezone с помощью Middleware:
Зависимость от session middleware можно убрать, если вы не собираетесь кэшировать часовой пояс в сессии. Функция
Собственно, можно было бы привести код для тега и фильтра шаблона и на этом закруглится, но профессионально ленивый программист, вроде меня, решит, что писать тег или фильтр localtime каждый раз это хлопотно, плюс при выдаче форм нужно вручную преобразовывать время в полях туда и обратно, плюс в отсутствие контекста запроса (рассылка писем по крону, например) это без дополнительных телодвижений не заработает, плюс при работе в видах нужно быть постоянно начеку — календарик с событиями может выглядеть по-разному для разных часовых поясов. Что ж трудолюбивые ребята могут взять код фильтра в примере к вышеупомянутому патчу и быть таковы, остальные пусть будут готовы к небольшому колдовству.
Очевидно, если мы хотим, чтобы даты и времена автоматически переводились в текущий временной пояс, то без некоторой магии тут действительно не обойтись. Все данные из моделей мы получаем через их поля — отлично, преобразовывая время после выборки и перед вставкой можно получить требуемый эффект. Однако, поля ничего не знают ни о контексте шаблона, ни о объекте запроса, их вообще может не быть. Очевидно, активный часовой пояс должен быть глобальным. Можно посмотреть как аналогичная ситуация разрешена в django.utils.translation и реализовать то же для часовых поясов:
Функция
И устанавливать активный часовой пояс для каждого запроса, например, дописав
Готово, просто заменяем стандартный
Надеюсь, все кто дочитал до этого места получили или практическую пользу, или эстетическое удовольствие, которое, полагаю, многим пишущим на Django знакомо.
P. S. В недавно вышедшем Django 1.2 изменился интерфейс полей модели, поэтому приведённый код для
В самом питоне с этим всё неплохо, есть отличный модуль 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
понадобится допилить в соответствии с инструкцией по обновлению