Open11

Python/Django のTips 備忘録

ピン留めされたアイテム
tenkeitenkei

概要

Python/Django 開発での個人的な Tips を書き足していく。
あくまで Python 初心者である筆者の備忘録です。内容の正確性が担保されたものではないことに注意。

tenkeitenkei

クラスメソッドのモック

テスト時、特定のクラスのメソッドをモックしたい場合

例: Foo クラスの do メソッドの中で Slack 通知を行うような実装

import unittest.mock

from foo import Foo
from utils.slack import SlackUtils

class TestFoo
    @unittest.mock.patch.object(SlackUtils, 'send')
    def test_get(self, mock_slack_send):
        Foo.do()
        assert "メッセージ内容" in mock_slack_send.call_args.kwargs['message']

tenkeitenkei

Model 同士にリレーションを張る際、 related_name を指定することができる。

from django.db import models

class User(models.Model):
  # 色々なプロパティ

class Profile(models.Model):
  user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='user_profile')

user = User.objects.first()
user.user_profile
tenkeitenkei

1対1でのリレーションの存在確認

ObjectDoesNotExist でエラーハンドリングする。

from django.core.exceptions import ObjectDoesNotExist

class Person(models.Model):
    def get_profile(self):
        try:
            return self.profile
        except ObjectDoesNotExist:
            return None

class Profile(models.Model):
    person = models.OneToOneField(Person, on_delete=models.CASCADE)

あるいは has_attr を利用してもよい。

class Person(models.Model):
    def get_profile(self):
        return self.profile if hasattr(self, 'profile') else None

class Profile(models.Model):
    person = models.OneToOneField(Person, on_delete=models.CASCADE)

https://docs.djangoproject.com/en/5.1/topics/db/examples/one_to_one/

tenkeitenkei

related_name で指定した名称で解決しようとすると、Any になってしまう。(自分の書き方が悪いのかもしれない)

from typing import Optional

class User(models.Model):
  # 色々なプロパティ

class Profile(models.Model):
  user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='user_profile')

user = User.objects.first()
profile = user.user_profile # Any

# 型指定しても Any は取れない
profile: Optional[UserProfile] = user.user_profile # UserProfile | None | Any

cast を使うといったん解決。

from typing import cast, Optional

profile = cast(Optional[UserProfile], user.user_profile) # UserProfile | None
tenkeitenkei

Django Admin の save_model メソッド

Django Admin 上の保存・変更をフックにして、何かの処理をしたいときがある。関連するデータも更新する、など。
そのような場合に save_model メソッドが利用できる。

from models import Person

@admin.register(Person)
class PersonAdmin(admin.ModelAdmin):
    # 色々なプロパティ

    # 色々なメソッド

    def save_model(self, request, obj, form, change):
        before = User.objects.get(id=obj.id)

        # before と after(obj) を利用して、ここで処理を実行できる。

        # 保存を続行するには、親メソッドを実行
        super().save_model(request, obj, form, change)

request には HTTP リクエスト情報が含まれる。あまり使うことはないかも?
change には bool が入り、新規追加なら False が入り、既存のレコードへの編集なら True が入る。

tenkeitenkei

admin.py のファイル分割

Django Admin の記述を1つの admin.py ファイルに加えていくと、ファイルの行数がどんどん増えていく。これを分割する方法。

# 分割前
- my_project
    - admin.py  # 例えば、Author, Book などの Model を含むとする

# 分割後
- my_project
    - admin # ディレクトリ(モジュール)にしてしまう
        - __init__.py
        - author_admin.py
        - book_admin.py

分割したファイル内で admin.register を行う。

from django.contrib import admin
from api.models.author import Author

@admin.register(Author)
class AuthorAdmin(admin.ModelAdmin):
    # list_display や had_delete_permission など

そして __init__.py に呼び出せばOK

import my_project.admin.author
import my_project.admin.book
tenkeitenkei

マイグレーション時に何もしない

マイグレーションファイルで「何もしない」を表現したいとき、 noop が利用できる。

from django.db import migrations


def upgrade(apps, schema_editor):
    # アップグレード時の処理


class Migration(migrations.Migration):

    dependencies = [
        ('app_name', '1234_previous_migration'),
    ]

    operations = [
        migrations.AddField(
            model_name='person',
            name='address',
            field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='app_name.address'),
        ),
        migrations.RunPython(
            code=upgrade,
            reverse_code=migrations.RunPython.noop,  # ロールバック時は何もしない
        )
    ]

ちなみに、 RunPython の reverse_code だけを省略することはできない。

migrations.RunPython(
    code=upgrade
)
tenkeitenkei

TYPE_CHECKING で型定義の循環参照を回避する

Django でモデルが複雑になってくると、循環参照に陥ることがある。

# Person
from django.db import models
from models.book import Book

class Person(models.Model):
    def get_books() -> list[Book]
        return ...


# Book
from django.db import models
from models.person import Person

class Book(models.Model):
    person = models.ForeignKey(Person, ...)

ForeignKey の指定に文字列を使えば、循環参照の問題は回避できる。が、型情報が失われてしまい不便。

from django.db import models

class Book(models.Model):
    person = models.ForeignKey('Person', ...)  # 文字列に変更した

typing が提供する TYPE_CHECKING を利用すれば、この問題は解決できる。

# Person
from typing import TYPE_CHECKING
from django.db import models
if TYPE_CHECKING:
    from models.book import Book

class Person(models.Model):
    def get_books() -> list['Book']
        return ...


# Book
from typing import TYPE_CHECKING
from django.db import models
if TYPE_CHECKING:
    from models.person import Person

class Book(models.Model):
    person = models.ForeignKey['Person']('Person', ...)

クォーテーションで囲まないと行けないのがちょっと気持ち悪いけど…。

参考:

https://zenn.dev/ganariya/articles/python-lazy-annotation

tenkeitenkei

複合インデックス、複合ユニーク制約

Django で複合インデックス、複合ユニーク制約を指定する方法。 Meta クラス内で指定する。

from django.db import models

class Person(models.Model):
    foo = models.CharField()
    bar = models.CharField()

    class Meta:
        indexes = [
            models.Index(fields=['foo', 'bar'], name='%(class)s_foo_bar_idx')
        ]
        constraints = [
            models.UniqueConstraint(fields=['foo', 'bar'], name='%(class)s_foo_bar_unique')
        ]

name はDB全体でユニークであるべきなので、 %(class)s_ プレフィックスをつけている。

tenkeitenkei

除外クエリ(__ne)

除外クエリは __ne で実現可能。

result = Book.objects.filter(category__ne=BOOK_CATEGORY.NOVELS)

ただし、以下のような複合クエリでは使えない。

result = Book.objects.filter(
    Q(category__ne=BOOK_CATEGORY.NOVELS) |
    Q(published_at__gte=...
)
# django.core.exceptions.FieldError: Unsupported lookup 'ne' for CharField or join on the field not permitted.

exclude で除外すればOK。__ne とは真偽が反転することに注意。

result = Book.objects \
    .filter(published_at__gte=...) \
    .exclude(category=BOOK_CATEGORY.NOVELS)