地下ペディアを Docker コンテナに入れてみた

Docker を勉強する上で、とりあえず何パターンか作業をしたいと思い、いくつかやってきました。

で、次は既にある程度作り込まれているものをコンテナに入れてみようと思い、過去に自分が作った物で企業との面談等でも触れていただくことの多い「地下ぺディア」を使ってやってみることにしました。

ゴールとしてはとりあえず Docker イメージを run してブラウザで 127.0.0.1:8000 を開けば地下ぺディアが使える様にします。

  1. 地下ぺディアとは
  2. Dockerfile を作る
  3. docker build & docker run
  4. 動作確認
  5. ローカルで修正して build & run

1. 地下ぺディアとは

そもそも「地下ぺディア」とは、自然言語処理の技術の一つである形態素解析を使って、ウィキペディアの記事をカイジっぽい文体で表示する Web アプリです。

フレームワークに Django、形態素解析には CaboCha を使用しており、任意のウィキペディアページの HTML ソースを解析、HTML 要素を崩さずに文体を変更し HTTP レスポンスとして返す様になっています。

元々は「ウィキペディア記事を元に自由ミルクボーイの漫才を作れたら。。」と思い立ったものの難しそうだったのでひとまず地下ぺディアという形にしたという経緯があります。

2. Dockerfile を作る

元々地下ぺディアのファイル群があるディレクトリに Dockerfile を作成します。

ベースイメージとしてはこちらの記事で作成した、Python で CaboCha を使える様にしたものを使います。

FROM docker_nlp:1.0

# ファイルを全てコピーし、requirements.txt で pip install を実施
WORKDIR /app
COPY . .
RUN pip3 install -r requirements.txt

# コンテナ外からのアクセスを可能にするため 0.0.0.0 番で runserver を実行
# 開発環境用の settings_dev.py を使用
CMD ["python3", "chikapedia/manage.py", "runserver", "0.0.0.0:8000","--settings=chikapedia.settings_dev"]

CMD の部分で Django の runserver を実行する様記述していますが、「0.0.0.0:8000」としてコンテナ外(つまりホストから)からのアクセスを受け付ける様にし、「--settings=chikapedia.settings_dev」で開発環境用の settings.py を使用できる様にしています。

あとはいつも通りです。

3. docker build & docker run

% docker build -t chikadocker:1.0 .
% docker run --name chikapedia-docker -p 8000:8000 -it chikadocker:1.0
Watching for file changes with StatReloader
Performing system checks...

System check identified no issues (0 silenced).

You have 18 unapplied migration(s). Your project may not work properly until you apply the migrations for app(s): admin, auth, contenttypes, sessions.
Run 'python manage.py migrate' to apply them.
May 19, 2023 - 02:22:08
Django version 3.2.4, using settings 'chikapedia.settings_dev'
Starting development server at http://0.0.0.0:8000/
Quit the server with CONTROL-C.

未 migrate のマイグレーションに関する警告が出ますが、地下ぺディアはデータベースを使わないので無視します。

run は無事完了し、Django のサーバーもコンテナ内「0.0.0.0:8000」で立ち上がりました。

4. 動作確認

果たして動くのか?ローカル PC のブラウザで 127.0.0.1:8000 にアクセスしてみます。

無事動きました!

5. ローカルで修正して build & run

バグ修正時の対応としては

  1. ローカルでコード修正
  2. docker build でイメージ更新
  3. docker rm でコンテナ削除
  4. docker run でコンテナ作成
  5. 動作確認

といった流れで修正と確認を繰り返しました。

Django アプリを Docker イメージに入れる

勉強がてら、Django を Docker イメージに入れてみます。

Django は最低限の部分のみで、ロケットの画面が表示できれば OK とします。

  1. Python 仮想環境に Django をインストール
  2. Django プロジェクトを作る
  3. requirements.txt を作る
  4. Dockerfile を作る
  5. .dockerignore を作る
  6. docker build でイメージ作成
  7. docker run でイメージ実行

Python 仮想環境に Django をインストール

% python3 -m venv venv
% source venv/bin/activate
(venv) % pip install django

Django プロジェクトを作る

とりあえず最低限、あのロケットの画面の表示だけする様にします。

(venv) % django-admin startproject core
(venv) % python core/manage.py runserver
(venv) % ls                                                          
core                    venv

一旦 Django はここまで。

requirements.txt を作る

(venv) % pip freeze > requirements.txt
(venv) % ls                                                          
core                    requirements.txt        venv
# requirements.txt
asgiref==3.6.0
backports.zoneinfo==0.2.1
Django==4.2.1
sqlparse==0.4.4

Django と requirements.txt を作ったところで Docker の作業へ移っていきます。

Dockerfile を作る

今回は「python:3.8.3-slim-buster」のイメージをベースにして Dockerfile を作成します。

FROM python:3.8.3-slim-buster

WORKDIR /app

COPY requirements.txt .
RUN pip3 install -r requirements.txt

COPY . .

# コンテナ外からのアクセスを可能にするため 0.0.0.0 番で runserver を実行
CMD ["python3", "manage.py", "runserver", "0.0.0.0:8000"]

「COPY . .」の部分で、ローカルの作業フォルダ配下を全てコンテナの作業フォルダ(/app)にコピーします。この部分で Django の関連ファイルも全てコピーされます。

(venv) % ls                                                          
Dockerfile              core                    requirements.txt        venv

/venv 配下のみ、次の .dockerignore で除外設定をします。

.dockerignore を作る

「venv」配下をイメージに含めない様「.dockerignore」に追加します。

# .dockerignore
venv

docker build でイメージ作成

(venv) % docker build -t dockerdjango:1.0 .
(venv) % docker images
REPOSITORY     TAG       IMAGE ID       CREATED          SIZE
dockerdjango   1.0       9c4fe787bc1d   45 seconds ago   205MB

docker run でイメージ実行

「docker run」を実行します。

(venv) % docker run --name dj_dk -p 8000:8000 dockerdjango:1.0

個人的なメモ

  • 「-p 8000:8000」と明示的にポートフォワーディングを設定しないとコンテナが孤立する。
  • たとえホスト側とコンテナ側で使うポート番号が一致していても、明示的に設定しないとホスト 8000 番へのアクセスがコンテナ 8000 番へ転送されない。

下記を実行してブラウザで「127.0.0.1:8000」へアクセスします。

とりあえずロケットの画面は表示されました。

「docker exec」でコンテナの中身を確認します。

(venv) % docker exec -it dj_dk bash
root@40ac730cceba:/app# ls
Dockerfile  core  requirements.txt

「.dockerignore」で指定した venv はきちんと除外されています。

Ubuntu でのログ肥大化で Django が 502 エラー

Django アプリで 502 エラー

VPS でホストしている Django アプリの挙動がおかしくなりました。

トップページには問題なくアクセス出来るものの、ログインをしようとすると「502 Bad Gateway」のエラーが発生。

どれか一つのアプリではなく、同じサーバーでホストしている複数のアプリで同じ挙動(トップページは表示されるがログイン機能で 502 エラー)が出たので調べました。

サーバーの容量が限界に近い

Ubuntu にログインした所、下記の様に表示されました。

Welcome to Ubuntu 20.04.2 LTS (GNU/Linux 5.4.0-137-generic x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/advantage

  System information as of Thu Mar 30 17:12:11 JST 2023

  System load:  0.02               Processes:             142
  Usage of /:   95.0% of 94.40GB   Users logged in:       0
  Memory usage: 35%                IPv4 address for ens3: 133.125.39.81
  Swap usage:   28%

  => / is using 95.0% of 94.40GB

「Usage of /: 95.0% of 94.40GB」との表示で、どうやら容量の 95.0 % を使ってしまっているらしい。

原因はログファイルの肥大化

「sudo du -sh /* | sort -hr」でルートディレクトリ直下をチェック。

$ sudo du -sh /* | sort -hr
du: cannot access '/proc/905133/task/905133/fd/4': No such file or directory
du: cannot access '/proc/905133/task/905133/fdinfo/4': No such file or directory
du: cannot access '/proc/905133/fd/3': No such file or directory
du: cannot access '/proc/905133/fdinfo/3': No such file or directory
85G	/var
4.2G	/usr
701M	/home
306M	/boot
7.9M	/root
6.6M	/etc
728K	/run
56K	/tmp
16K	/lost+found
8.0K	/srv
4.0K	/opt
4.0K	/mnt
4.0K	/media
4.0K	/cdrom
0	/sys
0	/sbin
0	/proc
0	/libx32
0	/lib64
0	/lib32
0	/lib
0	/dev
0	/bin

なるほど「/var」で 85 GB も使っていると。

$ sudo du -sh /var/* | sort -hr
81G	/var/log
2.7G	/var/lib
1.3G	/var/www
103M	/var/cache
2.4M	/var/spool
2.1M	/var/backups
36K	/var/tmp
4.0K	/var/opt
4.0K	/var/mail
4.0K	/var/local
4.0K	/var/crash
0	/var/run
0	/var/lock

「/var/log」で 81 GB か。

$ sudo du -sh /var/log/* | sort -hr
45G	/var/log/syslog
31G	/var/log/syslog.1
4.1G	/var/log/journal
325M	/var/log/syslog.2.gz
193M	/var/log/syslog.5.gz
190M	/var/log/syslog.6.gz
188M	/var/log/syslog.7.gz
164M	/var/log/syslog.4.gz
159M	/var/log/syslog.3.gz
126M	/var/log/btmp
122M	/var/log/btmp.1
104M	/var/log/nginx
27M	/var/log/auth.log.1
21M	/var/log/auth.log
2.7M	/var/log/auth.log.4.gz
2.1M	/var/log/auth.log.3.gz
2.0M	/var/log/auth.log.2.gz
1.4M	/var/log/mail.log.1
1.3M	/var/log/mail.log
776K	/var/log/vsftpd.log

「/var/log/syslog」と「/var/log/syslog.1」が大きい。

けどこれって定期的に gz に圧縮されているのか?

ログファイルの容量を下げてみる

とりあえず「sudo logrotate -f /etc/logrotate.conf」を実行し、logrotate を強制的に実行してみました。

$ sudo logrotate -f /etc/logrotate.conf
error: destination /var/log/mysql/error.log.1.gz already exists, renaming to /var/log/mysql/error.log.1.gz-2023040500.backup

上記の様にエラーメッセージは出たものの、「syslog.1.gz-2023031800.backup」が作成され、サーバーにログインし直すと容量に空きができました。

「Usage of /: 30.9% of 94.40GB」となっています。

Welcome to Ubuntu 20.04.2 LTS (GNU/Linux 5.4.0-137-generic x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/advantage

  System information as of Wed Apr  5 07:34:52 JST 2023

  System load:  0.3                Processes:             140
  Usage of /:   30.9% of 94.40GB   Users logged in:       0
  Memory usage: 46%                IPv4 address for ens3: 133.125.39.81
  Swap usage:   22%

Django アプリ復活で解決

ログイン機能が 502 エラーを返していた状況はなくなり、問題なく動く様になりました。

原因は Django のコードに print 文がコメントアウトされずに残っていて、自然言語処理に関する冗長なログがそのまま Gunicorn 経由で出力されていたためでした。

Fetch API と Django のクロスオリジンで気をつける事

個人的に開発する中で、localhost:8080 でホストしている javascript ファイルから localhost:8000 でホストしてる Django に対し HTTP リクエストをかけたところ、CORS (Cross Origin Resource Sharing) 関連のエラーが色々出ました。

色々調べながら解決しましたが、自分なりに分かりやすく整理したかったのでこの記事にまとめました。

Javascript からの HTTP リクエスト には fetch API を使っています。

  1. CORS (Cross Origin Resource Sharing) とは
  2. Step 1: GET
  3. Step 2: request に Cookie を含める
  4. Step 3: POST や DELETE

CORS (Cross Origin Resource Sharing) とは

下記こちらからの抜粋です。

アプリケーションが読み込まれたのと同じオリジンに対してのみリソースのリクエストを行うことができ、それ以外のオリジンからの場合は正しい CORS ヘッダーを含んでいることが必要です。

オリジン間リソース共有の仕様は、ウェブブラウザーから情報を読み取ることを許可されているオリジンをサーバーが記述することができる、新たな HTTP ヘッダーを追加することで作用します。

Step 1: GET

まずは一番単純な GET から。Cookie も送らない想定です。

何もクロスオリジン対応をしていないと恐らく「No 'Access-Control-Allow-Origin' header is present on the requested resource.」という様なエラーが発生します。

クロスオリジンの request に対してサーバー側から Access-Control-Allow-Origin ヘッダーを適切に返す必要があります。

Fetch API での対応

Fetch API は普通に fetch() で request URL を叩けば大丈夫です。

let fetchResponse = await fetch(
    'http://localhost:8000/api/something'
)

メインはサーバー側(Django)での対応です。

Django での対応

django-cors-headers を pip install し、settings.py を下記のように追記して http://localhost:8080 からのリクエストを許可します。

pip install django-cors-headers
# settings.py

INSTALLED_APPS = [
   ...
   'corsheaders',
]

MIDDLEWARE = [
  ...
  'corsheaders.middleware.CorsMiddleware',
]

CORS_ALLOWED_ORIGINS = ['http://localhost:8080']

これでクロスオリジンの request を送れる様になります。(localhost:8080 → localhost:8000)

次に、request に cookie を含める設定を行います。

クロスオリジンでの fetch API は「デフォルトでは cookies を送信しない」仕様になっています。

これを解決するには fetch API の credentials パラメータを 'include' に設定し、サーバー側は Access-Control-Allow-Credentials ヘッダーを true で返す必要があります。

Fetch API での対応

fetch API のオプションに credentials: 'include' を含めます。

let fetchResponse = await fetch(
    'http://localhost:8000/api/something',
    {credentials: 'include'} // 追記分
)

Django での対応

settings.py に「CORS_ALLOW_CREDENTIALS = True」を追記します。

# settings.py
CORS_ALLOW_CREDENTIALS = True

これで Fetch API で Cookie が送れる様になり、Django 側もそれを受けられる様になりました。

Django でログイン済みのはずが AnonymousUser となる件

ちなみにログインユーザーを対象にした処理を Django 側で行う場合、上記の設定をしていないと、例えブラウザの cookie と Django の django_session テーブルで sessionid が一致していても 500 Internal Server Error や 403 Forbidden が発生すると思います。

理由は、クライアント側からの sessionid を request.COOKIES として受け取れていないため、request.user が非ログインユーザーを意味する AnonymousUser となるためです。

Step 3: POST や DELETE

さらに、クロスオリジンで POST や DELETE など unsafe な request を送る場合、クライアント側の fetch() に X-CSRFToken ヘッダーを設定し、Django 側では settings.py の CSRF_TRUSTED_ORIGINS のリストに request を許可するオリジンを記述する必要がります。

Fetch API での対応

let fetchResponse = await fetch(
    'http://localhost:8000/api/something',
    {credentials: 'include'},
    // ここから追記分
    method: 'POST',
    headers: {
        'X-CSRFToken': csrftoken, // Cookie から取得
        'Content-Type': 'application/json'
    },
    body: JSON.stringify({
        'someKey1': someData1,
        'someKey2': someData2
    }
)

GET メソッドは fetch のデフォルト値でしたが、POST や他のメソッドを使用する場合は method を指定する必要があります。

また、headers に X-CSRFToken として cookie から取得した csrftoken をアサインし、Content-Type も指定する必要があります。

Django での対応

settings.py の CSRF_TRUSTED_ORIGINS のリストに request を許可するオリジンを記述します。

# settings.py
CSRF_TRUSTED_ORIGINS = ['http://localhost:8080']

【Django】Apple MusicKit JS で Apple Music サインインの問題発生

事象

まず、MusicKit JS でプレイヤーを設置し曲をフル再生しようとすると Apple Music へのサインインが必要となります。

その際の正常なフローとしては:

  1. 自動ポップアップでサインイン画面が表示される
  2. ユーザーID & パスワード、そして6 桁のワンタイムコードを入力してサインイン
    • Mac の Safari では指紋認証のみでサインイン可
  3. アプリケーション(オリジン毎)による Apple Music へのアクセス許可を求められる
  4. 「許可」ボタンのクリックするとポップアップ画面が閉じる
  5. アプリケーションで曲のフルバージョンが再生できるようになる

今回、上記 4 の「許可」ボタンをクリックしてもボタンがグレーアウトされるだけで何も変わらない事象が起きた。

ポップアップウィンドウは閉じず、曲も再生されない。

もう一度曲を再生しようとしても再度別のポップアップが開いてサインインを要求されるという状況でした。

ちなみにサインインされてない状態だとフルバージョンではなく 30 秒バージョンの再生になります。

問題 1:「許可」ボタンクリックから進まない

まず、上記ステップで「許可」ボタンをクリックしてもボタンがグレーアウトするだけでその先に進まなかった問題から。

原因:Referrer-Policy の設定漏れ

諸々調べた結果、原因としてボタンクリック時の HTTP request に Referer 情報が含まれていなかった事が挙げられます。

Django ではデフォルトの Referrer-Policy が same-origin となりますが、same-origin の挙動は「同一オリジンのリクエストではオリジン、パス、クエリー文字列を送信します。オリジン間リクエストでは Referer ヘッダーを送信しません。」となっています。

つまり Apple の認証システムへの request 時に Referer ヘッダーが送信されず、その後の処理が行われなかったと思われます。

Django での Referrer-Policy 変更方法

Django で Referrer-Policy を変更するには settings.py で SECURE_REFERRER_POLICY を設定する必要があります。

おそらく「SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"」で良いと思います。

残る問題

これで「許可」ボタンをクリックすると、ポップアップウィンドウの中でアプリが表示されました。

仕方なくその中で曲を再生してみようとするとまた別のポップアップウィンドウが開きサインインを求められるループに入りました。

一旦グレーアウトで止まる問題は解消されましたが、別の問題が発生しただけです。

問題 2:曲のフル再生ができない

「許可」ボタンクリック後ポップアップウィンドウが閉じない問題はさておき、曲が再生できないのはなぜなのかを調べました。

原因 1/2:musicUserToken が付与されない

デベロッパーツールで request/response の内容を確認した結果、「許可」ボタンをクリックした際の Apple 側からの response で musicUserToken を受け取っているにもかかわらず、それがこちら側の musicKitInstance に渡っていないことが判明。

正常時はブラウザの Local Storage に保存され、無事認証が完了した musicKitInstance をデベロッパーツールで覗くと下記のように musicUserToken が入っているはずですが、今回はこれが「undefined」となっていました。

ちなみにコード上で半ば無理やり下記のようにアサインすると曲の再生ができていました。(music は musicKitInstance)

music.musicUserToken = "AoSKv/b0ED6YZzVuAEIvki4eOgFgeQYrCPaU+KSFV7fFdEozGUawOuKXxrzGyISRlHPfJlOzkclA+Nk4I0SbLI/f0tiZ++a+QYOG3EP+d935PvL+udndhJjfG/xe+ctry69X/rTtqgdr2VRCbqMgt/xzocg7gg2w/QPuTcA7YSpevglys3/2AsC69ofZKl8fHKkp04dyLuhxVZOC2h4PGXc+6chmnSHIxo7tp/VTv+IWr8+fhQ=="

原因 2/2:ポップアップ起動直前の URL が「#」で終わっている

正直かなり特殊な例かもしれませんが、自分のアプリ上、曲のタブを表示するボタンに a タグを使用し href="#" としていました。

つまり、曲を再生する前にこれをクリックするとメインウィンドウの URL が「http://localhost:8000/#」という形になりますが、これが良くなかったようです。

サインイン用のポップアップが開いて「許可」ボタンをクリックした後、ポップアップウィンドウの URL が更新されますが、その際メインウィンドウの URL と musicUserToken が「#」で繋がったものが入ります。

「http://localhost:8000/#ユーザートークン」の形で「#」につづけて musicUserToken が入ってくるんですが、上記で先に「#」が一つ入るため「http://localhost:8000/##ユーザートークン」という風に「#」が重複していました。

これが原因なのではと思い「#」を一つ消してブラウザ更新すると musicKitInstance に musicUserToken がアサインされ、URL からトークンの部分が消えました。(http://localhost:8000/ になる)

解決:URLに「#」を含まない様変更

アプリのコードを変更して「#」が付かないようにしたところ、「許可」ボタンクリックで musicUserToken は付与されるようになりました。

残る問題

ただ、メインウィンドウに戻らずポップアップ上にそのままアプリが表示される状況です。

その状態で曲はフル再生できますが、ポップアップが閉じてくれないのは問題です。

正直これに関しては解決できませんでした。

Django のテンプレートをそのまま Django の外に置いて python の http.server の Web サーバーで表示したところ無事にサインインできました。

なぜ内容が同じなのに Django で render されたものだとポップアップの挙動がおかしくなるのかは謎です。

と言うわけで、結局 html/css/js の部分は Django を通さず、API 部分だけ Django で書くことにしました。

それによってクロスオリジンのエラーを解決する必要がありましたが今のところ大丈夫そうです。

その他メモ書き

  • musicUserToken はオリジン毎の割り当て。
    • 127.0.0.1:8000 で取得した musicUserToken は localhost:8000 には共有されない。
    • 同じ http://127.0.0.1:8000 であれば http.server を使った html/js でも Django の runserver を使った Django テンプレートでも同じ musicUserToken で動く。

Javascript から Django REST Framework への POST/DELETE で 415 Unsupported Media Type

問題

Django REST Framework を使って API を作成中、Javascript から POST または DELETE リクエストを送ろうとした結果「POST(リクエスト先 URL) 415 (Unsupported Media Type)」のエラー表示。

*DELETE の場合も同じエラーが出ます。

原因

リクエストヘッダーに Content-Type がなかったのが原因。

解決方法

元々↓だったヘッダー部分に…

fetch('http://example.com/exampleapi',
    {
        method: 'POST',
        headers:{
            "X-CSRFToken": getCookie('csrftoken')
        body: JSON.stringify({ 'username': 'example'})
    }
)

↓の様に Content-Type を追加したら解決しました。

fetch('http://example.com/exampleapi',
    {
        method: 'POST',
        headers:{
            "X-CSRFToken": getCookie('csrftoken'),
            'Content-Type': 'application/json'} // 追記分
        body: JSON.stringify({ 'username': 'example'})
    }
)

DELETE の場合も同じ方法で解決しました。

Javascript から Django REST Framework への POST/DELETE で 403 Forbidden

問題

Django REST Framework を使って API を作成中、Javascript から POST または DELETE リクエストを送ろうとした結果「POST(リクエスト先 URL) 403 (Forbidden)」のエラー表示。

*DELETE の場合も同じエラーが出ます。

原因

CRSF (Cross Site Request Forgery) に対するセキュリティが働いて、リクエストが拒否されている様子。

解決法

ブラウザのクッキーから CSRF Token を取って、POST リクエストのヘッダーに含めたら解決しました。

元々↓だったところに…

const fetchOptions = {
    method: 'POST'
}
fetch('http://example.com/exampleapi',fetchOptions)

クッキーから csfrtoken を抽出して headers に含めて POST リクエストをする様にしました。

function getCookie(name) {
    let cookieValue = null;
    if (document.cookie && document.cookie !== '') {
        const cookies = document.cookie.split(';');
        for (let i = 0; i < cookies.length; i++) {
                const cookie = cookies[i].trim();
                if (cookie.substring(0, name.length + 1) === (name + '=')) {
                cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
                break;
            }
        }
    }
    return cookieValue;
}

const fetchOptions = {
    method: 'POST',
    headers:{"X-CSRFToken": getCookie('csrftoken')}
}
fetch('http://example.com/exampleapi',fetchOptions)

これで一応 POST リクエストが通る様になりました。(HTTP Status 200 が返ってきました。)

DELETE の場合も同じ方法で解決しました。

参考にした記事

【Django】カスタム User モデルの作成方法

Django プロジェクトを作る際、もしデフォルトの User モデルで機能が足りている場合でも、将来の保守性や変更の必要性が出た場合を考慮して、カスタムの User モデルを使用することが強く推奨されています。

また、この作業 Django プロジェクトを作成して最初の migrate 実行前に行う必要があります。

  1. ユーザー管理用アプリケーションの作成
  2. カスタム User モデルの作成
  3. カスタム User モデルの登録
  4. マイグレーション

参考資料

ユーザー管理用アプリケーションの作成

account という名前でアプリケーションを作ります。

% python manage.py startapp account
# settings.py

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'account.apps.AccountConfig', # 追記
]

カスタム User モデルの作成

# account/models.py

from django.contrib.auth.models import AbstractUser

class User(AbstractUser):
    pass

カスタム User モデルの登録

# settings.py

AUTH_USER_MODEL = 'account.User'

尚、この設定をした時の注意点として、django.contrib.auth.models の User を直接参照すると AUTH_USER_MODEL で設定した User モデルを参照できないそうです。

なので User モデルを参照する必要がある場合は django.contrib.auth.get_user_model() を使用する必要があります。

# account/admin.py

from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from .models import User # models.py のカスタム User をインポート

admin.site.register(User, UserAdmin)

マイグレーション

まず、新たに作成した「account」アプリケーションのマイグレーションを作成します。

% python manage.py makemigrations account
Migrations for 'account':
  account/migrations/0001_initial.py
    - Create model User

マイグレーションを実行します。

% python manage.py migrate
Operations to perform:
  Apply all migrations: account, admin, auth, contenttypes, sessions
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying contenttypes.0002_remove_content_type_name... OK
  Applying auth.0001_initial... OK
  Applying auth.0002_alter_permission_name_max_length... OK
  Applying auth.0003_alter_user_email_max_length... OK
  Applying auth.0004_alter_user_username_opts... OK
  Applying auth.0005_alter_user_last_login_null... OK
  Applying auth.0006_require_contenttypes_0002... OK
  Applying auth.0007_alter_validators_add_error_messages... OK
  Applying auth.0008_alter_user_username_max_length... OK
  Applying auth.0009_alter_user_last_name_max_length... OK
  Applying auth.0010_alter_group_name_max_length... OK
  Applying auth.0011_update_proxy_permissions... OK
  Applying auth.0012_alter_user_first_name_max_length... OK
  Applying account.0001_initial... OK
  Applying admin.0001_initial... OK
  Applying admin.0002_logentry_remove_auto_add... OK
  Applying admin.0003_logentry_add_action_flag_choices... OK
  Applying sessions.0001_initial... OK

無事マイグレーション完了です。

【Mac】Django アプリ作成 & MySQL 初回 migrate まで

  1. Python 仮想環境の作成
  2. Django プロジェクト&アプリケーションの作成
  3. MySQL へ初回 migrate
  4. 管理ユーザーの作成

Python 仮想環境の作成

任意の場所に Python 仮想環境を作成、起動します。

% python3 -m venv justjam
% cd justjam
% source bin/activate

Django プロジェクト&アプリケーションの作成

仮想環境内で django をインストール、そしてプロジェクトとアプリケーションを作成します。

% pip install --upgrade pip
% pip install django
% django-admin startproject justjam_proj
% cd justjam_proj
% python manage.py startapp api
# settings.py

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'api.apps.ApiConfig', # 追記分
]

runserver を実行し、ブラウザで動作確認します。

% python manage.py runserver

MySQL へ初回 migrate

注)カスタムユーザーモデルを使用する場合は、以下のステップに進む前に設定を完了しておく必要があります。

settings.py の DATABASES を変更し MySQL 対応にします。

# settings.py
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.mysql',
        'NAME': 'justjam',
    }
}

MySQL でデータベースを作成し、migrate を実行します。

% mysql
mysql> create database justjam;
mysql> exit
% pip install mysql
% python manage.py migrate

Django のテーブルが作成されていることを確認します。

% mysql
mysql> show tables
    -> ;
+----------------------------+
| Tables_in_justjam          |
+----------------------------+
| auth_group                 |
| auth_group_permissions     |
| auth_permission            |
| auth_user                  |
| auth_user_groups           |
| auth_user_user_permissions |
| django_admin_log           |
| django_content_type        |
| django_migrations          |
| django_session             |
+----------------------------+
10 rows in set (0.00 sec)

mysql> exit;

管理ユーザーの作成

% python manage.py createsuperuser
Username (leave blank to use 'username'): admin
Email address: admin@justjam.com
Password: 
Password (again): 
Superuser created successfully.

取り急ぎ Django でログイン機能を作った方法(クラスベースビュー)

とりあえず Django のクラスベースビューでログイン機能を追加した時の内容をまとめます。

  1. views.py で LoginRequiredMixin の継承
  2. forms.py でログインフォームを作成
  3. views.py で ログインビューの作成
  4. urls.py でパスと view の紐付け
  5. ログイン用テンプレートの作成
  6. ログインの動きのまとめ
  7. ログアウトページの作成

views.py で LoginRequiredMixin の継承

非ログインユーザーに表示させたくない View 全てで LoginRequiredMixin を継承します。

複数のクラスを継承している場合、下記の例の様に必ず一番目に記述します。

# views.py
class ClassList(LoginRequiredMixin, generic.ListView):
    model = ClassMaster
    template_name = 'class_list.html'

class ClubList(LoginRequiredMixin, generic.ListView):
    model = ClubMaster
    template_name = 'club_list.html'

非ログインユーザーが LoginRequiredMixin を継承したビューに当たった場合、デフォルトで accounts/login/ にリダイレクトされますが、下記の様に settings.py に明示的に LOGIN_URL としてリダイレクト先を設定することも可能です。

# settings.py
LOGIN_URL = 'http://ドメイン名/login' # 一例です。

上記いずれかのパスでログインビューを表示する様、後ほど設定します。

また、リダイレクトされる際、元々アクセスしようとしていたパスを next パラメータとして URL に保持した状態で飛んでいきます。これも後ほどログイン後のリダイレクトに利用します。

forms.py でログインフォームを作成

AuthenticationForm クラスを継承してフォームを作成します。とりあえず username と password のみ認証対象とします。

# forms.py

from django.contrib.auth.models import User
from django.contrib.auth.forms import AuthenticationForm

class LoginForm(AuthenticationForm):
    class Meta:
        model = User
        fields = ['username', 'password']

views.py で ログインビューの作成

ログイン用のビューは LoginView クラスを継承して作成します。

ログインフォームの表示と、ユーザーから submit されたログイン情報の認証、そしてユーザーが元々アクセスしようとしていたページへのリダイレクト等を担ってくれます。

# views.py

from django.contrib.auth.views import LoginView

class MyLoginView(LoginView):
    form_class = LoginForm

template_name を指定しなくてもデフォルトで registration/login.html になっているので上記の例では明記していませんが、他の view 同様 「template_name = 'login.html'」の様に設定することも可能です。

また、LoginView クラスは POST 時に next URL を渡すとログイン後その URL にリダイレクトしてくれます

"If called via POST with user submitted credentials, it tries to log the user in. If login is successful, the view redirects to the URL specified in next. If next isn’t provided, it redirects to settings.LOGIN_REDIRECT_URL (which defaults to /accounts/profile/). If login isn’t successful, it redisplays the login form."

後ほどテンプレートを作成する際に form からの POST 時に next 渡す様設定します。

urls.py でパスと view の紐付け

アプリケーションレベルの urls.py にログインページのパスとログイン用 view の紐付けを記載します。

# urls.py

from django.urls import path
from . import views

app_name = 'sample_app'
urlpatterns = [
    #〜省略〜
    path('accounts/login/', views.MyLoginView.as_view(), name="login"),
    #〜省略〜
]

ログイン用テンプレートの作成

作成したログインフォームを表示するテンプレートを作ります。

Form 部分の記述は下記の様な感じ。

<!--login.html-->
 
<form action="{% url 'sample_app:login' %}" method="post">
    {% csrf_token %}
        {{ form.as_p }}
        <p><input type="hidden" name="next" value="{{next}}"></p>
        <p><input type="submit" value="ログイン"></p>
    </form>

いくつかポイントがあります。

  • ログインフォーム自身に対して POST する。
  • input の一つとして、リダイレクトされた時に持ってきた next URL を裏で取っておく(type="hidden" で非表示にしています)。

こうしておくことで、ログイン成功時に LoginView がリダイレクトしてくれます。

ログインの動きのまとめ

というわけで、ログイン部分を一通り作りました。

非ログインユーザーがアクセスした場合、下記の様な流れになります。

  1. 非ログインユーザーがページへアクセスしようとする。
  2. LoginRequiredMixin によって、アクセスしようとしていたページのパスを next に保持したままログインページ(LoginView)へリダイレクトされる。
  3. リダイレクト先でのログインフォームでユーザー名とパスワードを Submit する際、next も一緒に LoginView に対して POST する。
  4. 認証に問題がなければ、ログインされ、next のパスにリダイレクトされる。

ログアウトページの作成

一応ログアウト部分もやっつけで書いておきます。

サイトのヘッダー部分など、どこかしらにログアウト用リンクを作って、ログアウトビューとつなげる感じです。

views.py

LogoutView クラスを継承したビューを作成し、任意の template_name を指定します。

# views.py

class MyLogoutView(LogoutView):
    template_name = 'テンプレート名.html'

urls.py

ログアウト用のパスとログアウト用の view を繋げるだけです。

# urls.py

from django.urls import path
from . import views

app_name = 'sample_app'
urlpatterns = [
    #〜省略〜
    path('accounts/login/', views.MyLoginView.as_view(), name="login"),
    path('accounts/logout/', views.MyLogoutView.as_view(), name="logout"),
    #〜省略〜
]

テンプレート

テンプレートでは、任意の場所にハイパーリンクを置いてログアウト用 URL に飛ぶ様にすれば大丈夫です。