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

Подготовка

Создадим простой проект tz_test cо стандартными настройками:

TIME_ZONE = 'UTC'

USE_TZ = True

Тут указана поддержка часовых поясов а также указан стандартный часовой пояс который будет использован в некоторых случаях. Стандартным у нас указано UTC, то есть всемирное коорденированное время, не зависящее от географиеского положения и времени года. Синонимичным названием к UTC выступает UTC+0, но проще и наверное правильнее говорить просто UTC.

При администрировании многонациональных севреров админимстраторы частенько используют UTC на сервере. Установить UTC на многих Linux можно коммандой:

ln -fs /usr/share/zoneinfo/UTC /etc/localtime

Однако если ваши настройки часовых поясов другие и при этом ПО правильно определяет время UTC, вы можете оставить все как есть. Проверить вы можете так:

$ python3
>>> from datetime import datetime
>>> str(datetime.utcnow())
'2016-04-08 15:39:00.452852'

Текущее значение времени в поясе UTC для сверки вы можете проверить например тут: http://www.worldtimeserver.com/current_time_in_UTC.aspx.

Вернемся к Django. Для полной поддержки таймзон должен быть установлен python модуль pytz.

Добавим однин простой урл на наш единственный вью main, который мы рассмотрим чуть позже:

urlpatterns = [
    url(r'^$', 'tz_test.views.main')
]

views.py я создам в той же папке что и settings.py, и ее же и буду использовать как папку с Django приложением tz_test, так что еще в settings.py в INSTALLED_APPS надо добавить tz_test для того чтобы manage.py потом смог искать модели для создания миграций в нашем приложении.

INSTALLED_APPS = (
    ....
    'tz_test',
)

Время и часовые пояса

Для удобства понимания часовых поясов можем рассмотреть карту Европы от http://www.timeanddate.com/ :

Все страны находятся в каком-то часовом поясе (ТаймЗоне, далее ТЗ) и к тому же некоторые из них весной в последнее Воскресенье Марта переводят стрелки вперед на час, переходя таким образом на так называемое "летнее" время, или DST, Daylight Saving Time, якобы сокращая таким образом использование искуственного света летом. Ну а осенью в конце Сентября переводят обратно назад, возвращаясь в стандартный режим. Но это делают далеко не все страны так как выгода от такого перехода весьма спорна, а вот проблемы вечных переводов всегда присутствуют в разных сферах жизни. В странах где используется DST, cтандартное время обычно называют зимним, но такое название немного запутывает потому что на самом деле зимнего времени не бывает,  а есть только стандартное и летнее (DST).

К примру в Исландии время UTC, и DST не используется. В Англии зимой UTC, а летом UTC+1. Собственно для примера я и возьму Лондон. Сейчас там UTC+1 так как статья пишется в Апреле и с перевода на DST уже прошло пару недель.

Отображение времени в шаблонах Django

Начнем с самого простого - получим время используя стандартную функцию timezone.now() и отобразим его двумя способами - выводом со стандартным форматом в шаблоне и кастом к строке (такой каст покажет все внутренности объекта datetime который возвращает метод):

from django.http.response import HttpResponse
from django.template import Context, Template
from django.utils import timezone
import pytz


def main(request):

    t = Template("No TZ activated, now is {{ n1 }}, str: {{ n1_str }}")
    n1 = timezone.now()
    args = {"n1": n1,
            "n1_str": str(n1)}
    resp = t.render(Context(args))

    timezone.activate(pytz.timezone("Europe/London"))
    n2 = timezone.now()
    args["n2"] = n2
    args["n2_str"] = str(n2)
    t = Template("London TZ activated, now is {{ n2 }}, str: {{ n2_str }}")
    resp += t.render(Context(args))

    return HttpResponse(resp)

Результат:

No TZ activated, now is April 8, 2016, 10:33 a.m., str: 2016-04-08 10:33:43.981955+00:00
London TZ activated, now is April 8, 2016, 11:33 a.m., str: 2016-04-08 10:33:43.982455+00:00

Посмотрев на внутренние представления объектов времени вы можете убедиться что функция timezone.now() всегда возвращает текущее время в UTC, не зависимо активирована ли сейчас таймзона или нет и кстате не зависемо от settings.TIME_ZONE. Это важно. Можем даже вглянуть на код timezone.now():

def now():
    """
    Returns an aware or naive datetime.datetime, depending on settings.USE_TZ.
    """
    if settings.USE_TZ:
        # timeit shows that datetime.now(tz=utc) is 24% slower
        return datetime.utcnow().replace(tzinfo=utc)
    else:
        return datetime.now()

Как видим когда в сеттингсах активирована USE_TZ функция просто создает объект datetime с временем UTC и аттрибутом tzinfo равным UTC, то есть смещение пояса +0. Если настройка USE_TZ не активна он  возвращает datetime с текущем временим сервера и аттрибутом tzinfo равным None. Кстати объекты в которых задан аттрибут tzinfo называют aware потому что они "осведомлены" о том в какой таймзоне они указаны. Остальные же называются native, они не знают какое в них время, они знают только что оно родное:

>>> from datetime import datetime
>>> from pytz import utc
>>> str(datetime.utcnow().tzinfo)
'None' # я native
>>> str(datetime.utcnow().replace(tzinfo=utc).tzinfo)
'UTC' # я aware

Вернемся к нашему вью. В первом случае мы отрендерили темплейт t когда таймзона не была активирована, по этому при рендере времени использовалась ТЗ из settings.TIME_ZONE, то есть UTC, во втором же случае перед рендером мы активировали лондонский часовой пояс и рендер перевел время в UTC в локальное лондонское UTC+1, что и ожидалась. Отрендереное время с активной ТЗ называется локальным (localtime).

Активацию на практике конечно удобнее выполнять не в сасмом view а в каком-нибудь Middlware (в функции которая выполнится перед выполнением view). Например она может выбирать таймзону из профайла request.user, или вытаскивать из запроса айпи через request.META.get('REMOTE_ADDR') и смотреть какая ТЗ в его стране (определяя страну каким-нибудь pygeoip), вобщем интересных вариантов может быть полно. Но это все дело техники, а нам все же для эксперимента удобнее активировать таймзону во вью что бы смотреть как это влияет на результат. К тому же далеко не всегда ТЗ будет привзяана к пользователю из реквеста. Возможны сложные ситуации когда в системе пользователю придется работать с объектами из разных стран и возможно ему понадобится смотреть время в зонах этих разных стран а не в своей.

Если вы хотите показать пользователю явно какую ТЗ использовал рендер, вы можете задать символ О в формате фильтра | date . При форматировании времени в примере выше мы не использовали фильтр и по этому рендер взял стандартный формат который выдал нам April 8, 2016, 11:33 a.m.

Изменим шаблоны так:

t = Template('No TZ activated, now is {{ n1 | date:"D d-m-Y H:i:s O"}}, str: {{ n1_str }}<br>')
...
t = Template('London TZ activated, now is {{ n2 | date:"D d-m-Y H:i:s O"}}, str: {{ n2_str }}')

Теперь мы увидим:

No TZ activated, now is Fri 08-04-2016 10:49:42 +0000, str: 2016-04-08 10:49:42.381156+00:00
London TZ activated, now is Fri 08-04-2016 11:49:42 +0100, str: 2016-04-08 10:49:42.655191+00:00

Теперь давайте сделаем такойже формат только дату отрендерем не на джанговском процессоре а вручную:

def main(request):

    t = Template('No TZ activated, now is {{ n1 }}, str: {{ n1_str }}
') n1 = timezone.now() args = {"n1": n1.strftime("%a %d-%m-%Y %H:%M:%S %z"), "n1_str": str(n1)} resp = t.render(Context(args)) timezone.activate(pytz.timezone("Europe/London")) n2 = timezone.now() args["n2"] = n2.strftime("%a %d-%m-%Y %H:%M:%S %z") args["n2_str"] = str(n2) t = Template('London TZ activated, now is {{ n2 }}, str: {{ n2_str }}') resp += t.render(Context(args)) return HttpResponse(resp)

Резульат:

No TZ activated, now is Fri 08-04-2016 10:59:19 +0000, str: 2016-04-08 10:59:19.267912+00:00
London TZ activated, now is Fri 08-04-2016 10:59:19 +0000, str: 2016-04-08 10:59:19.267912+00:00

Что-то пошло не так? Результат не правильный - перевод в активный часовой пояс не сработал? Нет. Все верно - в прошлом примере объект типа datetime (который вернул нам timezone.now() ) через контекст попал рендеру и тот представлял его в строку сам переобразовав в localtime. То есть рендер знал что сейчас активрована ТЗ лондона и по этому отобразил все в ней. В данном же случае мы сами просто отформатировали объект datetime который сейчас в UTC и получили строку отдав ее в контекст, рендер увидел что это объект типа str и конечно уже не стал с ним ничего делать.

Кстати обратите внимание что форматы для фильтра даты отличаются от форматов в стандартных функциях пайтона. Вот две быcтрые ссылки, если вам когда-нибудь понадобится найти таблици формата, помните что они тут =):

Формат Djnago фильтра |datehttps://docs.djangoproject.com/en/1.9/ref/templates/builtins/#date

Формат python strftimehttps://docs.python.org/3.4/library/datetime.html#strftime-strptime-behavior

Но что если вам все же нужно отобразить время в текущем часовом поясе не передавая его через шабоны (например вы отдаете JSON для какого-нибудь асинхронного фреимворка вроде DataTables.net). В этом случае можно сделать следующее:

current_tz = timezone.get_current_timezone()
current_tz.normalize(n1).strftime("%d.%m.%Y %H:%M:%S %z")

Методом normalize мы вручную выполнили перевод времени UTC в localtime.

Таймзоны в моделях и формах

Давайте создадим простую модель с одним полем типа DateTimeField и посмотрим как сохраняются aware datatime в БД. Стандартного SQLite нам хватит. Вот models.py:

from django.db.models.base import Model
from django.db.models.fields import DateTimeField

class M(Model):
    d = DateTimeField()

Выполним manage.py makemigrations tz_test и manage.py migrate.

Для начала просто сохраним в модель текущее время и сразу перечитав модель достанем его, также выведем запросы к БД от текущего коннекшена чтобы представлять что происходит.

from django.db import connection
from django.http.response import HttpResponse
from django.template import Context, Template
from django.utils import timezone
import pytz
from tz_test.models import M


def main(request):

timezone.activate(pytz.timezone("Europe/London"))
  t = Template('TZ activated, now {{ n1 | date:"D d-m-Y H:i:s O" }}, str: {{ n1_str }}<br>'
  'Query: <div style="font-family:\'Lucida Console\'">{{ qry |safe }}</div>')
  m = M()
  m.d = timezone.now()
  m.save()
  m1 = M.objects.get(id=m.id)
args = {"n1": m1.d,
"n1_str": str(m1.d),
  "qry": "<hr>".join(["{}".format(c['sql']) for c in connection.queries])}
  resp = t.render(Context(args))

  return HttpResponse(resp)

Получим такое

TZ activated, now Fri 08-04-2016 14:16:07 +0100, str: 2016-04-08 13:16:07.004662+00:00
Query:
QUERY = 'BEGIN' - PARAMS = ()
QUERY = 'INSERT INTO "tz_test_m" ("d") VALUES (%s)' - PARAMS = ('2016-04-08 13:16:07.004662',)
QUERY = 'SELECT "tz_test_m"."id", "tz_test_m"."d" FROM "tz_test_m" WHERE "tz_test_m"."id" = %s' - PARAMS = (17,)

Пока все просто и понятно - в модель сохраняется UTCшный datetime а при выводе он рендерится с применением таймзоны.

Давайте добавим форму и сохраним значение из нее:

from django.db import connection
from django.forms.models import ModelForm
from django.http.response import HttpResponse
from django.template import Context, Template
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
import pytz
from tz_test.models import M

class MForm(ModelForm):
    class Meta:
        model = M
        fields = ['d']

@csrf_exempt
def main(request):
    timezone.activate(pytz.timezone("Europe/London"))
    if request.method == "GET":
        form = MForm()
        return HttpResponse(
            Template('<form method="POST">{{ f }} <button>DO POST</button></form>').render(Context({"f": form})))
    elif request.method == "POST":
        t = Template('TZ activated {{ n1 | date:"D d-m-Y H:i:s O" }}, str: {{ n1_str }}<br>'
                     'Query: <div style="font-family:\'Lucida Console\'">{{ qry |safe }}</div>')
        f = MForm(request.POST)
        if f.is_valid():
            m = f.save(commit=False)
            m.save()
            m1 = M.objects.get(id=m.id)
            args = {"n1": m1.d,
                    "n1_str": str(m1.d),
                    "qry": "<hr>".join(["{}".format(c['sql']) for c in connection.queries])}
            resp = t.render(Context(args))

            return HttpResponse(resp)
        else:
            return HttpResponse(
                Template('<form method="POST">{{ f }} <button>DO POST</button></form>').render(Context({"f": f})))
    

Введем время 2016-04-01 13:30:00 мы получим:

TZ activated Fri 01-04-2016 13:30:00 +0100, str: 2016-04-01 12:30:00+00:00
Query:
QUERY = 'BEGIN' - PARAMS = ()
QUERY = 'INSERT INTO "tz_test_m" ("d") VALUES (%s)' - PARAMS = ('2016-04-01 12:30:00',)
QUERY = 'SELECT "tz_test_m"."id", "tz_test_m"."d" FROM "tz_test_m" WHERE "tz_test_m"."id" = %s' - PARAMS = (36,)

Вот тут уже интересней. Как можем увидеть в базу на этот раз уже записывалось значение на час меньше. Из этого следует что при сохранении формы Django работает в обратную к рендеру сторону - он предполагает что пользователь вводит в своей ТЗ (ну то есть в той которая активирована) а значит нужно перевести это в UTC. Действительно - во время DST в Лондоне 1ч 30 мин в Исландии в это время еще только 12:30 потому что она западнее а значит солнце там появится позже чем в Англии.

Для закрепления попробуйте еще ввести 2016-01-01 13:30:00, и проанализировать результат. Он будет такой:

TZ activated Fri 01-01-2016 13:30:00 +0000, str: 2016-01-01 13:30:00+00:00
Query:
QUERY = 'BEGIN' - PARAMS = ()
QUERY = 'INSERT INTO "tz_test_m" ("d") VALUES (%s)' - PARAMS = ('2016-01-01 13:30:00',)
QUERY = 'SELECT "tz_test_m"."id", "tz_test_m"."d" FROM "tz_test_m" WHERE "tz_test_m"."id" = %s' - PARAMS = (38,)

Надеюсь вы уже поняли почему. Если нет - спрашивайте в комментарях.

На этом все, надеюсь статья помогла вам понять основные принципы. Этого должно быть достаточно для реализации самых разных задач связыных с таймзонами, но в некоторых случаях вам могут понадобиться дополнительные простоые методы и фильтры, которые местами как и все средства Django позволят элегантно решить те или иные сложности, так что советую хотябы бегло просмотреть что там есть и взять на заметку: https://docs.djangoproject.com/en/1.7/topics/i18n/timezones/.

Бонус: Преобразование в localtime на сервере БД MySQL (MariaDB)

Опытные программмисты Django знают что ORM в Dango может решить далеко не все задачи. Например вот вам такая задача: есть несколько объектов M в БД в которых хранится определенное время в поле sometime (TimeField, который без даты и хранит просто объект time, а не datetime которые мы рассматривали выше). Также в объекте есть поле часового пояса tz - обычная строка, например "Europe/Kiev":

class M(Model):
   sometime = TimeField(default=time(hour=0))

   tz = CharField(max_length=50, default="UTC", blank=False, choices=[(t, t) for t in pytz.common_timezones])

И вам нужно вытащить объекты из базы например по такому условию:

 

objects.filter(sometime__gt=ТЕКУЩЕЕ_ВРЕМЯ_В_ПОЯСЕ_tz)

То есть объекты в которых в поле sometime указано время большее чем текущее в их ТЗ.

Сделать такое стандартным ORM невозможно (иля я не нашел способа). Возможно вы скажете почему бы не сохранить sometime в БД уже в UTC переконвертировав его при вводе используя поле tz а потом сравнивать с UTC? Ответ прост - этого сделать нельзя потому что объекты time вообще не подлежат конвертациям в часовых поясах - невозможно просто так взять и перевести время в какойто часовой пояс не зная даты! Нельзя уже даже потому что без даты мы не знаем DST сейчас или нет. По этому такой хитрый фильтр придется реализовывать вручную.

Во первых мы должнч настроить ТЗ в my.conf:

default-time-zone='+00:00'

Во вторых нужно заполнить специальную таблицу в mysql которая будет использована при конвертациях.

mysql_tzinfo_to_sql /usr/share/zoneinfo | mysql -u root -p mysql

Эта комманда запоняет специальную таблицу информацией из каталогов в /etc/zoneinfo. Эти каталоги в свую очередь поставляются в системном пакете tzdata и содержат инфу из базы часовых поясов IANA http://www.iana.org/time-zones. Когда какая либо страна принимает решение об отказе или введении DST, IANA выпускает апдейт базы, меинтейнеры обновляют в репозиториях пакет tzdata а системные администраторы должы обновить его в системе и заново выполнить mysql_tzinfo_to_sql. Кстати пакет pytz который используется в Django тоже содержит ту же базу данных от IANA и его тоже стоит иногда обновлять.

Сделав эти два простых действия осталось написать EXTRA WHERE в Django:

extra_where = "`M`.`sometime` > TIME(CONVERT_TZ(now(), 'UTC', `M`.`tz`))"
result = M.objects.filter().extra(where=[extra_where])

На самом деле постоянно писать чистый SQL далеко не обязательно. В данном случае вы можете потратить немного времени на создание своего Lookup-а для ORM, например назвав его gtnow и вызывать его в фильтре, передавая ему имя колонки с тайм зоной (например filter(sometime__gtnow='tz')) либо собственно название таймзоны через F : filter(sometime__gtnow=F('tz')) - зависит от того как вы этот лукап реализуете.