Django 4 y zonas horarias (timezone)

Aquí estamos con mi primer proyecto en el que tengo que tener en cuenta la zona horaria del usuario que visualiza los datos de la aplicación que estoy creando.

La aplicación recolecta unos datos de unos sensores y debe almacenarlos en la base de datos, pero luego estos datos que almaceno en mi servidor, tienen que corresponder a la hora del país donde se encuentra el sensor. Con esto empiezas a preguntarte, ¿cómo debo almacenar el dato? ¿cómo lo muestro?

Respuesta rápida:
– Pones tu aplicación en UTC
– Almacenas todos los datos de los sensores con UTC
– Visualizas los datos según la configuración horaria del navegador y/o del usuario

Al investigar sobre el tema, me han pasado un post en un blog muy divertido en el que explica todo esto de las zonas horarias, ¿sabías que existen 244 zonas horarias distintas y un total de 195 países? Y no todas las zonas horarias saltan de hora a hora, además de que nos encontramos en un país que tiene el horario de invierno y el de verano, cosa que no tienen todos los países. Vamos, todo esto de las zonas horarias es un cacao guapo, guapo, guapo.

Programando con Django, una cosa que me he encontrado varias veces es que hay 2 librerías que se llaman igual y se usan distinto (import datetime vs from datetime import datetime), un auténtico quebradero de cabeza que hace que código que funciona en una parte, no lo haga en otra. Al atacar esta cuestión para mi programa, ya temblaba, pero por suerte, parece que en Django 4 han “solucionado” el tema de gestión de las zonas horarias. Vamos a verlo.

django.utils timezone
Partimos de un ejemplo muy sencillo para ver como funciona

En la parte de backend tenemos esto:

def index(request):
    nav = {'menu': 'dashboard'}

    from django.utils import timezone
    _now = timezone.now()

    return render(request, 'web/system/dashboard.html', {'index': index, 'nav': nav, 'now': _now})

Y en la de frontend esto:

{% load tz %}

{% block content %}
    {% timezone "Europe/Madrid" %}
    Madrid time: {{ now }}
    {% endtimezone %}
    
{% timezone None %} Server time: {{ now }} {% endtimezone %} {% endblock %}

En el fichero settings.py del proyecto, he definido lo siguiente:

TIME_ZONE = 'UTC'
USE_TZ = True

Y la hora del servidor está en GMT+2, correspondiente al horario de verano en la zona horaria ‘Europe/Madrid’.

Al cargar la página, lo que nos muestra es:

Madrid time: 24 de juliol de 2022 a les 12:44
Server time: 24 de juliol de 2022 a les 10:44

El lío que tenemos que tener en cuenta entre una librería u otra, es lo que en la documentación llaman naive (ingenuo) o aware. En el formato naive, no se tiene en cuenta la hora horaria y en aware si, aquí un ejemplo de la documentación de django que nos permite ver la diferencia:

(Pdb) from django.utils import timezone
(Pdb) aware = timezone.now()
(Pdb) naive = timezone.make_naive(aware)
(Pdb) naive
datetime.datetime(2022, 7, 24, 9, 37, 42, 43903)
(Pdb) aware
datetime.datetime(2022, 7, 24, 9, 37, 42, 43903, tzinfo=datetime.timezone.utc)

Ahora lo siguiente que nos puede interesar es definir una hora distinta a la hora actual.

(Pdb) from django.utils import timezone
(Pdb) _now = timezone.now()
(Pdb) _now
datetime.datetime(2022, 7, 24, 11, 11, 29, 329913, tzinfo=datetime.timezone.utc)
(Pdb) import datetime
(Pdb) _past = datetime.datetime(2022, 1, 31, 10, 0)
(Pdb) _past
datetime.datetime(2022, 1, 31, 10, 0)
(Pdb) _past_aware = timezone.make_aware(_past)
(Pdb) _past_aware
datetime.datetime(2022, 1, 31, 10, 0, tzinfo=zoneinfo.ZoneInfo(key='UTC'))

Si replicamos el código de django de un poco mas arriba, añadiendo estas variables vamos a ver lo siguiente:

BACKEND

from django.utils import timezone
import datetime

def index(request):
    nav = {'menu': 'dashboard'}

    _now = timezone.now()
    _past = datetime.datetime(2022, 1, 31, 10, 0)
    _past_aware = timezone.make_aware(_past)

    return render(request, 'web/system/dashboard.html', { 'index': index, 'nav': nav, 'now': _now, 'past': _past, 'past_aware': _past_aware})

FRONTEND

{% load tz %}

{% block content %}
    {% timezone "Europe/Madrid" %}
    Madrid time: 
- now: {{ now }}
- past: {{ past }}
- past aware: {{ past_aware }} {% endtimezone %}

{% timezone None %} Server time:
- {{ now }}
- past: {{ past }}
- past aware: {{ past_aware }} {% endtimezone %} {% endblock %}

El resultado es

Madrid time:
- now: 24 de juliol de 2022 a les 13:17
- past: 31 de gener de 2022 a les 10:00
- past aware: 31 de gener de 2022 a les 11:00

Server time:
- 24 de juliol de 2022 a les 11:17
- past: 31 de gener de 2022 a les 10:00
- past aware: 31 de gener de 2022 a les 10:00

Es decir, si definimos timezone.make_aware(_past), le “activamos” la propiedad de zona horaria. Si no le definimos ninguna, va a interpretar UTC que es lo que tenemos definido en el settings.py.

La recomendación es que en el Backend, siempre se trabaje con UTC y sólo se muestre la fecha correspondiente al usuario en la parte de Frontend gracias al {% load tz %}.

En mi código, en el modelo Profile, he añadido un campo adicional que se llama timezone

[Models.py]
class Profile(models.Model):
    [...]
    import zoneinfo
    TIMEZONE_CHOICES = ((x, x) for x in sorted(zoneinfo.available_timezones(), key=str.lower))
    [...]
    timezone = models.CharField(max_length=250, choices=TIMEZONE_CHOICES, default='UTC')
    [...]

Y luego para definir la zona en el Frontend uso:

Current user timezone: {{ request.user.profile.timezone }}

{% timezone request.user.profile.timezone %}
    {{ request.user.profile.timezone }} time: 
- now: {{ now }}
- past: {{ past }}
- past aware: {{ past_aware }} {% endtimezone %}

En el caso de usuarios que estén registrados, al entrar, si tienen definida la zona horaria como UTC se les puede dejar un mensaje para que la configuren, la otra es usar librearías como GEOIP para localizar el país y tratar de localizar la zona horaria del usuario. Lo mismo para usuarios no autenticados.

Y esta es la teoría, a ver si conseguimos hacerlo funcionar y no explote nada! :D

Muchas gracias a todos los que habéis realizado aportaciones para llegar a este post! :)

Deixa un comentari

L'adreça electrònica no es publicarà. Els camps necessaris estan marcats amb *

Aquest lloc utilitza Akismet per reduir els comentaris brossa. Apreneu com es processen les dades dels comentaris.