Django（Python）のDatetimeFieldとタイムゾーンの関係を理解して日時時刻処理をスムーズに実装できるようにする

# はじめに

DjangoでDatetimeFieldを使って日時時刻に関する処理を実装しようとするとタイムゾーンの設定のところで毎回つまづくので、DjangoでのDatetimeFieldの取り扱い方を学習した備忘録を残しておきます

参考にさせてもらった記事はこちら
https://nykergoto.hatenablog.jp/entry/2020/04/23/python_%E3%81%AE%E6%99%82%E5%88%BB_DateTime%E3%83%BBTimezone_%E3%81%A8_Django


# Datetime（日時）情報取り扱い概要

プログラミングをしていて言語や時刻を扱う際は地域の違いを考慮する必要があります
言語の取り扱いは日本では全角と半角に気を付けていれば基本的に何とかなりますが、時刻の取り扱いに関してはなかなかエラーが出まくるめんどくさい代物になっています
というのも時刻は使用する地域ごとに時差があるので、地域ごとに時刻の修正を行う必要があります
そのために利用するのがタイムゾーンで、日本の場合はTIME_ZONE = 'Asia/Tokyo'が割り当てられています
さてタイムゾーンですがご存じの通り基準値が存在してそれがTIME_ZONE = 'UTC'です
UTCはイギリスのグリニッジ標準時刻となっており、日本とイギリスでは+9時間の時差があります
※「時差 = 日本の時刻 - イギリスの時刻」

さて、イギリスと日本で利用されることを想定したアプリケーションを実装する際にログイン時刻やデータ作成時刻をDBに格納することを考えてみたいのですが、時差があるとどのようにデータを格納したらいいかちょっと困ります
仮に日本は日本・イギリスはイギリスの時刻を別々にそのままDBに格納したら大変めんどくさいことが起きます
例えば、アプリケーションでログイン時刻の速さを競う大会を仮に開いたとすると日本人とイギリス人で格納するデータが違うと順位判定をするときにすんごくめんどくさい処理をしなければならないことが容易に想像できると思います

なので、通常DBにはUTCつまりイギリスの時刻のデータを格納します
例えば日本で12月25日午前6時にログインしたとするとDBには12月24日午後9時の値が格納されます
※「イギリスの時刻 = 日本の時刻 - 時差（9時間）」
この時刻をタイムゾーンを含まない時間=Naive Timeと呼びます

さてではDBから値を取り出すときはどうしたらいいでしょうか？
そのまま取り出してはダメなことが分かると思います
ログインした日本人にとっては、12月25日午前6時にログインしているので12月24日午後9時が表示されてしまうと過去に戻ってしまうからです
なので日本でアプリケーションを使用して時刻を使用する場合はすべての時刻を日本時間に合わせて使用します
なのでDBから取り出してきたデータは一度すべて日本時間に戻すという作業を行います
ですので、アプリケーションのプログラムで使用する際は、DBに格納されている12月24日午後9時に+9時間を付与して12月25日午前6時の時間に戻すということですね
※「日本の時刻 = イギリスの時刻 + 時差（9時間）」
この時刻をタイムゾーンを含む時間=Aware Timeと呼びます


# Naive Time v.s. Aware Time

さてプログラミングの世界ではタイムゾーンを含むかどうかを特に意識しないといけないことがわかりました
タイムゾーンを含まない時刻（すなわちUTC）をNaive Time、タイムゾーンを含む時刻をAware Timeといいました

さてDBではNaive Timeで格納し、アプリケーションではAware Timeで取り扱いたいというのが一般的かと思います
そうなってくるとNaive Time と Aware Time を簡単に変換できる関数が欲しくなってきます

## Naive Time → Aware Time 変換

Pythonにおいてはその関数は```naive.astimezone```です
Naive Time にタイムゾーンを追加して Aware Time に変換してくれるメソッドです

```python
import pytz

# Naive Time で全員同時刻のデータをもっている場合
naive.astimezone(pytz.UTC) # 12月24日午後9時
naive.astimezone(pytz.timezone('Asia/Tokyo')) # 12月25日午前6時
naive.astimezone(pytz.timezone('America/New_York')) # 12月24日午後5時
```

なお、Naive Time と Aware Time で足し算・引き算を行うと TypeError が発生します


## Aware Time → Naive Time 変換

また逆に東京などのタイムゾーンを削除してUTCに変換したい場合は```timezone.locale```を使用します

```python
import pytz

# Naive Time で全員同時刻のデータをもっている場合
pytz.timezone(pytz.UTC).timezone.locale # 12月24日午後9時
pytz.timezone('Asia/Tokyo').timezone.locale # 12月24日午後9時
pytz.timezone('America/New_York').timezone.locale # 12月24日午後9時
```

# Djangoでの時間の取り扱い

settings.py に記載する USE_TZ を True にするとDjangoアプリケーションの処理はタイムゾーンを考慮したAware Timeを使用して処理を行うようになります
逆にUSE_TZ を False にするとタイムゾーンを含まないNaive Timeを使用して処理を行うようになります

ちなみにDjangoではデフォルトで基本USE_TZ=Trueに設定されています

さて、日本でアプリ開発をしていたら通常こうなっているであろうsettings.pyの設定が以下の状態の場合に時刻データがどうなるかを見ていきましょう

settings.py

```python
TIME_ZONE = 'Asia/Tokyo'
USE_TZ = True
```

## 基本的な使い方では何も意識する必要はない

Djangoの流儀に則って開発している限りは基本的には開発者は時刻を意識する必要はありません

DjangoはTZ_USEだとアプリケーション上ではAware Timeを使用して、DBのデータはNaive Timeで格納しなければならないという齟齬が出ますが、以下のようにモデルを作る際のDateTimeFieldでauto_now_addやauto_nowを付与しているとタイムゾーンを全く意識せずに、DBにはNaiveで格納してくれます
格納の方は全く煩わしいことはありません
またデータを取り出すときも比較などの処理を行わなければなにも意識することなく使用することができます

```python
class Profile(models.Model):

    created_at = models.DateTimeField(verbose_name="登録日時", auto_now_add=True)
    updated_at = models.DateTimeField(verbose_name="更新日時", auto_now=True, blank=True, null=True)
```

ちなみにDateTimeFieldでdefalutを使うことは非推奨なようです
理由としてはモデルの作成時ではなくpythonの関数実行時の時刻が格納されて信頼度が落ちるためだそうです

## 自動で時刻を格納してくれる機能を使用しない場合のケース

次にauto_now_addやauto_nowなどの自動で時刻を格納してくれる機能を使用しないケースの場合はどうしたらいいでしょうか？
デフォルトで時刻データを生成してくれるauto_now_addやauto_nowを使わない場合のDateTimeFieldのデータ生成方法は他に以下のような書き方があります
ちなみにユーザーアクティベーションのためのトークンを作る際に有効期限を作成する例です

この場合datetime.now()+timedelta(days=3)はAware Timeの時刻となりますが、実はこれも特に意識せずにこのまま使用することができます
Aware Timeで格納してもDjangoがよしなに対応してくれます
なので特に几帳面が行き過ぎている人でもない限りはこのままで放置してもいいとおもいます

```python

class UserActivateTokens(models.Model):

    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
    token = models.UUIDField(default=uuid.uuid4)
    expired_at = models.DateTimeField()

    objects = UserActivateTokensManager()


def publish_activate_token(sender, instance, **kwargs):
    user_activate_token = UserActivateTokens.objects.create(
        user=instance,
        expired_at=datetime.now()+timedelta(days=3),
    )
```

ただし、生成時にコンソールに以下のような警告メッセージは出てきます
しかしながらサポートはしているよとのことらしいのでDjangoはさすがです

:::note warn
RuntimeWarning: DateTimeField UserActivateTokens.expired_at received a naive datetime (2022-02-17 21:10:44.479200) while time zone support is active.
  warnings.warn("DateTimeField %s received a naive datetime (%s)"
:::

## DBに格納していたNaiveの時刻データを使った比較や計算処理が絡む場合

### 比較の場合

さて、組み込みフィルタ関数のgteなどで時刻を比較するときはどうでしょうか
実はこれも特にDBのNaiveやdatetime.now()で生成されるのはAware Timeとなっていますが、AwareをNaiveに変換する必要があるのかというと、特にその必要がなくDjangoがよしなにやってくれます
なのでexpired_at__gteの右辺はAware Timeとなるdatetime.now()を入力しても大丈夫となっています

以下はユーザーアクティベーションのトークンの有効期限切れ判定をする例です

```
class UserActivateTokensManager(models.Manager):

    def activate_user_by_token(self, token):
        user_activate_token = self.filter(
            token=token,
            expired_at__gte=datetime.now() # __gte = greater than equal
        ).first()
        if hasattr(user_activate_token, 'user'):
            user = user_activate_token.user
            user.is_active = True
            user.save()
            return user

```

### 足し算や引き算の計算の場合はタイムゾーンをそろえる

では、じゃあ全部Djangoがよしなにやってくれるのかというと残念ながら最後にちょっと曲者パターンがあります
以下は最近のログイン時刻の計算をする処理です
login_time: datetime = self.user.last_loginはDBから取り出してきたNaiveな標準時刻（UTC）を差す時刻データとなっています
なので、現在時刻datetime.now()をastimezone()を使用して標準時刻（UTCの時刻）に変換しています
これで比較できるようになりました
自作のメソッド側で時刻を比較する場合はタイムゾーンをそろえる必要がありました
これはあくまで一例でほかの実装もあると思います

```python
class Profile(models.Model):

    user = models.OneToOneField(
        settings.AUTH_USER_MODEL, primary_key=True, on_delete=models.CASCADE, related_name='profile')

    def from_last_login(self):
        now_aware = datetime.now().astimezone()
        if self.user.last_login is None:
            return "ログイン歴なし"
        login_time: datetime = self.user.last_login
        if now_aware <= login_time + timedelta(days=1):
            return "24時間以内"
        elif now_aware <= login_time + timedelta(days=2):
            return "2日以内"
        elif now_aware <= login_time + timedelta(days=3):
            return "3日以内"
        elif now_aware <= login_time + timedelta(days=7):
            return "1週間以内"
        else:
            return "1週間以上"

```