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

Часовые пояса - django timezone

Код проверялся на Django 3.1.5. На более cтарых версиях некоторые участки могут слегка отличаться. Но принципи неизменно актуальны для любой версии Джанго.

Подготовка

Создадим простой проект 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 из консоли на сервере (по ssh):

$ python3
>>> from datetime import datetime
>>> str(datetime.utcnow())
'2021-02-03 13:20:04.495444'

Текущее значение времени в поясе UTC для сверки вы можете проверить например на сайте worldtimeserver.

Вернемся к Django. Для полной поддержки таймзон должен быть установлен PyPI модуль pytz. Его можно установить через pip или pipenv (смотря что вы используете).

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

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

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

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

Время и часовые пояса (timezone) в Djnago

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

Карта часовых поясов

Все часовые пояса (timezone, или ТЗ) обозначены разными цветами. Некоторые из стран весной в последнее Воскресенье Марта переводят стрелки вперед на час, переходя таким образом на так называемое "летнее" время, или 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 }} <br>")
  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 Feb. 3, 2021, 1:40 p.m., str: 2021-02-03 13:40:43.792106+00:00
London TZ activated, now is Feb. 3, 2021, 1:40 p.m., str: 2021-02-03 13:40:43.805180+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 и аттрибутом равным, то есть смещение пояса +0. Если настройка USETZ не активна он возвращает 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, или базой maxmind), вобщем интересных вариантов может быть полно. Но это все дело техники, а нам все же для эксперимента удобнее активировать таймзону во вью что бы смотреть как это влияет на результат. К тому же далеко не всегда ТЗ будет привзяана к пользователю из реквеста. Возможны сложные ситуации когда в системе пользователю придется работать с объектами из разных стран и возможно ему понадобится смотреть время в зонах этих разных стран а не в своей.

Если вы хотите показать пользователю явно какую ТЗ использовал рендер, вы можете задать символ О в формате фильтра | date . При форматировании времени в примере выше мы не использовали фильтр и по этому рендер взял стандартный формат который выдал нам  Feb. 3, 2021, 1:40 p.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 Wed 03-02-2021 13:48:11 +0000, str: 2021-02-03 13:48:11.926188+00:00
London TZ activated, now is Wed 03-02-2021 13:48:11 +0000, str: 2021-02-03 13:48:11.931962+00:00

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

def main(request):
    t = Template('No TZ activated, now is {{ n1 }}, str: {{ n1_str }}<br />')

    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 Wed 03-02-2021 13:50:31 +0000, str: 2021-02-03 13:50:31.336855+00:00
London TZ activated, now is Wed 03-02-2021 13:50:31 +0000, str: 2021-02-03 13:50:31.339516+00:00

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

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

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

Формат python strftime: https://docs.python.org/3.8/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: {{ n1str }}<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,
             "n1str": 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 Wed 03-02-2021 14:08:23 +0000, str: 2021-02-03 14:08:23.923406+00:00

Query (запрос который Djnago ORM сформировал к базе данных):

QUERY = 'BEGIN' - PARAMS = ()
QUERY = 'INSERT INTO "tz_test_m" ("d") VALUES (%s)' - PARAMS = ('2021-02-03 14:08:23.923406',)
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})))

Введем время 2021-02-03 13:30:00 мы получим:

TZ activated Wed 03-02-2021 13:30:00 +0000, str: 2021-02-03 13:30:00+00:00 

Query (запрос который Djnago ORM сформировал к базе данных):

Query:<br />QUERY = 'BEGIN' - PARAMS = ()
QUERY = 'INSERT INTO "tz_test_m" ("d") VALUES (%s)' - PARAMS = ('2021-02-03 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 потому что она западнее а значит солнце там появится позже чем в Англии.

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

TZ activated Fri 01-01-2021 13:30:00 +0000, str: 2021-01-01 13:30:00+00:00

Query:

QUERY = 'BEGIN' - PARAMS = ()
QUERY = 'INSERT INTO "tz_test_m" ("d") VALUES (%s)' - PARAMS = ('2021-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/3.1/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:

[mysqld]
default-time-zone='+00:00'

Также в этом файле добавим клиента, который понадобится для pip пакета mysqlclient, который используется в Django для взаимодействия с базой данных mysql.

[client]
database = your_mysql_database
host = localhost
user = your_mysql_user
password = your_password
default-character-set = utf8

Во вторых нужно заполнить специальную таблицу в `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 tzinfoto_sql. Кстати пакет pytz который используется в Django тоже содержит ту же базу данных от IANA и его тоже стоит иногда обновлять.

В третих нужно изменить настройки базы данных в джанго:

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.mysql',
        'OPTIONS': {
            'read_default_file': '/etc/mysql/my.cnf',
        },
    }
}

И незабудте о manage.py makemigrations tz_test и manage.py migrate. Если у вас восникнут ошибки на подобии Table '...' doesn't exist или You can't delete all columns with ALTER TABLE; use DROP TABLE instead попробуйте закоментировать вашу модель и выполнить эти команды:

python manage.py makemigrations
python manage.py migrate --fake

Потом разкоментируйте модель и снова сделайте миграцию.

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

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

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