Django Adminサイトの一覧画面からレコードが見えないケースもある

Django Adminサイトの一覧画面で見えない = レコードが無い訳では無い。

「あれ?レコードが無いから削除されているはずだよな・・・?」と勘違いしていたので再現方法を書く。

モデルを用意する

例として、所属部署と社員のテーブルを作成する。 社員側のテーブルのon_deletedb_constraintの設定値がポイント。

from django.db import models


class Department(models.Model):
    """所属部署"""
    name = models.CharField("名前", max_length=100)

    def __str__(self):
        return self.name


class Employee(models.Model):
    """社員"""
    name = models.CharField("名前", max_length=100)
    department = models.ForeignKey(
        Department, 
        on_delete=models.DO_NOTHING, 
        db_constraint=False,
    )

    def __str__(self):
        return self.name

レコードを登録する

そして、適当なレコードを登録する。

  • 営業部: KIRINJI
  • 仕入部: マカロニえんぴつ
  • 物流部: 平井堅
  • 商品企画部: nibu

と登録した。名前は単純に自分が直近再生していたアーティストの名前である。

部署を削除する

部署の商品企画部を削除する。 この時は今回の設定値だと、以下のような挙動になる。

一覧画面を見る

Employeesの一覧画面を見ると、さっきまで表示されていた

  • 商品企画部: nibu

が無くなっている。

しかし、よく見ると 画面の左下には 4 employees と記載されている。

そのためURLを直接操作して開くと、レコードがある事が確認できる。

ちなみに、Django Admin画面上からはdepartmentカラムの値が無さそうに見えるが、実際には4の値が入っている。

sqlite> SELECT * from company_employee;
1|KIRINJI|1
2|マカロニえんぴつ|2
3|平井堅|3
4|nibu|4

以上より、Django Adminサイト上から「ぱっと見」無かったとしてもレコードが削除されたとは言い切れない事がわかる。

ちゃんとDBを見て、レコードを確認する必要がある。

余談

今回のようなケースだと、on_delete=CASCADEを利用したり、そもそもデフォルトの設定値であるdb_constraint=Trueを利用することが多いと思っている(ここは正直まだあまり詳しくない)。

db_constraintについては公式リファレンスにもデフォルトはTrueが望ましいと書かれている。

デフォルトは True で、ほとんどの場合それが望ましい設定です。

また、続けて有効なケースとして以下の例が挙げられている。

無効なレガシーデータを持っている場合。 データベースをシャーディング(分割)している場合。

自分としては記録系のその時に起こった内容を正確に記録する必要があるテーブルだと、こういう設定をする必要があると個人的には考えている。データベース設計は難しい。

参考: 47:トランザクションデータは正確に記録しよう

pytest-djangoではclientはimportしなくて良い

client-django-test-clientを見て気がついた内容。

pytest-djangoではfixtureを用意している。 自分でimportせずとも、テストで利用する(であろう)ライブラリをすぐに使えるようにしてくれていると認識している。

その一つにget()post()のHTTPリクエストをテストするClientが含まれる。

e.g.

テストユーザーを作成してログインするコードを書いてみる。

before

import pytest
from django.test import Client
from django.contrib.auth.models import User


@pytest.fixture
def user():
    return User.objects.create_user(username="testuser", password="12345")


@pytest.fixture
def client(user):
    client = Client()
    client.force_login(user)

after

import pytest
from django.contrib.auth.models import User


@pytest.fixture
def user():
    return User.objects.create_user(username="testuser", password="12345")


@pytest.fixture
def client(user, client):  # ここで引数に`client` を与える事ができる
    client.force_login(user)
    return client

Djangoでpytestを使う方法

DjangoでUTを書く時にはPythonunittestのサブクラスにしたdjango.test.TestCaseを用いることが出来る。

その一方で、他のUTを書くことも含めて考えると、pytestを利用したいケースも多く存在すると思われる。 Djangoでpytestを利用するにはpytest-djangoというライブラリをインストールする必要がある。

pytest-djangoのセットアップ方法

pytest-djangoのdocsに書いてある手順で構築すれば良い。

  1. pytest-djangoをpipでinstallする
  2. 設定ファイルにDJANGO_SETTINGS_MODULEを記述する

となる。特に2番目の定数値記述が必要であり、記述しない場合、pytestでtestできない状況に陥ってしまう。 どのようなケースになるかは後述する。

ドキュメント上では以下の3ファイルでの例が掲載されている。

  1. pytest.ini
  2. tox.ini
  3. pyproject.toml

失敗の備忘録

pytest.iniの時の失敗した記録。 ※tox, pyprojectでも同様のような結果になると予想している。

DJANGO_SETTINGS_MODULEを設定していない場合、具体的なファイルを指定した上でpytestを実行するとImproperlyConfigured が発生する。

root@f6ebfa8e11c6:/djangoTaskApp/mysite# pytest tasks/tests.py 
=============================================================== test session starts ===============================================================
platform linux -- Python 3.12.2, pytest-8.0.1, pluggy-1.4.0
rootdir: /djangoTaskApp
configfile: pytest.ini
plugins: cov-4.1.0, django-4.8.0, Faker-24.7.1
collected 0 items / 1 error                                                                                                                       

===================================================================== ERRORS ======================================================================
_____________________________________________________ ERROR collecting mysite/tasks/tests.py ______________________________________________________
tasks/tests.py:4: in <module>
    from django.contrib.auth.models import User
/usr/local/lib/python3.12/site-packages/django/contrib/auth/models.py:3: in <module>
    from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager
/usr/local/lib/python3.12/site-packages/django/contrib/auth/base_user.py:57: in <module>
    class AbstractBaseUser(models.Model):
/usr/local/lib/python3.12/site-packages/django/db/models/base.py:129: in __new__
    app_config = apps.get_containing_app_config(module)
/usr/local/lib/python3.12/site-packages/django/apps/registry.py:260: in get_containing_app_config
    self.check_apps_ready()
/usr/local/lib/python3.12/site-packages/django/apps/registry.py:137: in check_apps_ready
    settings.INSTALLED_APPS
/usr/local/lib/python3.12/site-packages/django/conf/__init__.py:102: in __getattr__
    self._setup(name)
/usr/local/lib/python3.12/site-packages/django/conf/__init__.py:82: in _setup
    raise ImproperlyConfigured(
E   django.core.exceptions.ImproperlyConfigured: Requested setting INSTALLED_APPS, but settings are not configured. You must either define the environment variable DJANGO_SETTINGS_MODULE or call settings.configure() before accessing settings.
============================================================= short test summary info =============================================================
ERROR tasks/tests.py - django.core.exceptions.ImproperlyConfigured: Requested setting INSTALLED_APPS, but settings are not configured. You must either define the env...
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
================================================================ 1 error in 0.18s =================================================================
root@f6ebfa8e11c6:/djangoTaskApp/mysite# 

こうしたケースを防ぐため、以下のように記述する必要がある。

[pytest]
DJANGO_SETTINGS_MODULE = mysite.settings

他の凡ミス

設定値にクォーテーションは不要だが、Pythonの文字列の癖でつい、(ダブル)クォーテーションを付与するミスをしたケース。

なお、これはpytest.iniまたはtox.iniで起こりうると予想している。 ドキュメントの例を見ると、pyproject.tomlではダブルクォーテーションで囲まれているため。

[pytest]
DJANGO_SETTINGS_MODULE = "mysite.settings"

この時はImportErrorが発生し、Python pathを追加するように言われてしまう。 が、実際にはクォーテーションが不要だったというオチだった。

ImportError: No module named '"mysite'

pytest-django found a Django project in . (it contains manage.py) and added it to the Python path.
If this is wrong, add "django_find_project = false" to pytest.ini and explicitly manage your Python path.

django_migrationsテーブルについて

django_migrationsマイグレーション履歴を保持するテーブル。

しかし、意外とドキュメントに説明はない。 主にチュートリアル part 2 に以下の説明がある通りである。

Django tracks which ones are applied using a special table in your database called django_migrations

djangoリポジトリを検索し、チュートリアル part2を含めて記載箇所は以下の3箇所のようだった。

コードの配置場所

django/db/migrations/recorder.pydjango_migrationsのコードが存在している。

https://github.com/django/django/blob/main/django/db/migrations/recorder.py

If a migration is unapplied its row is removed from the table. Having a row in the table always means a migration is applied.

とあり、マイグレーションの適用状態をテーブルに記録していることがわかる。

MigrationRecorderクラスのMigrationメソッド内部にMigrationクラスがあり、そこでModelの定義がされている。

メソッド内部にクラスがある書き方は見慣れなかったが、docstringにはAppRegistryNotReady を防ぐための遅延ロードの手法であることが書かれていた。

Lazy load to avoid AppRegistryNotReady if installed apps import MigrationRecorder.

モデルの記録内容

記録する内容は以下の3つ。

具体例を見ると、以下のようになっていた。

postgres=# SELECT * FROM django_migrations WHERE app='tasks';
 id |  app  |                name                |            applied            
----+-------+------------------------------------+-------------------------------
 19 | tasks | 0001_initial                       | 2024-04-06 04:44:41.661527+00
 20 | tasks | 0002_task_title_alter_task_content | 2024-04-06 04:44:41.671349+00
 21 | tasks | 0003_task_limit_date               | 2024-04-06 04:44:41.677108+00
 22 | tasks | 0004_task_status                   | 2024-04-06 04:44:41.682666+00
 23 | tasks | 0005_task_order                    | 2024-04-06 04:44:41.687782+00
 24 | tasks | 0006_alter_task_status             | 2024-04-06 04:44:41.692116+00
(6 rows)

その他のメソッド

そのほか、以下が定義されていた。

  • migration_qs
  • has_table
  • ensure_schema
  • applied_migrations
  • record_applied
  • record_unapplied
  • flush

この中のrecord_appliedによって、マイグレーション適用時の記録が書き込こまれ、record_unappliedによってマイグレーションロールバックなど、適用されなくなったときには削除されているようだった。

雑談

今までと文体が変わったが、この書き方の方が調べ物をしたときはスムーズだったので、試しに公開してみた。 このまま継続する...かもしれないし、継続しないかもしれない。 自己学習の記録であることを考えると、このままなのかな、という気はしている。

psqlコマンドが使えなかったがPATHを追加する必要があったという話

PostgreSQLをターミナル上で動かす際にpsqlというものがあります。

このpsqlが使えなかったケースがあり、結論としてはPATHが通っていなかったという状態でした。

Mac Ventura 13.6とHomebrewは4.2.17 の環境で確認しました。

また、以下の参考記事と内容はほぼ同一です。プラスアルファでインストール先が分からなかったので調べた、という差分です。

※結果としては記事と同じ場所に配置されていました。

【Mac/PostgreSQL】psqlコマンドが使えないときの対処法

状態の確認

まずは、インストール済みのPostgreSQLのバージョンを確認します。

tatsuya@MacBook-Pro ~ % brew search postgresql
==> Formulae
postgresql@10       postgresql@12       postgresql@14 ✔     postgresql@16       postgrest
postgresql@11       postgresql@13       postgresql@15       qt-postgresql

brew searchコマンドを使うと、インストール済のpostgresqlが表示されます。

この段階で、psql --versionと入力すると実行できると思っていましたが、zsh: command not found: psqlとなり、実行できませんでした。

そこで冒頭の記事同様に手順を進めて PATH を書く必要があるので書こうとしましたが、インストール先がわからない状態でした。

解決策

【Mac】brew --prefix でパッケージのインストールパスを取得する という記事に従って、brew --prefix を実行します。

※この記事は先輩から教えてもらいました。

今回は postgresql@14のインストール先が知りたいので、入力すると、結果がわかりました。

tatsuya@MacBook-Pro ~ % brew --prefix postgresql@14
/usr/local/opt/postgresql@14

実行パスがわかったので、.zshrcexport PATH="/usr/local/opt/postgresql@14/bin:$PATH"を追加して、ターミナルを再起動するとpsqlが実行できるようになりました。

tatsuya@MacBook-Pro ~ % psql --version
psql (PostgreSQL) 14.9 (Homebrew)

マイグレーションの実行前後ではshowmigrationsを実行しよう

Djangoマイグレーションの実行状態を確認する方法として、showmigrationsがあります。

https://docs.djangoproject.com/ja/5.0/ref/django-admin/#showmigrations

コマンドは以下のように実行します。

python manage.py showmigrations

以下のように実行されているかどうか、(あるいはマイグレーションファイルが無いこと)が出力されます。

コードはこちらのリポジトリを基にしています(2024/04/06現在のコードに対して、適当にモデルを変更するよう書き加えました)。

https://github.com/nibuno/djangoTaskApp

accounts
 (no migrations)
admin
 [X] 0001_initial
 [X] 0002_logentry_remove_auto_add
 [X] 0003_logentry_add_action_flag_choices
auth
 [X] 0001_initial
 [X] 0002_alter_permission_name_max_length
 [X] 0003_alter_user_email_max_length
 [X] 0004_alter_user_username_opts
 [X] 0005_alter_user_last_login_null
 [X] 0006_require_contenttypes_0002
 [X] 0007_alter_validators_add_error_messages
 [X] 0008_alter_user_username_max_length
 [X] 0009_alter_user_last_name_max_length
 [X] 0010_alter_group_name_max_length
 [X] 0011_update_proxy_permissions
 [X] 0012_alter_user_first_name_max_length
contenttypes
 [X] 0001_initial
 [X] 0002_remove_content_type_name
sessions
 [X] 0001_initial
tasks
 [X] 0001_initial
 [X] 0002_task_title_alter_task_content
 [X] 0003_task_limit_date
 [X] 0004_task_status
 [X] 0005_task_order
 [X] 0006_alter_task_status
 [ ] 0007_alter_task_table

このコマンドを実行することで、マイグレーション適用の状態を確認することができます。

引数まわり

python manage.py showmigrations tasks のようにアプリケーションを指定することで、特定のアプリケーションのマイグレーション状態を確認できます。おそらく、指定して確認するケースは結構使うんじゃ無いかなと思います。

tasks
 [X] 0001_initial
 [X] 0002_task_title_alter_task_content
 [X] 0003_task_limit_date
 [X] 0004_task_status
 [X] 0005_task_order
 [X] 0006_alter_task_status
 [ ] 0007_alter_task_table

--list または -l と言う引数がありますが、これはデフォルトで設定されているので挙動としては変わりません。

--plan または -p と言う引数もあります。これは全体を通したマイグレーションの実行計画が確認できるようでした。

python manage.py showmigrations tasks --plan を実行すると、以下のようになります。

[X]  contenttypes.0001_initial
[X]  auth.0001_initial
[X]  tasks.0001_initial
[X]  tasks.0002_task_title_alter_task_content
[X]  tasks.0003_task_limit_date
[X]  tasks.0004_task_status
[X]  tasks.0005_task_order
[X]  tasks.0006_alter_task_status
[ ]  tasks.0007_alter_task_table

どうやら、以下のコードがマイグレーションの最初に呼び出されているようでした。

https://github.com/django/django/blob/main/django/contrib/contenttypes/migrations/0001_initial.py

また、私はリファレンスを読んで初めて存在に気が付きましたが、django-admin に共通する引数として --verbosity と言うものが存在しており、以下のように実行することで、showmigrations (--list) の場合ではマイグレーション時間を確認することができます。

-python manage.py showmigrations tasks --verbosity 2

tasks
 [X] 0001_initial (applied at 2024-02-10 23:32:44)
 [X] 0002_task_title_alter_task_content (applied at 2024-02-10 23:32:44)
 [X] 0003_task_limit_date (applied at 2024-02-10 23:32:44)
 [X] 0004_task_status (applied at 2024-02-10 23:32:44)
 [X] 0005_task_order (applied at 2024-03-07 22:37:52)
 [X] 0006_alter_task_status (applied at 2024-03-09 22:51:47)
 [ ] 0007_alter_task_table

--plan の場合だと、依存するマイグレーションについても知ることができます。

python manage.py showmigrations tasks --plan --verbosity 2

[X]  contenttypes.0001_initial
[X]  auth.0001_initial ... (contenttypes.0001_initial)
[X]  tasks.0001_initial ... (auth.0001_initial)
[X]  tasks.0002_task_title_alter_task_content ... (tasks.0001_initial)
[X]  tasks.0003_task_limit_date ... (tasks.0002_task_title_alter_task_content)
[X]  tasks.0004_task_status ... (tasks.0003_task_limit_date)
[X]  tasks.0005_task_order ... (tasks.0004_task_status)
[X]  tasks.0006_alter_task_status ... (tasks.0005_task_order)
[ ]  tasks.0007_alter_task_table ... (tasks.0006_alter_task_status)

マイグレーションを実行してみる

あとは実際に、マイグレーションを実行して結果を確認します。

マイグレーションコマンド

python manage.py migrate tasks 0007_alter_task_table

showmigrationsコマンド

python manage.py showmigrations tasks --verbosity 2

出力結果

tasks
 [X] 0001_initial (applied at 2024-02-10 23:32:44)
 [X] 0002_task_title_alter_task_content (applied at 2024-02-10 23:32:44)
 [X] 0003_task_limit_date (applied at 2024-02-10 23:32:44)
 [X] 0004_task_status (applied at 2024-02-10 23:32:44)
 [X] 0005_task_order (applied at 2024-03-07 22:37:52)
 [X] 0006_alter_task_status (applied at 2024-03-09 22:51:47)
 [X] 0007_alter_task_table (applied at 2024-04-06 00:27:38)

[] 0007_alter_task_table が実行済みだと言うことがわかります。

また、ロールバックしてみます。

マイグレーションコマンド

python manage.py migrate tasks 0006_alter_task_status

showmigrationsコマンド

python manage.py showmigrations tasks --verbosity 2

出力結果

tasks
 [X] 0001_initial (applied at 2024-02-10 23:32:44)
 [X] 0002_task_title_alter_task_content (applied at 2024-02-10 23:32:44)
 [X] 0003_task_limit_date (applied at 2024-02-10 23:32:44)
 [X] 0004_task_status (applied at 2024-02-10 23:32:44)
 [X] 0005_task_order (applied at 2024-03-07 22:37:52)
 [X] 0006_alter_task_status (applied at 2024-03-09 22:51:47)
 [ ] 0007_alter_task_table

このように、showmigrations を活用することで、マイグレーションが実行されたかどうか、を確認することができます。

自分がマイグレーション実行の有無を確認したときにセットで書いた方が良いよ、と教えてもらったことがきっかけでした。

と言うのは私はマイグレーション実行後にしか利用していなかったので、正確さ・わかりやすさといった観点から教えてもらったためでした。

自分自身、こうした方が良いな〜 と思ったのでこれからは、マイグレーションの実行前後でshowmigrationsを実行していきます。

マイグレーションで出力されるSQLを確認する方法

DjangoでDBに関連した操作をするとき、django-adminmakemigrationsマイグレーションファイルを作成して、migrateマイグレーションを実行します。

そのマイグレーション実行時に実行されるSQLを確認するには、sqlmigrateを利用します。

Django4.2で検証しました。

sqlmigrate

https://docs.djangoproject.com/ja/4.2/ref/django-admin/#sqlmigrate

コマンドは以下のようになっています。

django-admin sqlmigrate app_label migration_name

app_labelはアプリケーション名、 migration_nameマイグレーションファイルの名前です。

例えば、taskテーブルをrenameする次のようなマイグレーションファイル0008_alter_task_table.pyが生成されたとします。

# Generated by Django 4.2.9 on 2024-03-28 11:25

from django.db import migrations


class Migration(migrations.Migration):

    dependencies = [
        ("tasks", "0007_taskbackup"),
    ]

    operations = [
        migrations.AlterModelTable(
            name="task",
            table="task_rename",
        ),
    ]

その時、コマンドは以下のようになります。

django-admin sqlmigrate tasks 0008_alter_task_table

実行結果(SQLite3の場合)は次のような結果が返されます。

BEGIN;
--
-- Rename table for task to task_rename
--
ALTER TABLE "task" RENAME TO "task_rename";
COMMIT;

マイグレーションファイルについて

今回の例のマイグレーションファイル 0008_alter_task_table.py で内部で AlterModelTable が利用されています。

DBスキーマ操作用のクラスが定義されており、リファレンスで言うと以下のようなページがあります。

https://docs.djangoproject.com/ja/4.2/ref/migration-operations/#altermodeltable

定義しているモデルのテーブル名を変更します(Meta サブクラスの db_table オプションを参照します)。

と記述されており、大まかなレベルではリファレンスからどういうことをするか確認できます。

詳細に確認するには、sqlmigrateを実行したら良いんだな、という理解をしています。

SQLの定義場所

そして、SQLは以下に定義されています。

https://github.com/django/django/blob/main/django/db/backends/base/schema.py#L75

ここではこの変数をベースにSQLが発行されていたことが確認できます。

sql_rename_table = "ALTER TABLE %(old_table)s RENAME TO %(new_table)s"

また、今回はSQLite3の場合でしたが、MySQL PostgreSQLで実行できないSQLもあるので、それを吸収できるよう、それぞれのDBに合わせたディレクトリとファイル(及び変数)が存在します。

DBをMySQLに置き換えると、以下のようになります。

--
-- Rename table for task to task_rename
--
RENAME TABLE `task` TO `task_rename`;

コードは以下です。

https://github.com/django/django/blob/main/django/db/backends/mysql/schema.py#L7

sql_rename_table = "RENAME TABLE %(old_table)s TO %(new_table)s"

余談

何回か取り上げたSQLを確認しよう、というシリーズの延長線上になりましたが、Django任せにせず、どういう風にDBを操作しているかを確認するのも大事だなと思いました。

Djangoマイグレーションファイルも「Djangoがよしなに生成してくれるもの」くらいの認識でいましたが、マイグレーション用のクラスがあったことや、その裏側でSQLも定義されていることを改めて認識できて、世界が広がって面白いなぁと思いました。