Flutter×Djangoマッチングアプリ個人開発記事
マッチングアプリを個人開発する~Djangoバックエンドその1 環境構築~
はじめに
マッチングアプリはWebアプリの基本を学べるよい教材だと感じるので、バックエンドにDjango・フロントエンドにFlutterを利用して個人開発で自作してみようと思います
なお実運用したい場合は有償無償を問わず「インターネット異性紹介業」の開業を警視庁に申請しなければならないのと、免許証もしくはクレジットカードを必ず扱うため個人情報の扱いを厳重に行う必要があります
実装とは別に運用面でハードルがありますが、マッチングアプリの自作に挑戦してみたい方の参考になれば幸いです
また、手前味噌で恐縮ですが、この記事はかなり詳しめに書いていまして、初学者にとってはかゆいところに手が届く内容にもなっているので、バックエンド・フロントエンド・デプロイにかけてのフルスタックなのWEBアプリ開発を一通り学んでみたい方にも特におすすめです
想定読者としては、プログラミングを一通り学んでおり、WEBアプリの仕組みについてもなんとなくわかるが、具体的な開発についてはどこから手を付けていいかわからないという人が最適かと思います
マッチングアプリはおそらく実運用が最も肝心なので開発は一つの通過点に過ぎないです
パパっと作ってスモールスタートする方がよさそうな気はしますね
では始めましょう
※おしらせ
近々記事のお引越しを行います。
しばらく関連記事のアクセスができない可能性がありますが、ご了承ください。
目次
- はじめに
- ※おしらせ
- 要件定義
- 開発環境
- バックエンド開発 Django
- 次回予告
- アーキテクチャ設計
- Djangoの初期設定~設定項目の編集~
- Modelの実装
- 管理者Adminサイトの整備
- 次回予告
- 今後の課題
- Serializersの実装
- Viewsの実装
- URL ルーティングを設定する
- 次回予告
- 動作確認
- 確認するエンドポイントの整理
- ユーザー新規作成とJWT認証(/api/authen/jwt/createと/api/users/create/)
- ログイン中のユーザー情報の取得と更新(/api/users/{pk}/)
- ログイン中のユーザー情報の取得と更新(/api/users/profile/{pk}/)
- ログイン中のプロフィール新規作成と異性プロフィール一覧取得(/api/profiles/)
- プロフィール取得(/api/profiles/{pk})
- マッチングデータ作成・マッチング一覧取得(CREATE, GET /api/favorite/ )
- マッチングデータ取得・更新(GET, PATCH /api/favorite/{pk})
- DM送信側処理(/api/dm-message/ )
- DM受信側一覧取得(GET /api/dm-message/, /api/dm-message/{pk})
- トークン期限切れ確認
- 次回予告
- 決済機能のStripeについての概要
- アーキテクチャ設計
- 各種機能を使用するための設定と環境変数を実装する
- Modelを実装する
- ルーティングURLの実装をする
- Viewを実装する
- 動作確認
- 次回予告
- はじめに
- おしらせ
- Flutterとは
- Flutter環境構築
- 開発環境
- Flutter プロジェクトを作成する
- Flutterでコードを実装していく
- ログイン機能の実装
- 自己プロフィール
- プロフィール一覧閲覧機能&いいね機能の実装
- メッセージ機能の実装
- 終わりに
要件定義
まずは作る目的、業務要件、機能要件、システム構成をざっくり整理しましょう
開発プロジェクトの最も基本であり、目的が重要であればあるほど作るモチベーションにも影響します
目的
自作マッチングアプリでモテモテになる
達成条件
自作マッチングアプリで集客した女性100人と出会う
業務要件
Must
本サービスに認可・認証されたユーザーのみが利用することができる
年齢確認をすることができる
ユーザーは異性ユーザーのプロフィールを一覧で閲覧することができる
ユーザーは異性ユーザーに「いいね」を送ることができる
ユーザー双方が「いいね」を送り合うと「マッチング」が成立する
マッチングが成立したユーザー同士はメッセージを使用することができる
Try
本人確認をすることができる
ユーザーはユーザーごとにレコメンドされた異性のプロフィールを一覧画面で閲覧することができる
機能要件
Must
ログイン認証機能
クレジットカード決済ができる
WEBアプリとして当然備えているべき一通りの基本的な機能が実装されている
Try
(プロフィール・免許証)画像のアップロードができる
email認証(アカウント作成時)
eKYC(Know Your Customer) 機能が備わっている
OAuth認証
システム構成
簡略図(draw.ioで作成)

※Next.jsではなくFlutterを使いました
さて一通り要件定義を済ませたので早速開発していきましょう
バックエンド開発 Django
まずはバックエンド側の開発です
Django(ジャンゴと読みます)を用いてRestAPIを作成していきます
ちなみにDjangoはPythonを元にしたWebアプリ開発のフレームワークの一つで、YoutubeやInstagramなど有名なサービスにも使われている実績のあるフレームワークです
バックエンド側Djangoの開発環境
インストール~環境構築
以下ではpythonとAnaconda NavigatorとPyCharmを利用します
インストールしていない人はインストールしてください
最新版をインストール
Download Python | Python.org
- Anaconda Navigator
なお、個人向け無料のエディションはAnaconda Individual Edition
Download Anaconda Distribution | Anaconda
- PyCharm
無料で使用したいためCommunityの方を選択
PyCharm のダウンロード: JetBrains のデータサイエンスおよびウェブ開発用 Python IDE
1. 仮想環境作成
まずは仮想環境を作成しましょう
ここで言っている仮想環境とはDjangoでこれからいろいろなパッケージをインストールして使っていくので、そのバージョン管理を行うための環境です
これを行わないとPC上に直接パッケージがインストールされて他のアプリ開発をするときに面倒なことが起きる可能性があります
特段理由がなければ少し手間はあるがこちらの手順を行っておくのが吉
Anaconda Navigatorを起動する
その後、Environments>Createで新たな仮想環境作成を選択
後は画面に従って、Nameに「Matching_App_API」などを入力していきcreateを押下
2. ターミナルを開き、必要なパッケージをインストールする
Anaconda Navigatorで先ほど作った環境の再生ボタン(△ボタン)をクリックしてターミナルを開き
コマンドラインにpip installを使うことで以下のパッケージをまずはインストールする
pip install ○○
- django - これから開発に使用するバックエンドのフレームワーク
- djangorestframework - Rest APIが使用できるようになる
- djangorestframework-simplejwt - JWTと呼ばれる最近使用されるセキュリティレベルの高いTokenを使用した認証機構(simpleがついていない方も存在したが更新日時がもはや5年前だった)
- djoser - JWTを便利に使用するためのパッケージ
- django-cors-headers - バックエンドとフロントエンドをクロスエンジンでつなぐ
- pillow - 画像を扱うためのパッケージ
- django-environ - 環境変数を扱うパッケージ
他のパッケージは必要に応じてインストールしていく
インストールが終わったらターミナルを閉じる
3. PyCharmに上記で作成した仮想環境を紐づける
任意の場所にプロジェクトルートとなるフォルダを作成する
例:C:\Users\[YOURNAME]\projects\matching-app
Pycharmを起動する
今作成したそのフォルダを開く
File>Settingを選択
Project>Python Interpreterを選択して、画面の歯車マークからAddを選択する
Existing environmentを選択して、...マークを選択する
%USERPROFILEフォルダ以下で envs\[仮想環境の名前]\python.exe を選択する
例: envs\Matching_App_API\python.exe
すると、Python Interpreterに先ほど作成した仮想環境Matching_App_APIが適用され、Djangoのコマンドなどが利用できるようになる
OKを押して、PyCharmを再起動するとこの仮想環境が適用される
4. プロジェクトとアプリの作成
それではdjangoのプロジェクトを作成していきましょう
PyCharmのターミナルを開いて、まずは以下のコマンドを入力して matchingappapi プロジェクトという名前でバックエンドのプロジェクトフォルダを作成しましょう
django-admin startproject matchingappapi
そしてプロジェクトを作成したら、cdで今作ったプロジェクトフォルダ直下に下るか、PyCharmで今作成したプロジェクトフォルダ直下にフォルダを開きなおすかします
今回は開きなおす方で行います
なお、同じフォルダが二つ作成されているが、プロジェクトフォルダ直下と言っているのは matching-app下のmatchingappapiフォルダのことです
matching-app
┗matchingappapi
┗ matchingappapi
なお、フォルダを開きなおすと先ほどの3の手順で行ったPyCharm仮想環境適用の設定をもう一度行う必要があるので、3の手順をもう一度行う
次にアプリを作成する
アプリ名はなんでもよいがここではbasicapiとして作成する
django-admin startapp basicapi
ここまで終わるとフォルダ構成がこのようになっているはずです
matching-app
┗ matchingappapi
┗ matchingappapi
┗ basicapi
┗ manage.py
5. サーバーの起動
ここまででWebアプリの初期導入は完了しています
なので、一度サーバーを起動してアプリが立ち上がるかを動作確認してみましょう
プロジェクト直下の manage.py を右クリックしてrun 'manage'をクリック
そのあと、PyCharm画面の右上にmanageボタンが表示されるので、クリックしてEdit Configurationsを選択する
parametersの箇所に runserver を追記する
この設定を行うことでPyCharmの右上の再生ボタンを押すことでサーバーを起動させることができるようになる
なお他の方法としては、ターミナルで
py manage.py runserver
と入力することでもサーバーを起動することができる
サーバーを起動させるとターミナルに起動URL(http://127.0.0.1:8000/)が表示されるはずなので、ブラウザを開いてそのURLにアクセスすると、Djangoのロケットが打ちあがる初期起動画面が見えるはずである
ロケットの画面が見えれば動作確認は成功です
次回予告
これからDjangoの本格的な開発に入っていこうと思いますが、長くなるのでいったん記事を区切ろうと思います
続きは、Djangoの設定をいじってモデルを作成し、ユーザー一覧を表示させられるまでのAPIの機能を作成しようと思います
次の記事
https://shinseidaiki.hatenablog.com/entry/2022/02/19/213700shinseidaiki.hatenablog.com
マッチングアプリを個人開発する~Djangoバックエンドその2 モデル作成~
この記事はマッチングアプリを自作してみようと試みている記事です
バックエンドを実装しています
前回の記事はこちらです
https://shinseidaiki.hatenablog.com/entry/2022/02/19/212201shinseidaiki.hatenablog.com
前回はDjangoの導入まで終わりました
次にモデルを作成していきましょう
目次
- はじめに
- ※おしらせ
- 要件定義
- 開発環境
- バックエンド開発 Django
- 次回予告
- アーキテクチャ設計
- Djangoの初期設定~設定項目の編集~
- Modelの実装
- 管理者Adminサイトの整備
- 次回予告
- 今後の課題
- Serializersの実装
- Viewsの実装
- URL ルーティングを設定する
- 次回予告
- 動作確認
- 確認するエンドポイントの整理
- ユーザー新規作成とJWT認証(/api/authen/jwt/createと/api/users/create/)
- ログイン中のユーザー情報の取得と更新(/api/users/{pk}/)
- ログイン中のユーザー情報の取得と更新(/api/users/profile/{pk}/)
- ログイン中のプロフィール新規作成と異性プロフィール一覧取得(/api/profiles/)
- プロフィール取得(/api/profiles/{pk})
- マッチングデータ作成・マッチング一覧取得(CREATE, GET /api/favorite/ )
- マッチングデータ取得・更新(GET, PATCH /api/favorite/{pk})
- DM送信側処理(/api/dm-message/ )
- DM受信側一覧取得(GET /api/dm-message/, /api/dm-message/{pk})
- トークン期限切れ確認
- 次回予告
- 決済機能のStripeについての概要
- アーキテクチャ設計
- 各種機能を使用するための設定と環境変数を実装する
- Modelを実装する
- ルーティングURLの実装をする
- Viewを実装する
- 動作確認
- 次回予告
- はじめに
- おしらせ
- Flutterとは
- Flutter環境構築
- 開発環境
- Flutter プロジェクトを作成する
- Flutterでコードを実装していく
- ログイン機能の実装
- 自己プロフィール
- プロフィール一覧閲覧機能&いいね機能の実装
- メッセージ機能の実装
- 終わりに
アーキテクチャ設計
さて、続きのAPIを作成する前に前回のガバガバなシステム構成図ではバックエンドの中身をどう作るのかについて全く未検討だったのでここでバックエンド側のアーキテクチャ設計を行います
具体的には、画面遷移図、DB設計、Model設計、View設計、RestAPI URI設計です
なお、Djangoにはコードを元にER図などのドキュメンテーションを作成してくれる機能があるため、ここでは簡単な絵図だけを描くことにします
画面遷移図
画面遷移図はバックエンドのアーキテクチャの設計のイメージがつきやすくなるためここで簡略図を作成する

ER図
ざっくりとテーブルのリレーションがわかるだけの簡略図を作ります

ユーザーの実装はDjangoにUserモデルが標準装備されているので、そのUserモデルをカスタマイズすることで実装するのが一般的となっている
その際、基本情報はUserモデルのフィールドとして持ち、nicknameなど新たに持たせたいフィールドは1対1のリレーションで新たに作成したProfileモデルに設定するのが一般的である
またマッチングクラスではいいねの送信者と受信者が双方向にいいねを送り合ったときに双方のapprovedをtrueに変更しマッチングとする設計にする
メッセージモデルに関しては単にUserモデルと1対多の関係性をとるのみとし、マッチングしているユーザー同士のみがメッセージのやり取りができる機能に関してはView側で実装していく
各モデルの他のフィールド(others......)に関しては実装時に具体的にしていく
RestAPI 設計
次にバックエンドで作成するRestAPIの機能とそのURIエンドポイントを設計します
原則としてREST APIはすべてログイン中のユーザーのみが操作できるようにして、それ以外のアクセスは拒否します
| エンドポイント | 機能内容 |
| /api/authen/ | ユーザー認証に利用される |
| /api/users/create/ | ユーザーを作成することができる |
| /api/users/{pk}/ | 自分自身のユーザー情報を取得・編集できる。他人のユーザー情報は取得・編集できない |
| /api/users/profile/{pk} | 自分自身のプロフィール情報を作成・取得・編集できる。他人のプロフィール情報は取得・編集できない |
| /api/profiles/ | 自身のプロフィールを作成したり、異性のプロフィール一覧を取得することができる。自分のプロフィールがない状態ではプロフィール一覧を取得することはできない |
| /api/favorite/ | いいねを送ったユーザといいねをくれたユーザ一覧を取得したりいいねを送ったり、いいねを承認したりすることができる |
| /api/dm-message/ | ユーザー自身が送信したDMの一覧を取得する。新たにメッセージを送信することができる |
| /api/dm-inbox/ | ユーザー自身が受信したDMの一覧を取得する |
Djangoの初期設定~設定項目の編集~
Djangoを本格的に開発していくためにはsettings.pyの設定を編集していくことが欠かせませんので、これから開発にあたって必要となってくる設定をここで初めにしておきます
プロジェクト構成に合わせて空フォルダを作成する
以下のようなフォルダ構成のフォルダとファイルを作成する
matchingappapi(プロジェクトルート)
┗ secrets - オープンソースなどでソースコードを外部公開する際に秘匿にしておく情報を扱う
┗ .env.dev
┗ .env.prod
┗ media - 開発環境のみでクライアントとの画像処理に使用する
┗ .gitignore - githubにアップロードしないファイルを定義する
シークレット情報の環境変数化
上記で作成した.envファイルに環境変数を記載する
.env.dev(開発環境)
SECRET_KEY=django-xxxxxxxxxxxxxx DEBUG=True ALLOWED_HOSTS=* CORS_ORIGIN_WHITELIST=http://localhost:3000 DATABASE_URL=sqlite:///db.sqlite3
.env.prod(本番環境 - デプロイする場合)
SECRET_KEY=django-xxxxxxxxxxxxxx DEBUG=False ALLOWED_HOSTS=[特定のホスト] CORS_ORIGIN_WHITELIST=[特定のホスト] DATABASE_URL=postgres://USER:PASSWORD@HOST:PORT/NAME
なおこの時環境変数に格納する値はクオテーションなしで登録する
DjangoはデフォルトでSQLiteを使用しているため、.env.devではSQLiteを使用することにする
また本番環境ではPostgresを使用するため.env.prodではPostgressを使用する設定とする
大文字になっている箇所は任意の名前を入力する
ちなみに余談ですがHerokuではデプロイした先でデフォルトで環境変数にDATABASE_URL=Postgresのパスが設定されているみたいです
setting.pyの編集
アプリケーション適用
まずはプロジェクトにアプリケーション(basicapi)を適用します
settings.py
INSTALLED_APPS = [
'basicapi.apps.BasicapiConfig',
]
環境変数適用
次にsetting.pyに先ほど.envに外部ファイル化した環境変数を適用していきます
setting.py
import environ env = environ.Env() root = environ.Path(os.path.join(BASE_DIR, 'secrets')) # 本番環境 # env.read_env(root('.env.prod')) # 開発環境 env.read_env(root('.env.dev')) SECRET_KEY = env.str('SECRET_KEY') DEBUG = env.bool('DEBUG') ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=[])
rootに関してはsecretsフォルダを作成したことで変数化しており、プロジェクト直下に配置する構成の場合は記載する必要はなくなる
なお次のような書き方もある
root = environ.Path(BASE_DIR / 'secrets')
パッケージ適用
次に最初にインストールしたpythonパッケージをこれから作るアプリに適用していきます
setting.py
import os from datetime import timedelta INSTALLED_APPS = [ 'rest_framework', 'corsheaders', 'djoser', ] MIDDLEWARE = [ 'corsheaders.middleware.CorsMiddleware', ] CORS_ORIGIN_WHITELIST = env.list('CORS_ORIGIN_WHITELIST', default=[])
CORS_ORIGIN_WHITELIST はフロントエンドからのアクセスを許可するためのホワイトリスト設定で、SIMPLE_JWT は認証トークンに使用するJWTの設定です
アクセス制限の適用
次に、バックエンドをREST API化したときのセキュリティの設定としてAPIの利用はデフォルトで認証された(ログイン中)ユーザーのみに制限します
また認証にはJWTを利用することにしており、Tokenの有効期限を1日に設定しています
settings.py
REST_FRAMEWORK = {
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
],
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework_simplejwt.authentication.JWTAuthentication',
],
}
SIMPLE_JWT = {
'AUTH_HEADER_TYPES': ('JWT',),
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=1440)
}
Djangoの設計自体は認証に関する権限に関してはViewに各画面毎に定義することになっているが、ベストプラクティスとしては基本となる認証ロジックに関してはsetting.pyに記述して差分をViewで適用する方法の方が良い実装だとされている模様で、本アプリではベストプラクティスに沿った実装を行おうと思います
DB設定
次に、Databaseの設定を行います
デフォルト(ローカル)ではsqliteを使用して、本番環境ではPOSTGRESを使用する構成にします
django-environのパッケージを使用すると環境変数でDBも取り扱えるようになる
環境変数にDATABASE_URLが記載されている場合そちらを優先して使用され、DATABASE_URLが無記載の場合はenv.db()がDjangoデフォルトのSQLiteを設定してくれます
settings.pyを以下のように書き換えます
settings.py
# DATABASES = { # 'default': { # 'ENGINE': 'django.db.backends.sqlite3', # 'NAME': BASE_DIR / 'db.sqlite3', # } # } DATABASES = { 'default': env.db(), }
カスタムユーザー適用準備
次に、今後Djangoで扱うユーザーはカスタムユーザーを使用していくので、ユーザーの設定を記載します
settings.py
AUTH_USER_MODEL = 'basicapi.User'
ロケーション変更
次に、ロケーションを日本に変更します
settings.py
LANGUAGE_CODE = 'ja' TIME_ZONE = 'Asia/Tokyo'
StaticRootの設定
次に、静的ファイルの配信最適化をする設定を行います
本番環境(Heroku)にデプロイする際にばらばらに配置されている静的ファイルを指定した一つのフォルダにまとめてくれます
settings.py
STATIC_ROOT = str(BASE_DIR / 'staticfiles')
Mediaファイルの取り扱い設定
次に、静的ファイルを取り扱えるようにmediaの設定をします
こちらも環境変数化し、本番環境と開発環境を切り分ける必要があるが後々行うこととします
settings.py
# 開発環境 MEDIA_ROOT = os.path.join(BASE_DIR, 'media') MEDIA_URL = '/media/'
またmediaの設定はurls.pyも編集が必要となるので、忘れないうちに以下のコードを追加しておきます
urls.py
from django.conf.urls.static import static from django.conf import settings if settings.DEBUG: urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
余談として、デフォルトで記述されているTEMPLATES は本アプリでは使用しないためsetting.pyから削除してもよいはずです
URLの設定
プロジェクト側urls.pyの設定
後でもいいですが、ここでルーティングのガワを決めておきます
matchingappapi/urls.py
from django.conf.urls import include urlpatterns = [ path('api/', include('basicapi.urls')), path('authen/', include('djoser.urls.jwt')), ]
アプリ側urls.pyの設定
basicapi直下にurls.pyを作成してアプリのルーティングのガワを記載しておきます
basicapi/urls.py
from rest_framework.routers import DefaultRouter from django.urls import path from django.conf.urls import include router = DefaultRouter() app_name = 'basicapi' urlpatterns = [ path('', include(router.urls)), ]
.gitignoreの編集
さて上記の設定作業では外部に流出したくないファイルを作成したので、そのファイルたちはGitHubにアップロードしないように設定したいので、.gitignoreを編集していきます
現時点では以下のあたりを記述しておく
.gitignore
basicapi/__pycache__/ basicapi/migrations/__pycache__/ matchingappapi/__pycache__/ media/ secrets/ db.sqlite3
こちらで初期設定はあらかた完了です
Modelの実装
それでは本題です
モデルを実装していきましょう
カスタムユーザーの実装
まずはカスタムユーザーを実装していきます
Djangoのドキュメントによると最初のMigrationでカスタムユーザーを使用しないとデフォルトユーザーを使用することになってしまい、さらにドキュメントにはカスタムユーザーの使用を推奨すると記載されているので、実質Djangoの初期作業となっています
カスタムユーザーの作り方としてはAbstractUserを継承する方法と、AbstractBaseUserを継承する方法の2種類がある
- AbstractUser: すでに存在するフィールドをそのまま流用して、フィールドの増減を行うことができる
ただし、メールアドレスをログインIDにするなどのことができないため、限定的な利用にとどまる
- AbstractBaseUser: Userモデルをほとんどゼロベースから作成することが可能
自由度が高くこちらをカスタムユーザーとするのが一般的で本アプリでもAbstractBaseUserでカスタム実装していきます
それではアプリのモデルを編集します
basicapi/models.pyを編集します
ユーザーマネージャーの実装
AbstractBaseUserを使用する場合はデータ挿入取得更新処理をするマネージャークラスを作成する必要があるのでマネージャークラスをまずは作ります
Djangoのデフォルトではユーザー名がログインに使用されますが、本アプリのカスタムユーザーはemailをユーザー名として利用するようにします
models.py
from django.contrib.auth.models import BaseUserManager class UserManager(BaseUserManager): def create_user(self, email, password=None, **extra_fields): if not email: raise ValueError('Users must have an email address') user = self.model(email=self.normalize_email(email), **extra_fields) user.set_password(password) user.save(using=self._db) return user def create_superuser(self, email, password): user = self.create_user(email, password) user.is_staff = True user.is_superuser = True user.save(using=self._db) return user
ユーザークラスの実装
ユーザーマネージャーが作成できれば次はユーザークラスを作成します
models.pyにユーザークラスを追記します
models.py
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin import uuid class User(AbstractBaseUser, PermissionsMixin): id = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False) email = models.EmailField(max_length=255, unique=True) username = models.CharField(max_length=255, blank=True) is_active = models.BooleanField(default=False) is_staff = models.BooleanField(default=False) objects = UserManager() USERNAME_FIELD = 'email' def __str__(self): return self.email
idにはuuidを使用しています
is_staffは管理者画面にアクセスできる権限を付与してしまうので、基本デフォルトでFalseにします
またマッチングアプリの利用者は年齢確認をしなければならないという制約があるので、アカウントの有効化はクレジットカード決済が有効だったユーザーのみに限定したいのでデフォルトでis_activeをFalseにします
アカウントの有効化機能はREST APIを作成する中で作成していきます
Profileクラスの実装
カスタムユーザーを作成する場合は通常セットで作成されるクラスです
nicknameなどの新たなフィールドをユーザークラスに追加したいが、ユーザークラスを煩雑にしたくないケースに頻繁に利用されます
カスタムユーザーを作成するときにはProfileクラスをセットで作成するのがデファクトスタンダードになっているといっても遜色ないかと思います
Userと1対1の関係をとるのでOneToOneFieldを利用する
マッチングアプリは性別や年齢が大切になってくるので必要な最低限の項目を追加したものをまずは作ります
models.py
from django.conf import settings from django.core.validators import MinValueValidator, MaxValueValidator class Profile(models.Model): user = models.OneToOneField( settings.AUTH_USER_MODEL, primary_key=True, on_delete=models.CASCADE, related_name='profile') is_kyc = models.BooleanField("本人確認", default=False) nickname = models.CharField("ニックネーム", max_length=20) created_at = models.DateTimeField("登録日時", auto_now_add=True) updated_at = models.DateTimeField("更新日時", auto_now=True, blank=True, null=True) age = models.PositiveSmallIntegerField( "年齢", validators=[MinValueValidator(18, '18歳未満は登録できません'), MaxValueValidator(100, '100歳を超えて登録はできません')]) SEX = [ ('male', '男性'), ('female', '女性'), ] sex = models.CharField("性別", max_length=16, choices=SEX) introduction = models.TextField("自己紹介", max_length=1000) def __str__(self): return self.nickname
次に既存のアプリでは他の項目もたくさんあるので、主にPairsを参考にその他のフィールドも追加していきます
プロフィールクラスにさらに項目を追加した結果が以下です
一気にコードが増えました
models.py
from datetime import datetime, timedelta def top_image_upload_path(instance, filename): ext = filename.split('.')[-1] return '/'.join(['images', 'top_image', f'{instance.user.id}{instance.nickname}.{ext}']) class Profile(models.Model): user = models.OneToOneField( settings.AUTH_USER_MODEL, primary_key=True, on_delete=models.CASCADE) """ Profile Fields """ is_special = models.BooleanField(verbose_name="優良会員", default=False) is_kyc = models.BooleanField(verbose_name="本人確認", default=False) top_image = models.ImageField( verbose_name="トップ画像", upload_to=top_image_upload_path, blank=True, null=True) nickname = models.CharField(verbose_name="ニックネーム", max_length=20) created_at = models.DateTimeField(verbose_name="登録日時", auto_now_add=True) updated_at = models.DateTimeField(verbose_name="更新日時", auto_now=True, blank=True, null=True) """ Physical """ age = models.PositiveSmallIntegerField( verbose_name="年齢", validators=[MinValueValidator(18, '18歳未満は登録できません'), MaxValueValidator(100, '100歳を超えて登録はできません')]) SEX = [ ('male', '男性'), ('female', '女性'), ] sex = models.CharField("性別", max_length=16, choices=SEX) height = models.PositiveSmallIntegerField( verbose_name="身長", blank=True, null=True, validators=[MinValueValidator(140, '140cm以上で入力してください'), MaxValueValidator(200, '200cm以下で入力してください')]) """ Environment """ LOCATION = [ ('hokkaido', '北海道'), ('tohoku', '東北'), ('kanto', '関東'), ('hokuriku', '北陸'), ('chubu', '中部'), ('kansai', '関西'), ('chugoku', '中国'), ('shikoku', '四国'), ('kyushu', '九州'), ] location = models.CharField(verbose_name="居住エリア", max_length=32, choices=LOCATION, blank=True, null=True) work = models.CharField(verbose_name="仕事", max_length=20, blank=True, null=True) revenue = models.PositiveSmallIntegerField(verbose_name="収入", blank=True, null=True) GRADUATION = [ ('junior_high_school', '中卒'), ('high_school', '高卒'), ('trade_school', '短大・専門学校卒'), ('university', '大卒'), ('grad_school', '大学院卒'), ] graduation = models.CharField( verbose_name="学歴", max_length=32, choices=GRADUATION, blank=True, null=True) """ Appealing Point """ hobby = models.CharField( verbose_name="趣味", max_length=32, blank=True, null=True) PASSION = [ ('hurry', '今すぐにでも'), ('speedy', '1年以内に'), ('slowly', 'ゆっくり考えたい'), ('no_marriage', '結婚する気はない'), ] passion = models.CharField( verbose_name="結婚に対する熱意", max_length=32, choices=PASSION, blank=True, null=True, default='slowly') tweet = models.CharField(verbose_name="つぶやき", max_length=8, blank=True, null=True) introduction = models.TextField(verbose_name="自己紹介", max_length=1000, blank=True, null=True) """ Assesment Fields """ send_favorite = models.PositiveIntegerField( verbose_name="送ったいいね数", blank=True, null=True, default=0) receive_favorite = models.PositiveIntegerField( verbose_name="もらったいいね数", blank=True, null=True, default=0) stock_favorite = models.PositiveIntegerField( verbose_name="いいね残数", blank=True, null=True, default=1000) class Meta: ordering = ['-created_at'] 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週間以上" def __str__(self): return self.nickname
verbose_nameはフィールドの詳細名です
validatorsはバリデータを設定できる項目で最小値や最大値などの制約を加えることができます
choicesは選択式のフィールドに対応する項目です
選択肢に対応する値はタプルの配列で格納します
DB上ではKeyが格納されコード上では辞書型として提供されます
最終ログイン日を教えてくれるサービスがあるので本アプリもそれにならいます
Userクラスのコードの見通しはよくしておきたいのでProfileクラスにメソッドを定義します
もしかしたらアンチパターンかもしれませんが......
またDjangoのモデルのフィールドはキャメルケースと迷いましたがDjangoに書いたフィールド名がDBのフィールド名にそのまま記載されたはずなので本アプリではスネークケースで書いています
デファクトスタンダードなのはどっちなのか正直わかっていません
Matchingクラスの実装
次にマッチングクラスを実装します
いいねを送った人ともらった人を格納するテーブルです
マッチングの実装については、いいねをもらった側のユーザーが承認(approvedをTrue)すると同時に相手側にいいねを送り返して双方向のいいねが成立した時点でマッチングとします
マッチング後、すなわちapprovedがTrueになっている場合にメッセージをやり取りできるような実装とします
またちなみにDjangoでは複合主キーはサポートされていないですが、フィールド同士の組み合わせ制約をunique_togetherで付与することができ、マッチングの重複を避けることができます
models.py
class Matching(models.Model): approaching = models.ForeignKey( settings.AUTH_USER_MODEL, related_name='approaching', on_delete=models.CASCADE ) approached = models.ForeignKey( settings.AUTH_USER_MODEL, related_name='approached', on_delete=models.CASCADE ) approved = models.BooleanField(verbose_name="マッチング許可", default=False) created_at = models.DateTimeField(verbose_name="登録日時", auto_now_add=True) class Meta: unique_together = (('approaching', 'approached'),) def __str__(self): return str(self.approaching) + ' like to ' + str(self.approached)
最初に設計したER図とずれがあるのは気にしないで下さい
実装が正です
DM(ダイレクトメッセージ)クラスの実装
最後にDirectMessageクラスの実装を行います
マッチングが成立している(MatchingクラスのapprovedがTrueになっている)ユーザー同士のメッセージが格納されます
余力がある人は画像データなどのマルチメディアのデータを送れるような実装にしてもよいかもしれませんが、このアプリではテキストデータのみを扱えることとします
models.py
class DirectMessage(models.Model): sender = models.ForeignKey( settings.AUTH_USER_MODEL, related_name='sender', on_delete=models.CASCADE ) receiver = models.ForeignKey( settings.AUTH_USER_MODEL, related_name='receiver', on_delete=models.CASCADE ) message = models.CharField(verbose_name="メッセージ", max_length=200) created_at = models.DateTimeField(verbose_name="登録日時", auto_now_add=True) def __str__(self): return str(self.sender) + ' --- send to ---> ' + str(self.receiver)
他にも作成したほうが良いモデルクラスは多分にありますが、今回はここまでの実装とします
参考にしているPairsにはコミュニティ機能があるので余力があればコミュニティクラスを作ったりタグクラスを作ったりしてもよいかもしれません
管理者Adminサイトの整備
管理者画面を扱いたい場合はアプリフォルダ(basicapi)にあるadmin.pyを編集していく必要があります
カスタムユーザーのモデルクラスを管理画面で扱えるようにする
管理者画面にカスタムユーザーを追加
basicapi/admin.pyを以下のように編集してきます
admin.py
from django.contrib import admin from .models import User from django.contrib.auth.admin import UserAdmin as BaseUserAdmin class UserAdmin(BaseUserAdmin): ordering = ('id',) list_display = ('email', 'password') fieldsets = ( (None, {'fields': ('email', 'password')}), ('Personal Information', {'fields': ('username',)}), ( 'Permissions', { 'fields': ( 'is_active', 'is_staff', 'is_superuser', ) } ), ('Important dates', {'fields': ('last_login',)}), ) add_fieldsets = ( (None, { 'classes': ('wide',), 'fields': ('email', 'password1', 'password2'), }), ) admin.site.register(User, UserAdmin)
Profileクラスをカスタムユーザーの詳細画面に挿入する
Inclineを利用するとテーブルが分かれているクラスを同一画面で見ることができて管理画面が利用しやすくなるのでUserにProfileクラスを挿入していきます
Inclineの行をUserAdminに追加します
admin.py
from .models import User, Profile class UserAdmin(BaseUserAdmin): # ...... inlines = (ProfileInline,)
実装したモデルクラスを管理画面で扱えるようにする
同様にadmin.pyを編集してユーザー以外のデータも見られるようにします
Prolileを管理者画面に追加
admin.py
from .models import Profile class ProfileAdmin(admin.ModelAdmin): ordering = ('-created_at',) list_display = ('__str__', 'user', 'age', 'sex', 'tweet', 'created_at', 'from_last_login') admin.site.register(Profile, ProfileAdmin)
Matchingを管理者画面に追加
admin.py
from .models import Matching class MatchingAdmin(admin.ModelAdmin): ordering = ('-created_at',) list_display = ('__str__', 'approved', 'created_at') admin.site.register(Matching, MatchingAdmin)
DirectMessageを管理者画面に追加
admin.py
from .models import DirectMessage class DirectMessageAdmin(admin.ModelAdmin): ordering = ('-created_at',) list_display = ('__str__', 'message', 'created_at') admin.site.register(DirectMessage, DirectMessageAdmin)
管理者ユーザー(Superuser)の作成
管理者画面にアクセスできるユーザーを作成しておきましょう
Djangoのデフォルトユーザーではusernameが作成時に必要ですがカスタムユーザーが適用されているとemailの設定が必要になっていることを確認できるはずです
py manage.py createsuperuser
管理画面の動作確認
管理者ユーザーを作成したらサーバーを起動して管理者画面にアクセスしてみましょう
http://127.0.0.1:8000/admin
emailとパスワードでログインできることを確認します
また実装したモデルが管理画面に表示されて追加などの各種CRUD操作ができることを確認します

またユーザーのパスワードがハッシュ化されていることやプロフィールがインラインで表示されていることも確認しておきます
これらが一通り確認できれば動作確認としては完了です
次回予告
さて、次はSerializersとViewsの実装を行っていよいよRestAPIを作成していきますが、長くなりましたのでここでいったん区切らせてもらいます
次の記事
https://shinseidaiki.hatenablog.com/entry/2022/02/19/214008shinseidaiki.hatenablog.com
今後の課題
今回年齢を格納するフィールドはProfileに持たせましたが、年齢は年を経るごとに更新されていくので生年月日の項目が必要であるのと、ユーザー新規作成時に年齢を入力してもらいたいという観点から、ユーザークラスに新規登録時年齢registered_ageと生年月日のフィールドを持っておいた方が良いですね
プロフィールは表示年齢として生年月日から自動計算にする実装にした方がいいですね
マッチングアプリを個人開発する~Djangoバックエンドその3 REST API作成~
この記事はマッチングアプリを自作してみようと試みている記事です
バックエンドを実装しています
前回までの記事はこちらです
https://shinseidaiki.hatenablog.com/entry/2022/02/19/213700shinseidaiki.hatenablog.com
前回はモデル作成まで終わりました
今回はバックエンドの機能としてREST APIの機能を実装していきます
目次
- はじめに
- ※おしらせ
- 要件定義
- 開発環境
- バックエンド開発 Django
- 次回予告
- アーキテクチャ設計
- Djangoの初期設定~設定項目の編集~
- Modelの実装
- 管理者Adminサイトの整備
- 次回予告
- 今後の課題
- Serializersの実装
- Viewsの実装
- URL ルーティングを設定する
- 次回予告
- 動作確認
- 確認するエンドポイントの整理
- ユーザー新規作成とJWT認証(/api/authen/jwt/createと/api/users/create/)
- ログイン中のユーザー情報の取得と更新(/api/users/{pk}/)
- ログイン中のユーザー情報の取得と更新(/api/users/profile/{pk}/)
- ログイン中のプロフィール新規作成と異性プロフィール一覧取得(/api/profiles/)
- プロフィール取得(/api/profiles/{pk})
- マッチングデータ作成・マッチング一覧取得(CREATE, GET /api/favorite/ )
- マッチングデータ取得・更新(GET, PATCH /api/favorite/{pk})
- DM送信側処理(/api/dm-message/ )
- DM受信側一覧取得(GET /api/dm-message/, /api/dm-message/{pk})
- トークン期限切れ確認
- 次回予告
- 決済機能のStripeについての概要
- アーキテクチャ設計
- 各種機能を使用するための設定と環境変数を実装する
- Modelを実装する
- ルーティングURLの実装をする
- Viewを実装する
- 動作確認
- 次回予告
- はじめに
- おしらせ
- Flutterとは
- Flutter環境構築
- 開発環境
- Flutter プロジェクトを作成する
- Flutterでコードを実装していく
- ログイン機能の実装
- 自己プロフィール
- プロフィール一覧閲覧機能&いいね機能の実装
- メッセージ機能の実装
- 終わりに
Serializersの実装
SerializerとはModelとViewをつなぐインターフェースです
Webアプリケーションは究極的にはデータの作成・参照・更新・削除(通称CRUD)をするためにコードをごちゃごちゃといじっていくものなのですが、SerializerはModelを基にCRUDを自動的に実装してくれるものになっています
一昔前はModelを作成した後にまずはデータ作成機能を作り、参照機能を作り、更新機能を作り、削除機能を作り、といったように手作業で一つ一つ実装していました
しかしながらこのようなCRUD機能に関してはModelが定まれば半機械的に実装できるもののためDjangoでは実際に自動化してくれる機能があり、それがSerializerとなっています
ちなみにDjangoのアーキテクチャーの役割はまだいまだに自分も理解しきれていないのですが、Webアプリの設計思想であるMVCモデル的に考えるとDjangoのViewがControllerに対応していて、SerializerがServiceに対応しているイメージでしょうか?
ちなみにDjangoはMVTモデルと言っているのでそう単純には比較できないでしょうがこのくらいの浅い知識でもアプリは実装できるのでいい時代ですね
それではアプリの方のフォルダ(basicapi/)にserializers.pyファイルを作成します
ユーザーシリアライザーの作成
まずはユーザーのCRUDをサポートするシリアライザーを作成します
そのためにModelSerializerを継承してシリアライザーを作成します
ModelSerializerを継承すると思考停止でCRUDをサポートしてくれるので、serializers.pyの実装は基本的にはModelSerializerにおんぶに抱っこされる形になろうかと思います
serializers.py
from rest_framework import serializers from django.contrib.auth import get_user_model class UserSerializer(serializers.ModelSerializer): class Meta: model = get_user_model() fields = ('email', 'password', 'username', 'id') extra_kwargs = {'password': {'write_only': True, 'min_length': 8}} def create(self, validated_data): user = get_user_model().objects.create_user(**validated_data) return user def update(self, use_instance, validated_data): for attr, value in validated_data.items(): if attr == 'password': use_instance.set_password(value) else: setattr(use_instance, attr, value) use_instance.save() return use_instance
def create(self, validated_data) はModelSerializerにもともと備わっているCreateメソッドのオーバーライドをしたものになっています
基本的にはModelSerializerは思考停止でいいのですが、ユーザーに関しては思考停止ではいけません
というのもパスワードを取り扱うためで、ModelSerializerをそのまま使ってしまうと生のパスワードを保存してしまうので、パスワードをハッシュ化する必要があります
そこでユーザーモデルを作成したときに実装したUserManagerのcreate_user()を利用します
create_user()メソッド内にはset_password()のメソッドがあり、これがパスワードの値をハッシュ化しています
validated_dataはユーザー作成に必要なemailやpassword、usernameなどのデータが入っている想定です
fieldsはCRUD機能を自動適用させたいフィールドを指定します
extra_kwargsは指定したフィールドに制約条件を課すことができて上記ではpasswordの文字数は8文字以上という制約条件をつけています
update()は更新処理メソッドのオーバーライドでこれもユーザー作成時同様パスワード変更時にハッシュ化を行う必要があるためにこのような実装を行っています
extra_kwargsでパスワードを書き込み専用にしているのでREST APIを使用しているユーザーがパスワードを取得することはないので二重ハッシュ化によるパスワード破壊は起こらないはずなので大丈夫だとは思うが、ユーザー情報の更新処理は差分更新(PATCHメソッド)で呼び出すこととします
ユーザー情報でパスワード更新とメールアドレスユーザー名の更新画面は画面をそれぞれ分けて利用する設計にします
ProfileSerializerの作成
次にプロフィールのシリアライザーを作成する
ユーザークラス以外のモデルに関しては基本的にはMetaクラスの属性を編集することで実装できる
fieldの作成日時と更新日時はミリ秒以下は不要のためフォーマットしてデータを格納します
作成日時は編集する必要がないので読み取り専用にしておきます
また特に外部キーに使用している user も変更されると一大事となるのでこちらも読み取り専用にしておきます
serializers.py
class ProfileSerializer(serializers.ModelSerializer): created_at = serializers.DateTimeField(format='%Y-%m-%d %H:%M:%S', read_only=True) updated_at = serializers.DateTimeField(format='%Y-%m-%d %H:%M:%S', read_only=True) class Meta: model = Profile fields = ( 'user', 'is_special', 'is_kyc', 'top_image', 'nickname', 'created_at', 'updated_at', 'age', 'sex', 'height', 'location', 'work', 'revenue', 'graduation', 'hobby', 'passion', 'tweet', 'introduction', 'send_favorite', 'receive_favorite', 'stock_favorite' ) extra_kwargs = {'user': {'read_only': True}}
マッチングシリアライザーの作成
同様にマッチングシリアライザーを作成します
serializers.py
class MatchingSerializer(serializers.ModelSerializer): created_at = serializers.DateTimeField(format='%Y-%m-%d %H:%M:%S', read_only=True) class Meta: model = Matching fields = ('id', 'approaching', 'approached', 'approved', 'created_at') extra_kwargs = {'approaching': {'read_only': True}}
ダイレクトメッセージシリアライザーの作成
ダイレクトメッセージはマッチング中のユーザーのみがやりとりできるという制約があるので受け手のユーザーをあらかじめマッチングしたユーザーのみにフィルタリングしておきます
フィルタリングしておくことで送り手はマッチングしたユーザーのみを受け手に指定できることになるのでマッチングしたユーザーにのみダイレクトメッセージを送れる機能要件を満たします
フィルタリングの機能の実装はややこしいのでコードを眺めているだけでは解読不能かと思いますので図を作成しました
適当な図ですが理解の助けになれば幸いです

マッチング中のユーザーのみをフィルタリングする
DMを送ることができるのはマッチングしているユーザーのみなので、マッチングしているユーザーのリストを手元に持っておきたいのでマッチング中のユーザーをフィルタリングして取得します
DMの送り手のユーザーのデータはログインユーザーであるので送り手のユーザーはrequest.userで取得ができます
ここでマッチングしているユーザーとは視点を変えれば送り手にいいねを送り返しているユーザーでもあるので、受け手視点の受け手側に送り手が含まれているマッチングモデルのデータをまずは取得してloversに格納します
loversには受け手が送り手(approaching)となっている視点のマッチングモデルのデータが含まれているので、そのapproachingのユーザーIDを使用してユーザーモデルをフィルタリングすると、マッチングしているユーザーのデータだけを取得することができます
上記の内容を実装した結果は以下の通りになります
serializers.py
from django.db.models import Q class MatchingFilter(serializers.PrimaryKeyRelatedField): def get_queryset(self): request = self.context['request'] lovers = Matching.objects.filter(Q(approached=request.user) & Q(approved=True)) list_lover = [] for lover in lovers: list_lover.append(lover.approaching.id) queryset = get_user_model().objects.filter(id__in=list_lover) return queryset
ちなみにMatchingFilterはPrimaryKeyRelatedFieldを継承していますがこれは、ModelSerializer を使用する場合に扱うフィールドが外部キーで別テーブルを参照している場合があるので、紐付いたレコードをどのように表示するかを指定するためのものです
基本的には今回のようにフィルタリングをするために使用されることが多そうです
ModelSerializer を使っていて外部キーのフィールドを扱っていてもフィルタリングなどの処理をする必要がなければPrimaryKeyRelatedFieldを特に意識せずともDjangoがよしなに全件取得を行ってくれるようです
DMシリアライザー実装
ダイレクトメッセージシリアライザーでは受け手receiverに格納されているデータは上記で作成したフィルタリングを使用してマッチングされているユーザーのみを扱うようにする
他は同じです
serializers.py
from .models import DirectMessage class DirectMessageSerializer(serializers.ModelSerializer): created_at = serializers.DateTimeField(format='%Y-%m-%d %H:%M:%S', read_only=True) receiver = MatchingFilter() class Meta: model = DirectMessage fields = ('id', 'sender', 'receiver', 'message', 'created_at') extra_kwargs = {'sender': {'read_only': True}}
これでシリアライザーの実装は一通り完了しました
次はViewを実装していきましょう
Viewsの実装
シリアライザーの次はViewを実装していきましょう
Viewの大きな役割としてはURIのエンドポイントごとにどういった処理を実装させるかを記述します
シリアライザーで処理のCRUDのインターフェースは作成されているので、Viewで記述する処理内容としてはCRUDのうち何を使用するのかとパーミッションを制御するのが主な実装内容となります
Djangoではビューの実装方法として関数ベースビューとクラスベースビューの二種類がありますが、本アプリではクラスベースビューを利用します
Djangoを利用する際はクラスベースビューを使用したほうが楽で見通しが良いです
処理はブラックボックス化しますが、非本質な処理を徹底的に排除しようという設計思想のDjangoフレームワークを利用する限りはブラックボックスは正義です
関数ベースビューは何をやっているかの処理が分かりやすくなりますがコード量が増えたり冗長になりがちなため特段理由がない限りはクラスベースビューを使用すればいいと思います
ブラックボックスを避けたい場合はSpringBootなどの別のフレームワークを採用するなど技術スタックの選定から検討をした方がいいです
それではViewの実装をしていきましょう
アプリ(basicapi)フォルダ下のviews.pyを編集していきます
クラスベースビューで使用するモジュール
クラスベースビューで使用するdjango-rest-frameworkのモジュールの機能を予習しておく
分からない場合はいったん流して後で眺めてもらったら良いです
generic viewsで使用可能なクラス
| クラス | 操作 |
|---|---|
| CreateAPIView | 登録(POST) |
| ListAPIView | 一覧取得(GET) |
| RetrieveAPIView | 取得(GET) |
| UpdateAPIView | 更新(PUT、PATCH) |
| DestroyAPIView | 削除(DELETE) |
他のクラスもあるが例えば以下のように上記のクラスの組み合わせとなる
ListCreateAPIView (POST、GET) = ListAPIView + CreateAPIView
viewsetsで使用可能なクラス
| クラス | 操作 |
|---|---|
| ModelViewSet | 登録取得更新削除(GETPOSTPUTPATCHDELETE) |
| ReadOnlyModel | 一覧取得・取得(GET) |
ModelViewSetはCRUDすべてのメソッドがサポートされているメソッドで、特別な理由がない限りは思考停止でModelViewSetを使用すればよいクラスとなっています
ReadOnlyModelはデータの取得と一覧取得のみができるクラスで読み取り専用にしたい場合に利用します
特別な理由がなければこちらのviewsetsのどちらかのクラスを継承してViewは構築していけばよさそうです
RESTAPI ユーザー作成機能の実装
クラスベースビューを使用するためにはベースビュークラスを継承して利用します
基本的には思考停止でrest_framework.viewsets.ModelViewSetを継承したクラスを作成すればよいですが、ModelViewSetはCRUDすべてがサポートされているためユーザーのようにREST API経由で変更削除されたくないような場合にはCRUDの一部に特化したクラスを継承する方がいい場合があります
今回はREST APIではユーザー作成ができれば十分であるので汎用APIビューのCreateのみをサポートするgenerics.CreateAPIViewを継承したクラスを作成します
また今回settings.pyのREST_FRAMEWORK設定でREST APIはログインユーザー以外はデフォルトで使用禁止にしているので、ユーザー作成の処理だけは例外としてログイン不要で使用可能にする設定を付与します
クラスベースビューで実装したユーザー作成機能は以下のようなコードになります
views.py
from rest_framework.generics import CreateAPIView from .serializers import UserSerializer from rest_framework.permissions import AllowAny class CreateUserView(CreateAPIView): serializer_class = UserSerializer permission_classes = (AllowAny,)
処理を2行という圧倒的な短さで記述できるのがDjangoの驚異的なところです
余談 Djangoテンプレートの話
ちなみに余談ですが、今回はフロントエンドをNext.jsで実装する予定のためREST API用のベースビューのクラスを rest_framework モジュールから継承していますが、実はNext.jsを使用せずにDjangoだけで画面も作成できるのですが、Djangoだけで画面まで作成する場合はdjango.views.genericのモジュールから各種ベースクラスのビューを同様に継承することで同様に実装することも可能です
この場合はserialiserも不要となったりしてものすごく簡単にアプリを実装することができます
この辺の実装手順はDjangoのチュートリアルに載っています
Djangoだけでもアプリ開発は十分に作成できるんだよという話でした
下手なローコードツールを使うよりよっぽど便利で楽だと思います
RESTAPI ユーザー取得更新機能の実装
こちらも取得と更新だけに特化したいのでgenerics.RetrieveUpdateAPIViewを継承します
RetrieveUpdateAPIViewはリクエストにpkのURLのリクエストパラメータがあることが要求されることには注意が必要です
pkと言っているのは/users/{pk}/の{pk}の部分です
ユーザーの場合はUUIDがここに入ってきます
views.py
class UserView(RetrieveUpdateAPIView): queryset = User.objects.all() serializer_class = UserSerializer def get_queryset(self): return self.queryset.filter(id=self.request.user.id)
RESTAPI プロフィールCRUD機能の実装
プロフィールは登録・一覧取得・取得・更新・削除のすべての操作を行いたいためModelViewSetを継承します
使用ケースは以下を想定します
| 登録 | ユーザー作成時 |
| 一覧取得 | 異性のプロフィール一覧を取得 |
| 取得 | 特定の異性のプロフィール詳細を取得 |
| 更新 | 自身のプロフィール情報を変更する |
| 削除 | 拒否する |
perform_create メソッドはデータ作成時に登録するデータを指定できるメソッドのことでProfileViewSetではログイン中のユーザーをProfileモデルの外部キーであるuserに登録します
get_querysetでは異性のユーザーをフィルタリングしています
ログインユーザーが男性の場合は女性、ログインユーザーが女性の場合は男性のプロフィール一覧をフィルタリングして取得します
views.py
from rest_framework.viewsets import ModelViewSet from .models import Profile from .serializers import ProfileSerializer from rest_framework import status from rest_framework.response import Response class ProfileViewSet(ModelViewSet): queryset = Profile.objects.all() serializer_class = ProfileSerializer def get_queryset(self): if hasattr(self.request.user, 'profile'): sex = self.request.user.profile.sex # Profile.SEX[0][0] = 'male', Profile.SEX[1][0] = 'female' if sex == Profile.SEX[0][0]: reversed_sex = Profile.SEX[1][0] if sex == Profile.SEX[1][0]: reversed_sex = Profile.SEX[0][0] return self.queryset.filter(sex=reversed_sex) return self.queryset.filter(user=self.request.user) def perform_create(self, serializer): serializer.save(user=self.request.user) def destroy(self, request, *args, **kwargs): response = {'message': 'Delete is not allowed !'} return Response(response, status=status.HTTP_400_BAD_REQUEST) def update(self, request, *args, **kwargs): response = {'message': 'Update DM is not allowed'} return Response(response, status=status.HTTP_400_BAD_REQUEST) def partial_update(self, request, *args, **kwargs): response = {'message': 'Patch DM is not allowed'} return Response(response, status=status.HTTP_400_BAD_REQUEST)
プロフィール削除に関してはAPIを経由して削除できない設計にします
なお、本マッチングアプリでのユーザーの削除はユーザーからなんらかの連絡手段を介して削除要請があった場合に該当するユーザーのis_activeを管理画面からfalseに変更する運用とします
RESTAPI 自己プロフィール取得更新機能の実装
次に自分のプロフィールを参照したり更新したりするためのviewの機能を作成します
クラスベースビューで単独データをとる場合もまずは queryset に [Model].objects.all() すなわち全件取得のクエリを置いておいてその後 get_queryset(self) のメソッドでデータをフィルタリングする実装が一般的なようです
views.py
from rest_framework.generics import RetrieveUpdateAPIView class MyProfileListView(RetrieveUpdateAPIView): queryset = Profile.objects.all() serializer_class = ProfileSerializer def get_queryset(self): return self.queryset.filter(user=self.request.user)
RESTAPI マッチング機能の実装
さてマッチングアプリで最も重要な機能であるマッチング機能を実装していきます
マッチングモデルのデータはログインユーザーがいいねを送ったデータとログインユーザーがいいねを受け取ったデータを取得します
またマッチングデータ作成時、すなわち、いいねを送る際はperform_createで送り手にログインユーザーを指定しておきます
またマッチングモデルには組み合わせ制約の条件を設定しているのでマッチングデータ作成時にバリデーションエラーが発生した場合に備えてtry exceptでエラーハンドリングを行います
また削除(DELETE)と差分更新(PATCH)は使用できないようにしておきます
views.py
from rest_framework.exceptions import ValidationError from .models import Matching from .serializers import MatchingSerializer from django.db.models import Q class MatchingViewSet(ModelViewSet): queryset = Matching.objects.all() serializer_class = MatchingSerializer def get_queryset(self): return self.queryset.filter(Q(approaching=self.request.user) | Q(approached=self.request.user)) def perform_create(self, serializer): try: serializer.save(approaching=self.request.user) except ValidationError: raise ValidationError("User cannot approach unique user a number of times") def destroy(self, request, *args, **kwargs): response = {'message': 'Delete is not allowed !'} return Response(response, status=status.HTTP_400_BAD_REQUEST) # partial_update(patch)は使用できるようにする # def partial_update(self, request, *args, **kwargs): # response = {'message': 'Patch is not allowed !'} # return Response(response, status=status.HTTP_400_BAD_REQUEST)
RESTAPI メッセージ送信機能の実装
メッセージのREST APIは送信を受信で機能が違うのでクラスビューを分けて実装します
まずはダイレクトメッセージの送信機能を実装します
データ取得はログインユーザーでフィルタリングします
データ作成時はログインユーザーを送り手に格納します
データ削除は出来ないようにします
メッセージ修正ができるようにし、メッセージ変更は基本的に差分変更(PATCH)で行う想定とします
views.py
from .models import DirectMessage from .serializers import DirectMessageSerializer class DirectMessageViewSet(ModelViewSet): queryset = DirectMessage.objects.all() serializer_class = DirectMessageSerializer def get_queryset(self): return self.queryset.filter(sender=self.request.user) def perform_create(self, serializer): serializer.save(sender=self.request.user) def destroy(self, request, *args, **kwargs): response = {'message': 'Delete DM is not allowed'} return Response(response, status=status.HTTP_400_BAD_REQUEST)
RESTAPI メッセージ受信機能の実装
受信したダイレクトメッセージは編集が出来てしまっては困るため読み取り専用とするのでReadOnlyModelViewSetを使用します
取得するデータは受信側がログインユーザーとなっているデータをフィルタリングします
views.py
from rest_framework.viewsets import ReadOnlyModelViewSet class InboxListView(ReadOnlyModelViewSet): queryset = DirectMessage.objects.all() serializer_class = DirectMessageSerializer def get_queryset(self): return self.queryset.filter(receiver=self.request.user)
以上で一通りのViewの実装は完了です
URL ルーティングを設定する
ではフロントが操作するためのURLのエンドポイントとどのViewの処理を実行するかを関連づけるルーティングの設定を行っていきましょう
では、アプリ(basicapi)フォルダのurls.pyを編集して実装していきましょう
ここで注意点として、Viewの実装で rest_framework.generics のモジュールのクラスを継承したクラスベースビュー(今回はCreateAPIView, RetrieveUpdateAPIViewを継承しているもの)はルーティングではurlpatternsに直接記載することができるが、rest_framework.viewsets モジュールクラスを継承したクラスベースビュー(ModelViewSet, ReadOnlyModelViewSetを継承しているもの)はrest_framework.routersを使用してルーティングしなければいけないという違いがある
routers.registerの第一引数はURLのパス名となる
urls.pyのコードは全量を記載しておく
urls.py
from rest_framework.routers import DefaultRouter from django.urls import path from django.conf.urls import include from .views import CreateUserView from .views import ProfileViewSet from .views import MyProfileListView from .views import MatchingViewSet from .views import DirectMessageViewSet from .views import InboxListView app_name = 'basicapi' router = DefaultRouter() router.register('profiles', ProfileViewSet) router.register('favorite', MatchingViewSet) router.register('dm-message', DirectMessageViewSet) router.register('dm-inbox', InboxListView) urlpatterns = [ path('users/create/', CreateUserView.as_view(), name='users-create'), path('users/<pk>', UserView.as_view(), name='users'), path('users/profile/', MyProfileListView.as_view(), name='users-profile'), path('', include(router.urls)), ]
Djasorを利用したときのJWTでのユーザー認証のエンドポイントについて
なお、ユーザー認証すなわちログインログアウトのための機能は djoser のモジュールを最初にインストールしたので、すでに/authen/のエンドポイントで利用することができるようになっている
今回はJWTを利用するので/authen/jwt/create/を利用する
JWTでのユーザーログイン認証の仕組みとしては、リクエストbodyにemail: [email]、password: [password]を正しく入力して、/authen/jwt/create/のエンドポイントを呼ぶと、JWTが生成されログイン成功となります
ログイン後は生成されたトークンを利用してユーザーを認証する仕組みとなっています
以上でREST APIの一通りの実装は完了です
次は動作確認を行っていきましょう
次回予告
長くなったのでここで一度区切ります
次回の記事はこちらです
https://shinseidaiki.hatenablog.com/entry/2022/02/19/214217shinseidaiki.hatenablog.com
マッチングアプリを個人開発する~Djangoバックエンドその4 動作確認~
この記事はマッチングアプリを自作してみようと試みている記事です
バックエンドを実装しています
前回までの記事はこちらです
https://shinseidaiki.hatenablog.com/entry/2022/02/19/214008shinseidaiki.hatenablog.com
前回はREST API作成まで終わりました
今回は実装したREST API機能がちゃんと動くかを動作確認していきます
目次
- はじめに
- ※おしらせ
- 要件定義
- 開発環境
- バックエンド開発 Django
- 次回予告
- アーキテクチャ設計
- Djangoの初期設定~設定項目の編集~
- Modelの実装
- 管理者Adminサイトの整備
- 次回予告
- 今後の課題
- Serializersの実装
- Viewsの実装
- URL ルーティングを設定する
- 次回予告
- 動作確認
- 確認するエンドポイントの整理
- ユーザー新規作成とJWT認証(/api/authen/jwt/createと/api/users/create/)
- ログイン中のユーザー情報の取得と更新(/api/users/{pk}/)
- ログイン中のユーザー情報の取得と更新(/api/users/profile/{pk}/)
- ログイン中のプロフィール新規作成と異性プロフィール一覧取得(/api/profiles/)
- プロフィール取得(/api/profiles/{pk})
- マッチングデータ作成・マッチング一覧取得(CREATE, GET /api/favorite/ )
- マッチングデータ取得・更新(GET, PATCH /api/favorite/{pk})
- DM送信側処理(/api/dm-message/ )
- DM受信側一覧取得(GET /api/dm-message/, /api/dm-message/{pk})
- トークン期限切れ確認
- 次回予告
- 決済機能のStripeについての概要
- アーキテクチャ設計
- 各種機能を使用するための設定と環境変数を実装する
- Modelを実装する
- ルーティングURLの実装をする
- Viewを実装する
- 動作確認
- 次回予告
- はじめに
- おしらせ
- Flutterとは
- Flutter環境構築
- 開発環境
- Flutter プロジェクトを作成する
- Flutterでコードを実装していく
- ログイン機能の実装
- 自己プロフィール
- プロフィール一覧閲覧機能&いいね機能の実装
- メッセージ機能の実装
- 終わりに
動作確認
それではAPIが想定通り正しく動作するか確認を行っていきましょう
DjangoはRest APIのテスト実行環境も用意されておりサポートが手厚いのでここでもDjangoにおんぶに抱っこされながら動作確認をしていきましょう
DjangoのREST APIテスト実行環境を使用するにあたって必要なことはサーバーを起動して/api/のエンドポイントにアクセスするだけです
さてそれではPyCharmの△ボタンを押してサーバーを起動しましょう
確認するエンドポイントの整理
確認するエンドポイントの整理をしておきましょう
前回の記事でURL設計を作成しましたが、この動作確認ではそのエンドポイントの各挙動を確認していきます
これらは後でテストケースとしても使用できます
| エンドポイント | メソッド | 確認内容 |
| /api/authen/jwt/create | POST | ログイン後JWTトークンが返ってくる |
| /api/users/create/ | POST | 認証なしでユーザー新規作成ができる |
| /api/users/{pk}/ | GET, PATCH | pkで指定したユーザーの情報が取得できることを確認する。pkで指定したユーザーのemailとパスワード更新ができてパスワードはハッシュ化されている。ただしpkは自分自身に限り、他人のpkは使用しても何もできないことも確認する。PATCHのみの使用を想定し、PUTの使用は想定しない |
| /api/users/profile/{pk} | GET, PUT, PATCH | pkで指定したユーザーのプロフィール情報の取得ができることを確認する。pkで指定したユーザーのプロフィール情報の更新ができることを確認する。ただしpkは自分自身に限り、他人のpkは使用しても何もできないことも確認する。 |
| /api/profiles/ | CREATE, GET | ユーザーのプロフィールを作成できることを確認する。すでに自身のプロフィールが作られている場合は作成できないことを確認する。異性だけがフィルタリングされたプロフィール一覧が取得できることを確認する。自分のプロフィールがない状態では誰のプロフィールも取得できないことを確認する |
| /api/profiles/{pk} | GET | pkで指定したユーザーのプロフィールが取得できることを確認する。このエンドポイントでは更新(UPDATE)はできないことを確認する |
| /api/favorite/ | CREATE, GET | いいねすなわちマッチングレコードが作成できることを確認する。approachingとapproachedが自分であるようにフィルタリングされたマッチングモデルのデータ一覧を取得できることを確認する。 |
| /api/favorite/{pk}/ | GET, PATCH | approvedをtrue, falseに変更することができることを確認する。PATCHの利用を想定しUPDATEは想定しない |
| /api/dm-message/ | CREATE, GET | approavedがTrueでかつapproach"ed"が自分であるマッチングデータが存在する場合にDMデータが作成できることを確認する。senderが自分自身のDMだけが一覧で取得されることを確認する。 |
| /api/dm-message/{pk}/ | GET, UPDATE | pkで指定したDMを取得できる。DMのメッセージを変更できる。PATCHの利用を想定しUPDATEは想定しない。 |
| /api/dm-inbox/ | GET | receiverが自分のDM一覧が取得できることを確認する。 |
| /api/dm-inbox/{pk}/ | GET | pkで指定したDMを取得できる。更新はできないことを確認する |
/api/authen/jwt/create/と/api/users/create/以外はJWTトークンなしではアクセスを拒否されることも確認しておく
ユーザー新規作成とJWT認証(/api/authen/jwt/createと/api/users/create/)
サーバ起動後まずはhttp://127.0.0.1:8000/api/にアクセスしましょう
以下のような画面が表示されているはずです

認証情報が含まれていないといわれて接続を拒否されているのはデフォルトでログインユーザーのみしかAPIにアクセスできないとしているため正しい挙動をしていることが分かります
それではまずはユーザーを作成してそのユーザーのJWTの認証情報を手に入れましょう
手順は以下です
ユーザー新規作成
http://127.0.0.1:8000/api/users/create/
にアクセスします
このエンドポイントはあらゆるアクセスを許可しているのでアクセスできるはずです
以下のように任意のユーザーを作成しましょう

POSTの送信ボタンを押すとレスポンスが返ってきます
{ "email": "user1@user.com", "username": "user1", "id": "uuid-665e........................................." }
さて管理画面で作成できていることを確認しましょう
http://127.0.0.1:8000/adminのusersを確認しましょう
作成したユーザーがあればよいです
そしてここで、本アプリの特性ですが、作成したユーザーのis_activeはデフォルトでFalseとしていたので管理画面上で今作ったユーザーを使えるようにアクティベーションしておきます
is_activeフィールドを見つけてチェックを入れておいてください

ログイン認証処理とJWT発行
ではユーザーが作成できたので、先ほど作ったemailとpasswordを使用してJWTトークンを発行しましょう
Djoserのモジュールを今回利用しているので/authen/jwt/create/のエンドポイントに正しいemailとpasswordをリクエストボディに詰めて送信すると認証が成功しJWTトークンがレスポンスのaccessパラメータに格納されて返ってきます
返ってきたaccessのパラメータの値をトークンといい今回はJWTトークンという名前がついています
JWTの認識をつけるためにトークンに接頭辞としてJWT をつけるようにsettings.pyで設定しているのですが、取得した[JWT Token] をその後のリクエストに「JWT [JWT Token]」とした値でくっつけて送信すると認証済みユーザーとしてアクセスを許可する仕組みとなっています
この流れがいわゆる世間でいうログインです
それでは動作確認していきますが、ここで新しいToolを使用します
POSTMANというAPIのテスト実行環境を用意してくれるソフトウェアを利用します
まずはインストールして起動してください
www.postman.com
これはDjangoの用意してくれているREST API実行環境と同じようなものなのですがPostmanの方がより機能が多く代表的な機能としてはリクエストボディにパラメータを詰めてリクエストを投げることができます
それではPostmanの以下のような画面を開きます

必要な項目を埋めていきます
メソッド:POST
URL:http://127.0.0.1:8000/authen/jwt/create/
body>form-data: email, password の各値
を正しく入力してSendを押します
するとjsonが返ってくるはずです
このaccessの方の値を控えておきます
{ "refresh": "........................................................", "access": "jwttoken........................................." }
値を控える際は以下のように「JWT」の文字を接頭語に着けて控えておきます
後で使用します
「JWT jwttoken.........................................」
さて、JWTが発行されたので先ほど作成したuser1はログイン済みということになりました
では先ほどアクセスを拒否された/api/エンドポイントに再度アクセスしてみましょう
JWTを使用したアクセス認証
ここでまた別のツールを利用します
今回使用するのはテスト環境で利用するためのトークンであるため、ほかのサービスに使うような本物のトークンと混じってしまうと大変です
しかしそうならないためのサービスがグーグルクロームの拡張機能にあります
ModHeaderという拡張機能をインストールしてください
インストールして有効化すると右上に表示されます
これを利用していきます

RequestHeaderに先ほど控えたJWTトークンを貼り付けてトークンを使用するチェックを付けます

![]()
これでトークンがリクエストヘッダーにくっついてくるようになりました
ではhttp://127.0.0.1:8000/api/にアクセスしましょう
すると先ほどとは違ってapiの他のエンドポイントを見せてもらえるようになりました
これで認証が通っていることが確認出来ました
成功です

それではこの認証情報を使いながら他のエンドポイントの動作確認を行っていきましょう
ログイン中のユーザー情報の取得と更新(/api/users/{pk}/)
それでは次にユーザーの情報を取得・更新できるエンドポイントが想定通り動作するか確認していきます
このエンドポイントを利用するためにはユーザーのIDが必要になるのでIDが分からない人は管理者画面にアクセスしてユーザーのIDを引っ張って決ましょう
管理画面の一覧にIDを表示させている場合はこちらから
EMAIL ID パスワード
user1@user.com uuid-......................... hashed-password............
表示させるのを忘れている人は詳細更新画面でのURLからも引っ張ってくることができます
http://127.0.0.1:8000/admin/basicapi/user/[uuid-..........................]/change/
IDを控えたら/api/users/{pk}/のpkに控えたIDを入力してアクセスしましょう
アクセスできていたこのような画面になっているはずです

下の入力フォームを使っても変更できますが、ユーザー情報はemailもユーザー名もパスワードも別々の画面を経由して編集することが多いと思いますので、差分更新のPATCHを利用して更新できるかを確認してみようと思います
なお差分更新のPATCHはDjango REST FRAMEWORKの画面では起動できないようなので、PATCHに関してはポストマンで動作確認しましょう
ちなみにPATCHの実行はPUTにpatial=trueを引数に持たせて実装しているようなので、Djangoの実行環境でもがんばったらもしかしたらできるかもしれないですね
emailとユーザー名のPATCH変更
まずはemailの変更ができるかを試します
ポストマンで先ほどのように必要な項目を埋めていきます
メソッド:PATCH
URL:http://127.0.0.1:8000/api/users/{pk}
body>form-data: email, username の各値
を正しく入力してSendを押します
すると更新後に再度GETが実行されてユーザー情報が更新されたjsonデータが返ってきます

管理画面でも更新されていることが確認しておいてください
emailとユーザー名のPATCH変更
次にpasswordの変更ができるかを試します
ポストマンで先ほどのように必要な項目を埋めていきます
メソッド:PATCH
URL:http://127.0.0.1:8000/api/users/{pk}
body>form-data: password の値
を正しく入力してSendを押します
パスワードは書き込み専用なのでjsonでは返ってこないので管理画面で更新されていることを確認します
確認ポイントとしてはちゃんとパスワードがハッシュ化されていることとパスワード変更前のハッシュ値と変更後のハッシュ値が違うことを確認します
また/authen/jwt/create/のJWT認証エンドポイントで新しく変更したemail と passwordで認証が通るかを念のために確認するのもよいでしょう

他のユーザーのidをpkに入れても見れないことを確認する
APIからでも管理画面からでもどちらでもいいですが、別のユーザーを作成しましょう
ちなみにこういったデータ動作確認作業に管理画面(/admin)は大変重宝します
自分でいろいろいじってみてください
さて実行しましょう
GET /api/users/[別ユーザーuuid]
以下のように404が確認できれば確認OKです
HTTP 404 Not Found Allow: GET, PUT, PATCH, HEAD, OPTIONS Content-Type: application/json Vary: Accept { "detail": "見つかりませんでした。" }
ログイン中のユーザー情報の取得と更新(/api/users/profile/{pk}/)
それでは次にユーザーの情報を取得・更新できるエンドポイントが想定通り動作するか確認していきます
このエンドポイントを実行するにはプロフィールが存在しないといけないので、管理画面で先ほど作ったユーザーのどちらでもいいので、Profileクラスのデータを作成してください
なお、今回管理画面ではUserにProfileクラスをインクライン(挿入)しているためUserの中でProfile項目を編集できるようにしています
Profileクラスの詳細画面でProfileデータを追加してもいいですが、外部キーを入れるなど誰のデータをいじっているかわからなくなったりするのでadminにインクラインの設定を加えて作業しやすくしておくことは結構大事だったりします
ユーザー情報取得の確認(GET)
さてプロフィールを作成したらユーザーのUUIDをpkに入力して/api/users/profile/{pk}/にアクセスしましょう
なお、ログインしていない方のユーザーを使っている場合は404が出るはずなのでJWT認証の作業をもう一度行ってください
さてアクセスが成功して以下のような結果が返ってくればデータ取得はうまくいっていることが確認できました
HTTP 200 OK Allow: GET, PUT, PATCH, HEAD, OPTIONS Content-Type: application/json Vary: Accept { "user": "uuid-.............................", "is_special": false, "is_kyc": false, "top_image": "http://127.0.0.1:8000/media/images/top_image/[path]", "nickname": "ユーザー1_男性", "created_at": "2022-02-12 02:26:00", "updated_at": "2022-02-12 03:24:18", "age": 20, "sex": "male", "height": null, "location": null, "work": null, "revenue": null, "graduation": null, "hobby": null, "passion": "slowly", "tweet": null, "introduction": "", "send_favorite": 0, "receive_favorite": 0, "stock_favorite": 1000 }
ログイン中のプロフィール新規作成と異性プロフィール一覧取得(/api/profiles/)
プロフィール新規作成の確認(CREATE)
さてプロフィールの新規作成確認をしましょう
プロフィールをすでに作成してしまっている場合は新しくユーザーを作るなどしましょう
別のユーザーを使って動作確認する場合は別ユーザーのJWTを取得してModHeaderで切り替えて利用していきましょう
http://127.0.0.1:8000/api/profiles/ にアクセスします
プロフィールがない状態では空リストが表示されていることが確認できます
ではプロフィールを埋めていきましょう
ここで年齢などのバリデーションが効いているかも同時に確認しておきましょう
特に未成年チェックは必須です
作成した後にもう一度作成しようとしてみるとエラー(IntegrityError)で返されることも確認する
念のために作成されたユーザーは最初に入力したデータのままで作成されているか管理画面でも確認しておきましょう
異性をフィルタリングしたプロフィール一覧取得の確認(GET)
上記でプロフィールを作成したら再度http://127.0.0.1:8000/api/profiles/にアクセスします
ログインさせているユーザーの性別と違うユーザーだけが表示されていれば確認は完了です
同じことですがデータがない場合は新たに作成して確認みてください
プロフィール取得(/api/profiles/{pk})
プロフィールのあるユーザーを使用して/api/profiles/{異性ユーザーのUUID}にアクセスします
取得できれば成功です
なお、自身と同性ユーザーは取得できないことも確認しておきます
またPUTボタンを押して更新できないことを確認します
マッチングデータ作成・マッチング一覧取得(CREATE, GET /api/favorite/ )
次にマッチング機能(いいね機能)の動作を確認していきます
/api/favorite/にアクセスします
approachedでユーザーを選択してマッチングデータを作成します
ユーザは同性も選べますが異性を選んでおきましょう
POSTで作成が終わると再び同じURLでGETを実行しましょう
他にもマッチングデータを作成して想定通りのデータが取得できているか確認しましょう
自分に関連するデータだけが取得されているかを確認できれば成功です
マッチングデータ取得・更新(GET, PATCH /api/favorite/{pk})
次にマッチング機能(いいね機能)の更新処理の動作を確認していきます
/api/favorite/{自分と関連するマッチングデータのpk}にアクセスします
今回アクセスするマッチングデータのIDはUUIDではなくIntであることに注意します
マッチングデータのIDはhttp://127.0.0.1:8000/api/favorite/にアクセスしたときに表示されているIDから取得するか、管理画面の一覧画面にadmin.pyのlist_displayに'id'を追加するか、詳細画面の○○/{pk}/changeのpkを見ることでも取得できます
正しくアクセスできてPUTで更新処理が行えれば成功です
また/api/favorite/{自分と関連しないマッチングデータのpk}にアクセスすると404 Not Foundがレスポンスされることも確認しておきます
DM送信側処理(/api/dm-message/ )
次にDMの送信側の処理を動作確認します
送信側DM一覧取得(CREATE, GET)
approvedがTrueでapproachedに自分を指定しているマッチングデータのapproachingに対応するユーザーをreceiverに選択してmessageデータを作成するとデータが作成できてsenderに自分自身が入力されていることを確認します
またsenderが自分自身のmessageデータ一覧を取得できることを確認します
DM送信取得・更新取得(GET, PATCH /{pk}/ )
次に/api/dm-message/{自身がsenderになっているDMのpk}/にアクセスして送信済のデータを取得できるかを動作確認します
senderが他人のpkの場合は404 Not Foundであることも確認する
また、POSTMANで/api/dm-message/{自身がsenderになっているDMのpk}に対してPATCHを用いてメッセージの修正を目的とした更新が行えるかを確認する
なおJWTトークンをリクエストヘッダーに含めることを忘れないように
DM受信側一覧取得(GET /api/dm-message/, /api/dm-message/{pk})
次にDMの受信側の処理を動作確認します
まずは/api/dm-message/にアクセスしてreceiverが自分を差しているDMの一覧が取得できるか確認する
次に/api/dm-message/{自分がreceiverであるDMのpk}にアクセスしてDMが取得できるか確認する
receiverが自分ではないDMは404 Not Foundが返ってくることを確認する
トークン期限切れ確認
トークンの有効期限は今回1日に設定しているので期限切れで/api/のエンドポイントにアクセスするとアクセスが拒否されることを確認する
GET /api/ HTTP 401 Unauthorized Allow: GET, HEAD, OPTIONS Content-Type: application/json Vary: Accept WWW-Authenticate: JWT realm="api" { "detail": "Given token not valid for any token type", "code": "token_not_valid", "messages": [ { "token_class": "AccessToken", "token_type": "access", "message": "Token is invalid or expired" } ] }
これで動作確認は終了です
想定通りの挙動になっていない場合はデバッグを行いましょう
次回予告
動作確認を終えて、これにてバックエンドの機能はほとんど完成しました
ところで本アプリは新規ユーザー作成時にアカウントをデフォルトで非アクティブにしているので次はアカウント作成時にメールを送信し、メール中に記載したURLからクレジットカード決済画面(Stripe)にアクセスして、決済が成功したユーザーをアクティブとする処理を加えていきましょう
なお対面などで年齢確認を行ってユーザーをアクティブにする運用の場合はクレジットカード決済を使用せずにその都度管理者が管理画面でユーザーのアクティベーションを手動で行うという方法もあるかもしれない
続きはこちらです
https://shinseidaiki.hatenablog.com/entry/2022/02/19/214828shinseidaiki.hatenablog.com
マッチングアプリを個人開発する~Djangoバックエンドその5 決済機能実装~
この記事はマッチングアプリを自作してみようと試みている記事です
バックエンドを実装しています
前回までの記事はこちらです
https://shinseidaiki.hatenablog.com/entry/2022/02/19/214217shinseidaiki.hatenablog.com
前回はバックエンドのコア機能の動作確認を終えました
今回は積み残しのタスクとしての決済機能の実装を行っていきます
決済機能は不要だという方はこの章は飛ばしてもらってよいです
また、以下で紹介する実装は実運用には到底耐えないと思いますので本番運用を目指す方は更なるブラッシュアップを行ってください
目次
- はじめに
- ※おしらせ
- 要件定義
- 開発環境
- バックエンド開発 Django
- 次回予告
- アーキテクチャ設計
- Djangoの初期設定~設定項目の編集~
- Modelの実装
- 管理者Adminサイトの整備
- 次回予告
- 今後の課題
- Serializersの実装
- Viewsの実装
- URL ルーティングを設定する
- 次回予告
- 動作確認
- 確認するエンドポイントの整理
- ユーザー新規作成とJWT認証(/api/authen/jwt/createと/api/users/create/)
- ログイン中のユーザー情報の取得と更新(/api/users/{pk}/)
- ログイン中のユーザー情報の取得と更新(/api/users/profile/{pk}/)
- ログイン中のプロフィール新規作成と異性プロフィール一覧取得(/api/profiles/)
- プロフィール取得(/api/profiles/{pk})
- マッチングデータ作成・マッチング一覧取得(CREATE, GET /api/favorite/ )
- マッチングデータ取得・更新(GET, PATCH /api/favorite/{pk})
- DM送信側処理(/api/dm-message/ )
- DM受信側一覧取得(GET /api/dm-message/, /api/dm-message/{pk})
- トークン期限切れ確認
- 次回予告
- 決済機能のStripeについての概要
- アーキテクチャ設計
- 各種機能を使用するための設定と環境変数を実装する
- Modelを実装する
- ルーティングURLの実装をする
- Viewを実装する
- 動作確認
- 次回予告
- はじめに
- おしらせ
- Flutterとは
- Flutter環境構築
- 開発環境
- Flutter プロジェクトを作成する
- Flutterでコードを実装していく
- ログイン機能の実装
- 自己プロフィール
- プロフィール一覧閲覧機能&いいね機能の実装
- メッセージ機能の実装
- 終わりに
決済機能のStripeについての概要
今回決済機能にはStripeを利用します
Stripeとはクレジット決済機能代行サービスです
さらにクレジットのみならず Apple Pay や Google Play の電子マネーの決済にもデフォルトで対応しており、豊富なドキュメントと実績があり、料金は決済時の手数料の3%のみというわかりやすさで、アプリケーション開発における決済代行機能プラットフォームのデファクトスタンダードといってもよいでしょう
ちなみに一昔前はJCBに非対応でなかなか大きな穴がありましたが今現在は対応されており、Stripeの使用にあたって突っ込みが入るところはほぼなくなっていると思います
なお初めのうちは決済機能の自作を考えるかもしれないですが、決済に関しては各種法令や業務要件・仕様が非常にシビアなため個人開発で決済機能を自作することは全く現実的ではないと思われます
素直に代行機能を利用する方が賢明だと思います
なお、今回マッチングアプリの実装で決済機能を導入する最大の目的は年齢確認のためですので、機能としては最もシンプルな実装にとどめたいと思います
アーキテクチャ設計
ではシステム構成とREST APIのURIのエンドポイントとその機能をざっくり整理しておきます
システム構成図
やはり図を書いた方が何をこれからやるかがはっきりとするのでまずはお絵描きをします
図のような実装を行っていきます

画面のユーザー新規作成の段階では仮登録としてis_active=Falseであり、Emailに記載するURLからクレジット決済機能提供サービスのStripe経由でクレジット決済が完了したユーザーのみをアクティベーションします
ちなみにこれらの実装の目的としては不正なユーザーが簡単にユーザー登録をできないようにすることにあります
ただし残念ながらこの構成で目的が適うのかは私も知見がないのでわかりません
ここから先は多分大丈夫だろうという前提で実装を行っていきます
REST API設計
| エンドポイント | メソッド | 機能内容 |
| /api/users/{str:token_id}/payment/ | GET | Stripeが提供するクレジット決済決済画面にリダイレクトして、決済が成功した場合はユーザーアクティべーションのエンドポイントへリダイレクトし、キャンセルされた場合はキャンセルのエンドポイントにリダイレクトする。有効でないToken_idを指定したり、ユーザー新規作成からXX日以内にアクセスを行わない場合はStripeへのリダイレクトを行わず、決済画面へリダイレクトを行わない旨のメッセージを返す |
| /api/users/payment/cancel/ | GET | Stripeクレジット決済画面でキャンセルさせた場合にリダイレクトされてキャンセルされた旨のメッセージをレスポンスする |
| /api/users/{str:activate_token}/activation/ | GET | Stripeクレジット決済画面で決済が成功した後に呼び出されてユーザーのis_activeをtrueにすることでアクティベーションする。なおAPIとして公開されているためactivate_tokenは秘匿しておく必要がある |
まとまりました
では実装をしていきましょう
ブログの説明の構成的には、アクティベーション機能とEmail機能とクレジット決済機能単位で分けた方が分かりやすいとは思いますが、コードがごちゃごちゃするので全機能をまとめてsettings.pyやmodel.pyから説明していきます
それぞれの機能の実装方法は今後切り分けたほうが分かりやすければブログにまとめたいとおもいます
各種機能を使用するための設定と環境変数を実装する
まずはEmailの機能とStripeを利用するための設定を行いましょう
Email機能はDjangoがデフォルトで用意しているものを使います
Stripeアカウント登録とStripeのインストール
Stripeアカウントを持っていない方はアカウントを作成してください
stripe.com
メール認証も済ませてアカウントをアクティベーションしてください
アカウントを作成するとダッシュボードが表示されていると思います
デフォルトでテスト環境になっていることを確認します
なお本番利用する場合はアプリサービスごとに都度申請が必要であるのでテスト環境だと思ってたら実は本番だった的なうっかり課金が起こってしまうことがないので初心者やちょっとAPIを試してみたいユーザーには心強い存在ですね
さて、アカウントを作成したらStripeパッケージをインストールします
pip install stripe
settings.pyの実装
次にDjangoでEmailとStripeを利用するための設定を行います
Email機能の設定
DjangoでEmailを利用したい場合はsettings.pyにEMAIL_BACKENDを設定すればよいことになっています
本番環境ではdjango.core.mail.backends.smtp.EmailBackendを使用しますが、テスト環境でメールの確認をしたい場合にはdjango.core.mail.backends.console.EmailBackendを用いることがコンソールに結果が出力されるのでお手軽に試したい場合はこちらを使うのが便利です
メールを実際に送信する場合はメールサーバーを用意したり、gmailを利用したりしなければならないので少し手間がかかるので基本的にはコンソール出力でテスト環境は対処します
settings.py
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' # コンソールにメール内容を表示する # EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' # メールを送信する
またメールには送り手の情報などが必要になるので必要項目をsettings.pyに記述します
settings.py
EMAIL_HOST = env.str('EMAIL_HOST') EMAIL_PORT = env.int('EMAIL_PORT') DEFAULT_FROM_EMAIL = env.str('DEFAULT_FROM_EMAIL') EMAIL_HOST_USER = env.str('EMAIL_HOST_USER') EMAIL_HOST_PASSWORD = env.str('EMAIL_HOST_PASSWORD') EMAIL_USE_TLS = env.bool('EMAIL_USE_TLS')
PASSWORDなどおよそコード上に残したくないものを設定するのでこれらの値は.envファイルに記述して環境変数化します
以下はgmailを使用する場合の設定です
EMAIL_HOST_PASSWORDはgmailの生パスワードか二段階認証を有効化した時に使えるアプリパスワードを設定します
私は横着して生パスワードで一度試しましたが、googleアカウントのセキュリティで弾かれたので、弾かれた場合はGoogleアカウントの設定(Chromeブラウザの設定ではないことに注意)>セキュリティ>安全性の低いアプリのアクセスを「オン」にすれば使用できるようなります
しかしGoogleサービスはスパム判定とかどうなってるのかよくわからず怖いのでこういったものを試すなら吹っ飛んでも大丈夫なGoogleアカウントを用意しておく方が無難な気がします
(smtp.live.comというホットメールを使えるホストサーバーがあるらしい)
.env.dev or .env.prod
EMAIL_HOST=smtp.gmail.com EMAIL_PORT=587 DEFAULT_FROM_EMAIL=[YOUR.EMAIL] EMAIL_HOST_USER=[YOUR.EMAIL] EMAIL_HOST_PASSWORD=[YOUR.EMAIL.HOST.PASSWORD.BUT.APP.PASSWORD.FOR.GMAIL] EMAIL_USE_TLS=True
決済機能の設定
次にStripeの設定を行います
こちらは特にSTRIPE_API_SECRET_KEYが大事です
MY_URLはStripeの成功・キャンセル時のリダイレクトに使うために使用します
STRIPE_ITEM_PRICEはマッチングアプリの開始利用料を設定します
これらも環境変数化します
settings.py
STRIPE_API_SECRET_KEY = env.str('STRIPE_API_SECRET_KEY') MY_URL = env.str('MY_URL') STRIPE_ITEM_PRICE = env.str('STRIPE_ITEM_PRICE')
.env.dev or .env.prod
STRIPE_API_SECRET_KEY=[STRIPE API シークレットキー] MY_URL=http://127.0.0.1:8000 STRIPE_ITEM_PRICE=price_[STRIPE自動生成コード]
STRIPE_API_SECRET_KEYはダッシュボードの開発者向けのところで目隠しされているシークレットキーをコピーします
STRIPE_ITEM_PRICEに関してはドキュメントの販売商品の定義のところで自作した商品のコードを入力します
stripe.com
以下のようにテスト商品を作成することができるのでPRICE_IDに入った文字列をSTRIPE_ITEM_PRICEに格納する

Stripe例
line_items=[
{
# Provide the exact Price ID (for example, pr_1234) of the product you want to sell
'price': '{{PRICE_ID}}', # ドキュメントで商品の値段を設定すると ------> 'price_[PRODUCT_KEY]'になるはず
'quantity': 1,
},
],
設定はこれでおしまいです
次はモデルを作ります
Modelを実装する
次はモデルを実装します
仮登録ユーザーを本登録するアクティベーションの機能とメール送信機能を実装していきます
アクティベーションにはトークンを利用するのでトークンを格納するためのモデル作成していきます
アクティベーション機能の実装
現実世界で実際に運用されているWebサービスは通常、不正ユーザーの登録を防ぐために仮登録を行ってEmailの認証情報を入力させた後に本登録を完了させる実装が一般的かと思います
実装の具体的な方法はいろいろあるかと思いますが、今回は有効期限付きのトークンを発行して有効なトークンを持っているユーザーが期限内に決済を完了させた場合にアクティベーションをする実装とします
トークンを格納するためのモデルを作成する
まずは基本的なモデルを作ります
token_idは決済画面へリダイレクトするために用いてactivate_tokenは決済を通過した後のユーザーのアクティベーションのために用います
トークンとして用いるのは議論の余地がありそうですがトークンにはどちらもUUIDを使用しています
expired_atは有効期限です
models.py
class UserActivateTokens(models.Model): token_id = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False) user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) activate_token = models.UUIDField(default=uuid.uuid4) expired_at = models.DateTimeField() objects = UserActivateTokensManager()
ここで UserActivateTokens のモデルはユーザーの時のようにマネージャークラスを使っているのでマネージャークラスを実装します
トークンとユーザーのデータを検証してアクティベーションをする機能の実装
マネージャークラスは公式ドキュメントでは「Django のモデルに対するデータベースクエリの操作を提供するインターフェイスです」と説明されていますが、分かりにくいのでモデルの特殊なメソッド置き場みたいなイメージでいいと思います
さて、マネージャーの中のactivate_user_by_tokenの処理がまさしくユーザーのアクティベーションを行っている処理です
user.is_active = Trueをしている箇所からわかると思います
また前処理としてアクティブにするユーザーは 正しいactivate_tokenと、かつ、有効期限が切れていないデータを持っているユーザーだけにフィルタリングしています
つまりactivate_token(※実際に渡してるのはtoken_id)をユーザーを新規作成した本人にしか分からない渡し方、つまりemailで渡すと、トークンはその人しか持っていないはずなので、ここでアクティベーションされるユーザーは、ユーザーを新規作成した本人のアカウントであるという認証を行うことができる仕掛けとなっています
models.py
class UserActivateTokensManager(models.Manager): def activate_user_by_token(self, activate_token): user_activate_token = self.filter( activate_token=activate_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
トークンを発行する処理
さて、ユーザーを作成した時に同時にトークンを発行してそのトークンをemailに乗せて送信出来たら便利ですが、Djangoにはちゃんとそのためのシグナルズ django.db.models.signals という機能が備わっています
シグナルズを利用すると何らかの処理がプログラム上で起こったときにタイミングをプログラム上で明示しなくても条件に合致した場合に自動的にあらかじめ定義したメソッドを実行してくれる機能です
例えば今回利用するようなユーザーが作成された場合に自動的にトークンを発行してメール送信するといったような実
装です
シグナルズの使い方ですが、アノテーションを利用することで使えます
アノテーションとはメソッドの手前に@がつくものです
例えば、今回使う@receiver(post_save, )のようなものです
ちなみにシグナルズと混乱するかもですが、アノテーションをつけていると今回のようなメソッドの実行タイミングの制御やパーミッションの制御などに用いることができます
コード量を減らす工夫です
そして、@receiver(post_save, sender)ですがこれをつけるとsenderに定義したモデルが保存の処理を行うたびに直下のメソッドが自動的に呼び出されます
ここではユーザーが新規作成されたりフィールドが更新されて保存されたタイミングで呼び出されています
シグナリングを使用しなくてもviews.pyに処理をゴリゴリ書いてもいいですが、シグナリングを使用する方が見通しが良くなる場合はこちらで記述するのが得策です
そして、以下がトークンを発行する処理になります
userにはシグナルズでuser.save()メソッドを呼んだつまり新規作成されたユーザーのインスタンスが入ってきます
expired_atの有効期限は現在時刻にsettings.ACTIVATION_EXPIRED_DAYSで定義した3日を足した時刻を格納しています
アクティベーションのメソッドを呼び出すのはこの日時までだよという意味にあたるのがexpired_atになります
models.py
from django.db.models.signals import post_save from django.dispatch import receiver @receiver(post_save, sender=settings.AUTH_USER_MODEL) def publish_activate_token(sender, instance, **kwargs): if not instance.is_active: user_activate_token = UserActivateTokens.objects.create( user=instance, expired_at=datetime.now()+timedelta(days=settings.ACTIVATION_EXPIRED_DAYS), ) # .........Emailを送信する処理
またexpired_atの日付の格納に関してもAwareやNaiveなどのややこしい話があってこの実装ではDjangoのコンソールに警告文が出てしまうのですが、今回は気にしないことにします
この点に関しては記事を少しまとめたものがあるので暇があれば読んでください
qiita.com
トークンをEmailで送信する処理の実装
Emailで送信する処理ですが、Djangoはsend_mail()メソッドを呼ぶだけでメールを送ることができます
引数の意味はぱっと見でわかると思いますので以下実装で示します
上のpublish_activate_token()メソッドの続きにEmail処理を記述して以下のようにします
メール本文に{settings.MY_URL}/api/users/{user_activate_token.token_id}/payment/を記述しており、これをクリックするとStripeの決済画面に遷移する動きになっています
またpost_saveはマネージャークラスでis_activeをtrueにするときにも反応するのでアクティベーション完了のメールも送信する設定にしました
models.py
from django.core.mail import send_mail @receiver(post_save, sender=settings.AUTH_USER_MODEL) def publish_activate_token(sender, instance, **kwargs): if not instance.is_active: user_activate_token = UserActivateTokens.objects.create( user=instance, expired_at=datetime.now()+timedelta(days=settings.ACTIVATION_EXPIRED_DAYS), ) subject = 'Please Activate Your Account' message = f'URLにアクセスして決済を完了してください。\n {settings.MY_URL}/api/users/{user_activate_token.token_id}/payment/' if instance.is_active: subject = 'Activated! Your Account!' message = 'ユーザーが使用できるようになりました' from_email = settings.DEFAULT_FROM_EMAIL recipient_list = [ instance.email, ] send_mail(subject, message, from_email, recipient_list)
ルーティングURLの実装をする
Viewの前にルーティング設定であるurls.pyの実装を行っておきます
アーキテクチャ設計のところで記載したものと同じです
urls.py
from .views import activate_user from .views import pay_stripe from .views import pay_stripe_cancel urlpatterns = [ path('users/<uuid:token_id>/payment/', pay_stripe, name='pay-stripe'), # Stripe決済画面にリダイレクト path('users/payment/cancel/', pay_stripe_cancel, name='pay-stripe-cancel'), # 決済失敗時に実行 path('users/<uuid:activate_token>/activation/', activate_user, name='users-activation'), # 決済成功時にユーザーアクティベーションを実行 ]
Viewを実装する
Viewの実装では決済機能とユーザーアクティベーションの処理を実装します
決済機能の実装
Stripeを利用するための実装
今回のAPI機能はDjangoで作成するモデルをメインで使うよりはStripeの決済機能を使うロジックの方が多いので、メソッドベースのAPIViewを採用します
そして以下のアノテーションをつけてアクセス制御を行っています
@api_view(['GET'])
@permission_classes([AllowAny])
また、処理に関してはほぼStripeのドキュメントをそのまま利用しています
success_urlとcancel_urlはぱっと見でわかると思いますが成功時とキャンセル時のリダイレクト先を記載するところです
こちらのアプリで想定するURLに飛ばすように設定します
success_urlにはアクティベーション用のトークンをくっつけておきます
views.py
from rest_framework.decorators import api_view, permission_classes from django.shortcuts import redirect from django.conf import settings import stripe stripe.api_key = settings.STRIPE_API_SECRET_KEY @api_view(['GET']) @permission_classes([AllowAny]) def pay_stripe(request, token_id): try: # ......tokensの前処理 checkout_session = stripe.checkout.Session.create( line_items=[ { 'price': settings.STRIPE_ITEM_PRICE, 'quantity': 1, }, ], mode='payment', success_url=f'{settings.MY_URL}/api/users/{tokens.activate_token}/activation/', cancel_url=f'{settings.MY_URL}/api/users/payment/cancel/', ) except Exception as e: return str(e) return redirect(checkout_session.url, code=303)
token_idを受け取ってアクティベーションさせるメソッドにはactivate_tokenを渡す処理の実装
今回token_idとactivate_tokenでトークンを分けていましたが、これはtoken_idをもしusers/{str:token_id}/activation/のようにアクティベーションのURIに使ったとしたらユーザーがURLのエンドポイントの/activation/を推測できると決済を通さずにアクティベーションできてしまうというリスクがあるからでした
なので、token_idを決済画面のユーザー識別に使ってactivate_tokenをアクティベーションのトークンとして使い分ける実装まで行ったStripe決済画面呼び出しメソッド pay_stripe(request, token_id) は以下のようになります
といってもマネージャークラスで実装した処理とほとんど一緒です
これでトークンが流出するかUUIDが推測でもされない限り決済をスキップされる可能性がなくなりました
views.py
from .models import UserActivateTokens @api_view(['GET']) @permission_classes([AllowAny]) def pay_stripe(request, token_id): try: tokens = UserActivateTokens.objects.all().filter( token_id=token_id, expired_at__gte=datetime.now() ).first() if tokens is None: return Response({'message': 'トークン間違いもしくはトークンの有効期限切れです'}) checkout_session = stripe.checkout.Session.create( line_items=[ { 'price': settings.STRIPE_ITEM_PRICE, 'quantity': 1, }, ], mode='payment', success_url=f'{settings.MY_URL}/api/users/{tokens.activate_token}/activation/', cancel_url=f'{settings.MY_URL}/api/users/payment/cancel/', ) except Exception as e: return str(e) return redirect(checkout_session.url, code=303)
Stripeで決済をキャンセルしたときの処理の実装
キャンセルされたときの処理です
メッセージを返すだけです
Djangoで画面も作る実装の場合はHttpResponseを返しますが、今回のようにバックエンド特化のREST APIの場合はResponseを返すという違いがあります
views.py
from rest_framework.decorators import api_view, permission_classes from rest_framework.response import Response @api_view(['GET']) @permission_classes([AllowAny]) def pay_stripe_cancel(request): return Response({'message': '決済がキャンセルされました'})
ユーザーアクティベーションの処理の実装
決済が成功した場合はユーザーのアクティベーションを行うメソッドがリダイレクトされて呼び出されます
UserActivateTokens.objects.activate_user_by_token(activate_token)がアクティベーションの処理を呼び出しています
大まかには決済が成功して想定通りユーザーがアクティベーションされればactivated_userが返ってきて、何らかの障害やエラーが発生した場合には失敗のメッセージを送る想定としています
ただしこのメソッドは決済が絡んでくるためサービスの品質として絶対に落としてはいけないものであるので、本番環境ではこの処理の失敗時に特別なログを出力させて管理者に通知する実装をとった方が良いと思われます
余力があればそちらの実装も行っていきたいと思います
views.py
from .models import UserActivateTokens from rest_framework.decorators import api_view, permission_classes @api_view(['GET']) @permission_classes([AllowAny]) def activate_user(request, activate_token): activated_user = UserActivateTokens.objects.activate_user_by_token(activate_token) if hasattr(activated_user, 'is_active'): if activated_user.is_active: message = {'message': 'ユーザーのアクティベーションが完了しました'} if not activated_user.is_active: message = {'message': 'アクティベーションが失敗しています。管理者に問い合わせてください'} if not hasattr(activated_user, 'is_active'): message = {'message': 'エラーが発生しました'} return Response(message)
このメソッドではGETメソッドを使っていますが、Userモデルの変更を伴っているのにGETで呼び出していいのかなと思いつついい方法が思い浮かばなかったのでこの実装にしています
動作確認
さて簡単に動作を確認していきます
まずはDBの変更を伴っているのでマイグレーションを行ってから起動しましょう
py manage.py makemigrations py manage.py migrate py manage.py runserver
ユーザー作成時のトークン生成とEmail通知の確認
POSTMANでhttp://127.0.0.1:8000/api/users/create/にアクセスして、アカウントを作成しましょう
するとコンソールに次のようなEmailが返ってくることが確認できると思います
Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 8bit Subject: Please Activate Your Account From: super@user.com To: user5@user.com Date: Sat, 19 Feb 2022 11:27:48 -0000 Message-ID: <***********************************************> URLにアクセスして決済を完了してください。 http://127.0.0.1:8000/api/users/[token-id-uuid]/payment/ ------------------------------------------------------------------------------- [19/Feb/2022 20:27:48] "POST /api/users/create/ HTTP/1.1" 201 84
また管理画面でもユーザーが作成できていて、is_activeがFalseになっていることを確認します
またトークンが発行されていることも見ておきましょう
トークンテーブルを見るのはadmin.pyをいじって管理画面にテーブルを増やすか、CB Browswe for SQLiteのようなUIで操作が出来るフリーのツールをインストールしてきて、プロジェクトフォルダのdb.sqlite3を開けば確認することができます
トークンも同時に生成されていることが分かりました

Stripe決済機能とアクティベーション機能の確認
さて、emailの本文にあるhttp://127.0.0.1:8000/api/users/[token-id-uuid]/payment/にアクセスしましょう
Stripeの画面にリダイレクトしていることを確認します
さてテスト用の番号は以下のようになっているので試してみましょう
| 支払いが成功しました | 4242 4242 4242 4242 |
| 支払いには認証が必要です | 4000 0025 0000 3155 |
| 支払いが拒否されました | 4000 0000 0000 9995 |
4242 4242 4242 4242を入力してその他の項目も適当に埋めてOKを押します

チェックがかかりいつものDjangoのブラウザのRest API実行環境にリダイレクトされます
アクティベーションが成功した旨のメッセージが出ていれば成功です
{ "message": "ユーザーのアクティベーションが完了しました" }
逆にOKボタンを押さずに戻るボタンを押すなどするとキャンセルが起こります
キャンセルのリダイレクトも確認しておきましょう
Stripeを使えない場合のトラブルシュート
以下のようなエラーが発生した場合は、Stripeのアカウント名を変更する必要がある
InvalidRequestError at /pay/checkout/ In order to use Checkout, you must set an account or business name at https://dashboard.stripe.com/account.
ダッシュボードにアクセスし、プロフィール>アカウントのところで「名称未設定」のアカウントと表示されていればアカウント名を登録する
登録方法は 設定(歯車アイコン)>アカウントの詳細>アカウント名 で好きなアカウント名を入力する
保存を押すことでアカウント名は保存される
これで再度実行するとできるはずです
決済を通さないで不正にアクティベーションの実行を試みた場合に失敗するかを確認する
存在しないもしくは適当に自身で生成したuuidをactivate-token-uuidの部分に代入してアクティベーションを試みます
http://127.0.0.1:8000/api/users/[発行されていないactivate-token-uuid]/activation/
{ "message": "エラーが発生しました" }
失敗のメッセージが返ってきて、どのユーザーも勝手にアクティベーションされていないことを確認しておきます
これで、動作確認は終了しました
お疲れ様です
次回予告
これで実装したいバックエンドの機能は完成しました
次は変更に備えてテストと行きたいところですが、まずはフロントエンドの画面を先に作ろうと思います
テストは少し後回しにしたいと思います
次回記事
https://shinseidaiki.hatenablog.com/entry/2022/05/25/200909shinseidaiki.hatenablog.com
マッチングアプリ個人開発【Flutter画面編】
はじめに
個人開発のスキルアップの教材としてマッチングアプリは非常に良いお手本かと思うので、マッチングアプリとして最低限必要な機能を備えたMVPを作りました
マッチングアプリ業界はすでにレッドオーシャンのように見えますが、マッチングアプリを使用するユーザーの真の目的は理想のパートナーを見つけることにありますが、実際のマッチングアプリ業界に身を投じたユーザーの阿鼻叫喚具合を観察していると目的を達成しているとはいいがたく、筆者視点ではまだまだ開拓の余地がある領域だと思っています
これらの課題はマッチングアプリとしての機能面では解決できないことがもはや明確であるためどのように運用するかが勝敗を決めると思います
ほぼ供給者のアイデア勝負の領域になっていると思われますし、昨今の技術革新でアプリは誰でも簡単に作ることができるようになっています
理想のパートナーを見つけるというのは人類の根源的な欲求の一つでもあり、この崇高な理想を叶えようとする人が技術面の障壁で躓かないように、誰でもマッチングアプリを作れる記事を今回作成しました
想定する読者としてはプログラミングの知識はある程度持っていてアプリ開発については概要レベルしか知識がない人です
いつか自力で何かしらアプリを作りたいけど具体的には何作ろうかなと思っている感じの人には特に参考になるのではないのかなと思っています
こちらの記事はフロントエンド編です
Flutterを使用してモバイルにもWebにも対応したアプリを作ってみようと思います
また、バックエンドには定番のFirebaseを利用せずにDjangoを利用しているのでFirebaseを利用しない場合にFlutterの実装をどうしようか悩んでいる人の参考になれば幸いです
画面の実装をこれから行っていきますのでフルスタックな技術を身に着けたい人はバックエンドの記事も参照してください
前回までのバックエンドの実装記事はこちらです
1.
https://shinseidaiki.hatenablog.com/entry/2022/02/19/212201
2.
https://shinseidaiki.hatenablog.com/entry/2022/02/19/213700
3.
https://shinseidaiki.hatenablog.com/entry/2022/02/19/214008
4.
https://shinseidaiki.hatenablog.com/entry/2022/02/19/214217
5,
https://shinseidaiki.hatenablog.com/entry/2022/02/19/214828
また、実装済のコードはこちらです
実装しながら記事を書いていたので、ところどころ食い違いがあります
記事どおりに進めて詰まった時はこちらのリポジトリのコードを参照してください
GitHub - orekyo/matchingapp-oreyome-pub: 公開用リポジトリ
では初めて行きましょう
おしらせ
近々記事のお引越しを行おうと思っています。
しばらくアクセスできない可能性があるのでよろしくお願いします。
目次
- はじめに
- ※おしらせ
- 要件定義
- 開発環境
- バックエンド開発 Django
- 次回予告
- アーキテクチャ設計
- Djangoの初期設定~設定項目の編集~
- Modelの実装
- 管理者Adminサイトの整備
- 次回予告
- 今後の課題
- Serializersの実装
- Viewsの実装
- URL ルーティングを設定する
- 次回予告
- 動作確認
- 確認するエンドポイントの整理
- ユーザー新規作成とJWT認証(/api/authen/jwt/createと/api/users/create/)
- ログイン中のユーザー情報の取得と更新(/api/users/{pk}/)
- ログイン中のユーザー情報の取得と更新(/api/users/profile/{pk}/)
- ログイン中のプロフィール新規作成と異性プロフィール一覧取得(/api/profiles/)
- プロフィール取得(/api/profiles/{pk})
- マッチングデータ作成・マッチング一覧取得(CREATE, GET /api/favorite/ )
- マッチングデータ取得・更新(GET, PATCH /api/favorite/{pk})
- DM送信側処理(/api/dm-message/ )
- DM受信側一覧取得(GET /api/dm-message/, /api/dm-message/{pk})
- トークン期限切れ確認
- 次回予告
- 決済機能のStripeについての概要
- アーキテクチャ設計
- 各種機能を使用するための設定と環境変数を実装する
- Modelを実装する
- ルーティングURLの実装をする
- Viewを実装する
- 動作確認
- 次回予告
- はじめに
- おしらせ
- Flutterとは
- Flutter環境構築
- 開発環境
- Flutter プロジェクトを作成する
- Flutterでコードを実装していく
- ログイン機能の実装
- 自己プロフィール
- プロフィール一覧閲覧機能&いいね機能の実装
- メッセージ機能の実装
- 終わりに
Flutterとは
Google製のクロスプラットフォーム対応のアプリケーション開発フレームワークです
もう少しかみ砕いていうと1つのコードでiOSアプリとAndroidアプリとWebアプリすべてのアプリを実装することができて最近人気の技術です
Flutter環境構築
Flutterを使用していくので環境を構築してください
ここでは Windows を使用します
フラッターが導入できている前提で話を進めます
Flutterの環境構築に関してはYoutubeなどで構築手順を解説してくれているものがアップロードいたりするのでそちらを参考にするとよいかと思います
開発環境
- OS: Windows 10
- フレームワーク: Flutter v.2.
- エディタ: Android Studio
Flutter プロジェクトを作成する
プロジェクトを新規作成する
プロジェクトを新規作成してください
このブログではmatchingappwebというプロジェクト名にしました
プロジェクト構成のガワを作成する
まずは以下のように空フォルダ・ファイルを作成しましょう
matchingappmobile ┗lib ┗models ┗message_model.dart ┗matching_model.dart ┗profile_model.dart ┗user_model.dart ┗providers ┗login_provider.dart ┗message_provider.dart ┗profiles_provider.dart ┗screens ┗menu.dart ┗login.dart ┗signup.dart ┗他画面...... ┗services ┗utils ┗widgets ┗test ┗providers ┗screens ┗services ┗widgets ┗他......
modelsフォルダのファイル構成は基本的にDjangoで実装したモデル構成に準じています
providersは状態管理のライブラリで、フロントエンド側で扱うためのデータを一元管理します
screensは画面フォルダです
Flutterでコードを実装していく
さあ軽くジャブを打っていきましょう
さて環境構築を終えて最初のプロジェクトを作成するとカウンターのサンプルプログラムがあるのですが、とりあえずサンプルを実行してみましょう
flutter runのコマンドを打つことで実行することができます
なお、デバイスをインストールしていない方は仮想デバイスADVをインストールしましょう
次に、コードを実行することが出来たら軽くコードを眺めてみましょう
Dart言語(Flutterで扱っている言語のこと=ここではDartとFlutterはほとんど同じ意味で使ってます)を初めて見た人はぎょっとするコードかもしれません
特にJavaScriptのコードを少しいじったことがある人は少し違和感を感じるかもしれません
違和感の正体は、HTMLがないところにあろうかと思います
そうです、このDartという言語はHTMLやCSSを使用しません
このDartという言語は処理と画面を一つの言語で記述することができます
初めてアプリ開発を行う人にとっては何が何だかと思う類の話ですが、初心者としては他の言語を学ぶ必要がないうえに、Flutterだけで通常のアプリで作りたいであろう機能はほとんどカバーされているし、さらに作ったアプリは公開できるプラットフォームの幅も格段に大きくて、応用範囲が広いという点で、Flutterはプログラミング初学者に最もおすすめできるアプリ開発フレームワークだと思われます
ただし、チュートリアルや日本語の記事がまだまだ少ないので、そこが難関ではあるのですが......
今のところはプログラミング初心者は、無料のプログラミング学習サイトでJavaScriptやHTML、CSSを一通り習得してからFlutterを始める方が理解が早いかと思います
さて、サンプルのコードは大変ありがたいのですが、今回はプロバイダーを使用して状態管理をするためこの形を書き替えていきます
Providerとは状態管理のFlutterのライブラリです
状態管理パッケージのProviderを使ってみる
静的 Stateless と 動的 Statefull について
そもそもアプリというものは基本的にはデフォルトでは静的なものであって、あれこれ工夫することによってはじめて動的な処理を施すことができます
ちなみに静的(Stateless)というのはユーザーの操作に対してアプリの挙動が変化しないもののことを差し、動的(Statefull )とはその逆です
具体的には静的アプリはWikiペディアや阿部寛のサイト(阿部寛のホームページ)のようなものを想像すればよいと思いますし、動的アプリは私たちが日常的に使っているサイト全般です(例:Amazon などのECサイト)
FlutterアプリはStatefull Widgetを継承することで動的アプリを実装できるようにしておりこれが基本形なのですが、この実装ではアプリの実装が進んでいくにつれて保守性が下がるため、通常はProviderといったデータの状態管理用のライブラリを導入するのが鉄板となっています
Providerの導入
ではProviderを導入していきます
pub.dev
pubspec.yamlファイルにproviderを追記します
なお、yamlファイルはインデントが大事なので、インデントは以下のようになります
dependencies: flutter: sdk: flutter provider: ^6.0.2(最新バージョンを指定)
その後パッケージをインストールします
flutter pub dev
Providerの基本形
そして、pub dev サイトの Example のコードを参考にしながら以下のようなコードを書きます
これがプロバイダーの使用の基本形です
Exampleではプロバイダーの値の取り出しにcontext.read
どちらがベストプラクティスかは不明ですが、Consumerを利用する方が画面の再描画を行うのがピンポイントで制御できるため処理速度向上の観点から優れているといわれています
分からない人は後でまた説明します
lib/main.dart
import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; void main() { runApp( MultiProvider( providers: [ ChangeNotifierProvider(create: (_) => UserProvider()), ], child: const MyApp(), ), ); } class UserProvider with ChangeNotifier { String _nickname = ''; String get nickname => _nickname; notifyListeners(); void recommend() { _nickname = 'あなたの最高のお相手Aさんです'; notifyListeners(); } } class MyApp extends StatelessWidget { const MyApp({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return MaterialApp( home: Menu(), ); } } class Menu extends StatelessWidget { const Menu({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('メニューページ'), ), body: Center( child: Consumer<UserProvider>( builder: (context, provider, _) { return Column( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: <Widget>[ Text('さあ最高のマッチングアプリを始めましょう!'), Text(provider.nickname), ElevatedButton(onPressed: () => provider.recommend(), child: Text('あなたにふさわしいお相手を表示します')) ], ); }, ), ), ); } }
この実装で動的処理(状態管理)を実装することができました

ボタンを押すと新たな文字が表示されます

いい感じですね
今はボタンを押してもプログラミング内部で定義したデータを表示しているだけですが、次はバックエンドからとってきたデータを表示させていってみましょう
この処理が何を行っているかについて
さてバックエンドからデータをとってくるその前に、main.dartの中身、特にProviderの説明を軽くしておきます
まずそもそもアプリが起動するということが何なのかということを考えたいのですが、Flutterで定義されたアプリはFlutter runとかでアプリが実行されると一番最初にmain()が実行されます
↓こいつですね
void main() {
}
このmain()の中でrunApp()が呼び出され、最初にMyAppが呼ばれているという構図です
MyApp はMenuを呼んでおり、メニューページが表示されます
そのため基本的には画面を見ている限りはclass Menu extends StatelessWidget {}の中身を眺めておけばよいことになります
さて、アップバーなどのウィジェットは解説しなくてもなんとなくわかると思います
難しいのはConsumerやprovider.nicknameやElevatedButton(onPressed: () => provider.recommend(), child: Text('あなたにふさわしいお相手を表示します'))の部分だと思います
これらは何をしているかというと、(何となくわかると思いますが)ElevatedButtonを押すとprovider.nicknameの値を画面に表示しています
最初は空文字で定義されてボタンを押すと定義した文字列が表示されるコードはこちらに定義されています
class UserProvider with ChangeNotifier { String _nickname = ''; // 初期値 String get nickname => _nickname; notifyListeners(); void recommend() { // ボタンが押されたら値がセットされる _nickname = 'あなたの最高のお相手Aさんです'; notifyListeners(); // 値の変更を画面に伝えるメソッドで、プロバイダーにはこれが必要。わからなければおまじない } }
青色のボタンを押して→recommend() が実行され、→nicknameの値が変更されて、変更された値が画面に反映されている流れです
画面に反映する役割は、notifyListeners()とConsumerが担っています
Consumerの基本形
Consumerの基本形はこちらです
Consumer<UserProvider>( // 定義したProviderのクラス名 builder: (context, provider, _) { // 第二引数のproviderに実際のインスタンスが入る return Widget( // WidgetにはColumnなどのお好きなWidgetが利用できる // providerを利用する処理がかける ); }, ),
アロー関数などが思いっきり使われているので大変理解しがたい形でしょうが、まあ慣れましょう
これでデータの状態管理ができるようになりました
では次にまずはログイン機能を実装してみましょう
ログイン機能の実装
ログインをするためには正しいemailとpasswordを渡しつつログイン認証用APIエンドポイントを呼び出して認証情報を受け取る必要があるので、HttpクライアントとしてはDioを利用して、認証情報はCookieに格納する方針で実装することとします
その他の情報はデータの状態管理モジュールのProviderを利用して実装していきます
まずはmain.dartなどをProviderを利用できる形に書き換えていきます
必要なパッケージインストール
Httpクライアントとして使用するDioとCookieを管理するためのパッケージとそのほか必要なパッケージをインストールします
pubspec.yaml
dependencies: http: [最新版] dio: [最新版] cookie_jar: [最新版] dio_cookie_manager: [最新版] path_provider: [最新版]
main.dartのProviderに対応するための書き換え実装
main.dart
import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; void main() { runApp( MultiProvider( providers: [ ChangeNotifierProvider(create: (_) => LoginProvider()), ChangeNotifierProvider(create: (_) => ProfileProvider()), ], child: const MyApp(), ), ); } class MyApp extends StatelessWidget { const MyApp({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return MaterialApp( home: MenuScreen(), routes: <String, WidgetBuilder>{ '/menu': (BuildContext context) => MenuScreen(), '/login': (BuildContext context) => LoginScreen(), '/signup': (BuildContext context) => SignupScreen(), '/my-profile': (BuildContext context) => MyProfileScreen(), '/looking': (BuildContext context) => ProfilesScreen(), }, ); } }
メニュー画面の実装
ところでアプリのタイトルを決めていませんでしたが、ここでは俺の嫁探しにしています(ギャルゲみたいとか言わない)
screens/menu.dart
import 'package:flutter/material.dart'; class MenuScreen extends StatelessWidget { const MenuScreen({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('俺の嫁探し'), ), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: <Widget>[ const Text('さあ最高のマッチングアプリを始めましょう!',), const SizedBox(height: 16,), TextButton( child: const Text('ログイン'), onPressed: () { Navigator.pushNamed(context, '/login'); }, ), TextButton( child: const Text('新規会員登録'), onPressed: () { Navigator.pushNamed(context, '/signup'); }, ), ], ) ), ); } }
ログイン画面の実装
まずは画面のレイアウトを整えます
Consumerを利用してProviderを利用できるようにしています
またemailにはバリデータをつけています
email_validator | Dart package
screens/login.dart
import 'package:email_validator/email_validator.dart'; import 'package:flutter/material.dart'; class LoginScreen extends StatelessWidget { const LoginScreen({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('ログインページ'), ), body: Consumer2<LoginProvider, ProfileProvider>( builder: (context, loginProvider, profileProvider, _) { return Center( child: Padding( padding: const EdgeInsets.all(32.0), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ TextFormField( onChanged: (value) => loginProvider.email = value, decoration: const InputDecoration( labelText: 'email', ), maxLength: 50, ), Text(loginProvider.message, style: const TextStyle(color: Colors.red),), TextFormField( onChanged: (value) => loginProvider.password = value, decoration: InputDecoration( labelText: 'password', suffixIcon: IconButton( onPressed: () => loginProvider.togglePasswordVisible(), icon: const Icon(Icons.remove_red_eye) ), ), obscureText: loginProvider.hidePassword, maxLength: 50, ), Padding( padding: const EdgeInsets.only(top: 16.0), child: ElevatedButton( child: const Text('ログイン'), style: ElevatedButton.styleFrom( fixedSize: Size(MediaQuery.of(context).size.width * 0.95, 32), ), onPressed: () { loginProvider.setMessage(''); if( !EmailValidator.validate(loginProvider.email) ) { loginProvider.setMessage('Email形式で入力してください'); return ; } loginProvider.auth() .then( (isSuccess) { if (isSuccess) { profileProvider.fetchMyProfile(loginProvider.getUserId()) .then((isSuccess) { if (isSuccess) { print('プロフィール作成済みユーザーです'); Navigator.pushAndRemoveUntil( context, MaterialPageRoute(builder: (context) => const MyProfileScreen()), (route) => false, ); } else { print('プロフィール未作成ユーザーです'); Navigator.pushAndRemoveUntil( context, MaterialPageRoute(builder: (context) => const MyProfileScreen()), (route) => false, ); } }); } }) .catchError((error) => print(error)); }, ), ) ], ), ), ); } ), ); } }
新規ユーザー作成画面の実装
ログインとほぼ同じです
ボタンを呼び出す際に呼び出しているProviderのメソッドが違います
screens/signup.dart
class SignupScreen extends StatelessWidget { const SignupScreen({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('サインアップページ'), ), body: Consumer<LoginProvider>( builder: (context, loginProvider, _) { return Center( child: Padding( padding: const EdgeInsets.all(32.0), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ TextFormField( onChanged: (value) => loginProvider.email = value, decoration: const InputDecoration( labelText: 'email', ), maxLength: 50, ), Text(loginProvider.message, style: const TextStyle(color: Colors.red),), TextFormField( onChanged: (value) => loginProvider.password = value, decoration: InputDecoration( labelText: 'password', suffixIcon: IconButton( onPressed: () => loginProvider.togglePasswordVisible(), icon: const Icon(Icons.remove_red_eye) ), ), obscureText: loginProvider.hidePassword, maxLength: 50, ), Padding( padding: const EdgeInsets.only(top: 16.0), child: ElevatedButton( child: const Text('新規ユーザー作成'), style: ElevatedButton.styleFrom( fixedSize: Size(MediaQuery.of(context).size.width * 0.95, 32), ), onPressed: () { loginProvider.setMessage(''); if( !EmailValidator.validate(loginProvider.email) ) { loginProvider.setMessage('Email形式で入力してください'); return ; } loginProvider.signup() .then( (isSuccess) { if (isSuccess) { Navigator.pushAndRemoveUntil( context, MaterialPageRoute(builder: (context) => const LoginScreen()), (route) => false, ); } }) .catchError((error) => print(error)); }, ), ) ], ), ), ); } ), ); } }
LoginProviderの実装
ロジックの部分を実装します
プロバイダーはemailやパスワード情報を保持したり認証処理を行ったりする役割を担っています
togglePasswordVisibleのメソッドは例えば画面上でパスワードの表示非表示を切り替えるためのメソッドとなります
またauthのメソッドはいわゆるログインのメソッドとなっております
ログインの処理の流れとしてはまずはDioを呼び出してDioを使用する準備を整え、ログイン後に取得できるtokenをCookieに格納するための下準備をCookieJarなどを使って準備を行っています
またスマートフォンはストレージのアクセス権などが厳しく明示的にCookieの情報をストレージに書き込めることをpath_provider.dartのパッケージで明示しています(明示しなければストレージは読み取り専用)
そしてsaveFromResponseでクッキーを保存しています
これで認証情報を取得する処理は出来ました
認証情報を利用してAPIを呼び出す場合は直後の処理のようにヘッダーに 'Authorization': を付与します
正しい認証情報を持っていればアクセスができて、そうでなければエラーが返ってきます
また余談ですが、これはFlutter特有なのかわかりませんが、Flutterはエラーハンドリングはあまり細かく分けたりせずに大雑把にエラーをキャッチして一緒くたに処理するみたいですね
ネットに転がっている例とか見ていても細かくエラーハンドリングをしているケースをあまり見かけません
なのでこのアプリでもその慣習を踏襲して、エラーが起きたとしてもユーザーはエラーが起きたくらいのメッセージしか得られません
ユーザーフレンドリーじゃない気がしますが、Flutterの設計思想としてもしかしたらそもそも設計思想としてエラーを起こすやつは何やってもダメとかあるのかもしれないです
(Flutter開発歴が浅いので間違っていたらすみません)
providers/login_pro.dart
import 'dart:io'; import 'package:cookie_jar/cookie_jar.dart'; import 'package:dio/dio.dart'; import 'package:dio_cookie_manager/dio_cookie_manager.dart'; import 'package:flutter/material.dart'; import 'package:path_provider/path_provider.dart'; class LoginProvider with ChangeNotifier { bool _isSuccess = false; String message = ''; String email = ''; String password = ''; bool hidePassword = true; final UserModel _userModel = UserModel(); final Uri _uriHost = Uri.parse('http://10.0.2.2:8000'); // Mobile(エミュレータ)ではこちらのホストを利用する // final Uri _uriHost = Uri.parse('http://127.0.0.1:8000'); // Webではこちらのホストを利用する String getUserId() { return _userModel.id; } void setMessage(String msg) { message = msg; notifyListeners(); } void togglePasswordVisible() { hidePassword = !hidePassword; notifyListeners(); } Future<bool> auth() async { _isSuccess = false; message = ''; try { Dio dio = Dio(); dio.options.baseUrl = _uriHost.toString(); dio.options.connectTimeout = 5000; dio.options.receiveTimeout = 3000; dio.options.contentType = 'application/json'; List<Cookie> cookieList = []; Directory appDocDir = await getApplicationDocumentsDirectory(); String appDocPath = appDocDir.path; PersistCookieJar cookieJar = PersistCookieJar(storage: FileStorage(appDocPath+"/.cookies/")); dio.interceptors.add(CookieManager(cookieJar)); final responseJwt = await dio.post( '/authen/jwt/create', data: { 'email': email, 'password': password, } ); cookieList = [ ...cookieList, Cookie('access_token', responseJwt.data['access']) ]; cookieJar.saveFromResponse(_uriHost, cookieList); final responseUser = await dio.get( '/authen/users/me', options: Options( headers: { 'Authorization': 'JWT ${cookieList.first.value}', }, ), ); _userModel.id = responseUser.data['id']; _userModel.email = responseUser.data['email']; _isSuccess = true; } catch(error) { message = '正しいEメールとパスワードを入力してください'; print(error); _isSuccess = false; } notifyListeners(); return _isSuccess; } Future<void> logout() async { } Future<bool> signup() async { _isSuccess = false; message = ''; try { Dio dio = Dio(); dio.options.baseUrl = _uriHost.toString(); dio.options.contentType = 'application/json'; final response = await dio.post( '/api/users/create/', data: { 'email': email, 'password': password, 'username': '', } ); _userModel.id = response.data['id']; message = '新規ユーザーの仮登録が成功しました。本登録にはユーザーのアクティベーションを行って下さい'; _isSuccess = true; } catch(error) { message = '新規ユーザー登録処理が失敗しました。同じEmailは使用できません'; print(error); _isSuccess = false; } notifyListeners(); return _isSuccess; } }
http://10.0.2.2:8000 は http://127.0.0.1:8000/ のエイリアス となっており今回FlutterエミュレータとDjangoのバックエンド双方が127.0.0.1:8000のホストを占有しておりバッティングしているため、10.0.2.2:8000を指定することでエミュレータ側からバックエンドにアクセスすることができるようになります
後で環境変数化の解説があるので、その時にこちらのコードも環境変数化してください
CookieJar の PersistCookieJar を使用することでクッキーの認証情報をストレージに格納して永続的に利用できるようになります
これが実装できるとこんな画面でログインが可能となるはずです
MyProfileを実装してない場合は当該部分を適宜コメントアウトなどして動作確認してくださいな


※パスワードはログイン後に空文字を入れるなどして初期化したほうがいいですね
サインアップの動作確認
サインアップも同様に利用できることを確認しておきましょう
サインアップはバックエンド開発の時に行ったようにユーザーを新規作成するとEmailかバックエンド側コンソールにアクティベーション用のURLが表示されるので、スマートフォンエミュレータのブラウザを開いてそのURLにアクセスしましょう
今回は開発環境のためURLのホストは12.7.0.0.1の部分を10.0.2.2に書き換えてアクセスします
Stripe画面が呼び出されてクレジット決済が完了したらユーザーがアクティベーションされることが確認出来たらOKです
今回はStripeのリダイレクトをDjango Rest Frameworkで作成したViewで返されたデータをフロントエンドで適切に受け取って画面を表示させていないので、Djangoの開発者用の画面が表示されてしまっています
これはDjangoテンプレートを使って画面を作成する方がいいなとフロント実装しながら思いました
今の実装だとアカウント作成の導線がぐちゃっているので、アプリを本気で運用する場合はこの辺は修正したほうがよさそうですね
自己プロフィール
さて、ログインが完了すると最初は自己プロフィール画面に遷移させるようにしたので、自己プロフィール画面を実装していきます
自己プロフィール画面の実装
レイアウトはこちらになります

全体像
まずは全体コードを記載します
とてもコードが長いので細かい単位でスナックバーなどの共通部品は個別にウィジェットに分けています
screens/my_profile_screen.dart
import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:matchingappweb/pickers/graduation_picker_widget.dart'; import 'package:matchingappweb/pickers/passion_picker_widget.dart'; import 'package:matchingappweb/providers/login_provider.dart'; import 'package:matchingappweb/providers/profile_provider.dart'; import 'package:provider/provider.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import '../pickers/location_picker_widget.dart'; import '../pickers/sex_picker.dart'; import '../utils/show_snack_bar.dart'; import '../widgets/bottom_nav_bar_widget.dart'; import '../widgets/drawer_widget.dart'; class MyProfileScreen extends StatelessWidget { const MyProfileScreen({Key? key}) : super(key: key); @override Widget build(BuildContext context) { final Uri _uriHost = Uri.parse(dotenv.get('BACKEND_URL_HOST')); return Scaffold( appBar: AppBar( title: const Text('マイプロフィール'), ), body: Consumer2<ProfileProvider, LoginProvider>( builder: (context, profileProvider, loginProvider, _) { return Padding( padding: const EdgeInsets.all(32.0), child: SingleChildScrollView( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Stack( children: <Widget>[ profileProvider.uploadTopImage != null ? Image.file(profileProvider.uploadTopImage!) : profileProvider.myProfile.topImage != null ? Image.network('${profileProvider.myProfile.topImage?.replaceFirst(dotenv.get('STORAGE_URL_HOST'), _uriHost.toString())}', width: 100, fit: BoxFit.fill, errorBuilder: (context, error, stackTrace) { return Image.asset('images/nophotos.png',width: 100, fit: BoxFit.fill,); }, ) : Image.asset('images/nophotos.png', width: 100, fit: BoxFit.fill,), profileProvider.myProfile.isKyc ? const Icon(Icons.check_circle, color: Colors.greenAccent, size: 16,) : const SizedBox(), ], ), TextButton( child: const Text('画像変更'), onPressed: () => profileProvider.pickTopImage(), ), TextFormField( onChanged: (value) => profileProvider.myProfile.nickname = value, decoration: const InputDecoration(labelText: 'ニックネーム', hintText: 'このフィールドは必須です'), maxLength: 50, initialValue: profileProvider.myProfile.nickname, ), TextFormField( keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], onChanged: (value) => profileProvider.myProfile.age = int.parse(value), decoration: const InputDecoration(labelText: '年齢', hintText: '18歳未満は登録出来ません',), enabled: profileProvider.myProfile.user == null, maxLength: 2, initialValue: profileProvider.myProfile.age.toString(), ), for (String key in sexPicker.keys) ... { RadioListTile( value: key, groupValue: profileProvider.myProfile.sex, title: Text(sexPicker[key] ?? '性別不詳'), selected: profileProvider.myProfile.sex == key, onChanged: (value) { if (profileProvider.myProfile.user == null) { profileProvider.myProfile.sex = value.toString(); profileProvider.notifyListeners(); } else { null; } }, ) }, TextFormField( keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], onChanged: (value) => profileProvider.myProfile.height = int.parse(value), decoration: const InputDecoration(labelText: '身長cm', hintText: '140cm以上200cm未満で入力してください'), maxLength: 3, initialValue: profileProvider.myProfile.height != null ? profileProvider.myProfile.height.toString() : '', ), const LocationPickerWidget(), TextFormField( onChanged: (value) => profileProvider.myProfile.work = value, decoration: const InputDecoration(labelText: '仕事',), maxLength: 20, initialValue: profileProvider.myProfile.work, ), TextFormField( keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], onChanged: (value) => profileProvider.myProfile.revenue = int.parse(value), decoration: const InputDecoration(labelText: '収入(万円)',), maxLength: 4, initialValue: profileProvider.myProfile.revenue.toString(), ), const GraduationPickerWidget(), TextFormField( onChanged: (value) => profileProvider.myProfile.hobby = value, decoration: const InputDecoration(labelText: '趣味',), maxLength: 20, initialValue: profileProvider.myProfile.hobby, ), const PassionPickerWidget(), TextFormField( onChanged: (value) => profileProvider.myProfile.tweet = value, decoration: const InputDecoration(labelText: 'つぶやき',), maxLength: 10, initialValue: profileProvider.myProfile.tweet, ), TextFormField( keyboardType: TextInputType.multiline, onChanged: (value) => profileProvider.myProfile.introduction = value, decoration: const InputDecoration(labelText: '自己紹介',), maxLength: 1000, maxLines: null, initialValue: profileProvider.myProfile.introduction, ), Padding( padding: const EdgeInsets.only(top: 16.0), child: ElevatedButton( child: profileProvider.myProfile.user == null ? const Text('プロフィールを作成する') : const Text('プロフィールを更新する'), style: ElevatedButton.styleFrom( fixedSize: Size(MediaQuery.of(context).size.width * 0.95, 32), ), onPressed: () { if (profileProvider.myProfile.user == null) { profileProvider.createMyProfile(loginProvider.getUserId()).then((isSuccess) { if (isSuccess) { Navigator.pushReplacementNamed(context, '/my-profile'); showSnackBar(context, 'プロフィールが新規作成されました'); } else { showSnackBar(context, 'エラーが発生しました'); } }); } else { profileProvider.updateMyProfile(loginProvider.getUserId()).then((isSuccess) { if (isSuccess) { Navigator.pushReplacementNamed(context, '/my-profile'); showSnackBar(context, 'プロフィール更新が完了しました'); } else { showSnackBar(context, 'エラーが発生しました'); } }); } }, ), ), ], ), ), ); }, ), drawer: const DrawerWidget(), bottomNavigationBar: const BottomNavBarWidget(), ); } }
共通部品のコード実装
スナックバー
ボタンを押すなどのユーザーアクション後にメッセージが表示されてくるアレです
トーストともいわれるUI部品です
![]()
utils/show_snack_bar.dart
import 'package:flutter/material.dart'; void showSnackBar(BuildContext context, String msg) { final snackBar = SnackBar( content: Text(msg), action: SnackBarAction(label: '閉じる', onPressed: () {}), ); ScaffoldMessenger.of(context).showSnackBar(snackBar); }
ボトムナブバー
ボトムナビゲーションバーとか言ったり同義語がたくさんありますがアプリの下部にあるナビゲーションバーです
今回はただの飾りつけとしての実装しかしていません

widgets/bottom.dart
class BottomNavBarWidget extends StatelessWidget { const BottomNavBarWidget({ Key? key, }) : super(key: key); @override Widget build(BuildContext context) { return BottomNavigationBar( items: [ BottomNavigationBarItem( label: '相手探し', icon: IconButton( icon: const Icon(Icons.wc), onPressed: () { // Navigator.pushNamed(context, '/looking'); }, ) ), BottomNavigationBarItem( label: 'いいねリスト', icon: IconButton( icon: const Icon(Icons.volunteer_activism), onPressed: () { // Navigator.pushNamed(context, '/chance'); }, ) ), BottomNavigationBarItem( label: 'メッセージ', icon: IconButton( icon: const Icon(Icons.message), onPressed: () { // Navigator.pushNamed(context, '/message'); }, ) ), ], ); } }
ドロワー
左右から出てくるアレですね
今回はこれで画面移動を行います

widgets/drawer.dart
class DrawerWidget extends StatelessWidget { const DrawerWidget({ Key? key, }) : super(key: key); @override Widget build(BuildContext context) { return Drawer( child: Consumer4<LoginProvider, ProfileProvider, MatchingProvider, MessageProvider>( builder: (context, loginProvider, profileProvider, matchingProvider, messageProvider, _) { return ListView( children: [ const DrawerHeader( child: Text('ようこそ俺の嫁探しへ', style: TextStyle(color: Colors.white),), decoration: BoxDecoration( color: Colors.blue, ), ), ListTile( title: const Text("相手を探す"), trailing: const Icon(Icons.arrow_forward), onTap: () async { bool _isSuccess = await profileProvider.fetchProfileList(); if (_isSuccess) Navigator.pushNamed(context, '/looking'); }, ), ListTile( title: const Text("いいねした人リスト"), trailing: const Icon(Icons.arrow_forward), onTap: () async { bool _isSuccess = await profileProvider.fetchProfileApproachingList(); if (_isSuccess) Navigator.pushNamed(context, '/favorite'); }, ), ListTile( title: const Text("いいねをもらった人リスト"), trailing: const Icon(Icons.arrow_forward), onTap: () async { bool _isSuccess = await profileProvider.fetchProfileApproachedList(); if (_isSuccess) Navigator.pushNamed(context, '/chance'); }, ), ListTile( title: const Text("マッチング成立リスト"), trailing: const Icon(Icons.arrow_forward), onTap: () async { bool _isSuccess = await profileProvider.fetchProfileMatchingList(); if (_isSuccess) Navigator.pushNamed(context, '/matching'); }, ), ListTile( title: const Text("メッセージ"), trailing: const Icon(Icons.arrow_forward), onTap: () {} // Navigator.pushNamed(context, '/message'), ), ListTile( title: const Text("自己プロフィール"), trailing: const Icon(Icons.arrow_forward), onTap: () { profileProvider.fetchMyProfile(loginProvider.getUserId()) .then( (_) { Navigator.pushNamed(context, '/my-profile'); }); } ), ], ); } ) ); } }
環境変数の導入
開発環境や本番環境の分離やシークレット情報の保持などのために使用する環境変数を設定します
以下の環境変数用のパッケージをインストールしてマニュアルに記載の設定を行っていきます
flutter_dotenv | Flutter package
.env.devと.env.prodをプロジェクト直下に作成してassetsに記載します
pabspec.yaml
dependencies: flutter_dotenv: ^5.0.2 flutter: assets: - .env.dev - .env.prod
flutter pub get コマンドを叩いてインストールします
その後、.envファイルに環境変数を記載します
.env.dev
BACKEND_URL_HOST=http://10.0.2.2:8000 STORAGE_URL_HOST=http://127.0.0.1:8000/
import 'package:flutter_dotenv/flutter_dotenv.dart'; Future<void> main() async { await dotenv.load(fileName: ".env.dev"); runApp(); }
その後は読み込みたいファイルで環境変数を読み込みます
my_profile_screen.dart
import 'package:flutter_dotenv/flutter_dotenv.dart'; class MyProfileScreen extends StatelessWidget { Widget build(BuildContext context) { String _urlString = dotenv.get('BACKEND_URL_HOST'); final Uri _uriHost = Uri.parse(_urlString); ... } }
pickerの導入
詳しい手順はQiitaにまとめました
Provider利用時のFlutterのpicker(ドラムロール)の実装方法 #provider - Qiita

例:graduation_picker.dart
final Map<String, String> graduationPicker = { '' : '', 'junior_high_school' : '中卒', 'high_school' : '高卒', 'trade_school' : '短大・専門学校卒', 'university' : '大卒', 'grad_school' : '大学院卒', };
例:graduation_picker_widget.dart
import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:matchingappweb/pickers/graduation_picker.dart'; import 'package:provider/provider.dart'; import '../providers/profile_provider.dart'; class GraduationPickerWidget extends StatelessWidget { const GraduationPickerWidget({ Key? key, }) : super(key: key); @override Widget build(BuildContext context) { return Consumer<ProfileProvider>( builder: (context, profileProvider, _) { return Row( children: [ const Text('最終学歴 ',), Text(graduationPicker[profileProvider.myProfile.graduation] ?? '', style: const TextStyle(fontWeight: FontWeight.bold)), TextButton( child: const Text('選択'), onPressed: () { showModalBottomSheet( context: context, builder: (BuildContext context) { return Container( height: MediaQuery.of(context).size.height / 2, child: Column( children: [ Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ TextButton( child: const Text('戻る'), onPressed: () => Navigator.pop(context), ), TextButton( child: const Text('決定'), onPressed: () { profileProvider.notifyListeners(); Navigator.pop(context); }, ), ], ), Container( height: MediaQuery.of(context).size.height / 3, child: CupertinoPicker( itemExtent: 40, children: [ for (String key in graduationPicker.keys) ... { Text(graduationPicker[key] ?? '最終学歴不詳') }, ], onSelectedItemChanged: (int index) => profileProvider.myProfile.graduation = graduationPicker.keys.elementAt(index), ), ) ], ), ); } ); }, ), ], ); } ); } }
ProfileProviderの実装
それでは自己プロフィールを扱うためのデータとロジックをProviderに実装していきましょう
以下が自己プロフィールに関連したコード全体です
profile_provider.dart
import 'dart:io'; import 'package:cookie_jar/cookie_jar.dart'; import 'package:dio/dio.dart'; import 'package:dio_cookie_manager/dio_cookie_manager.dart'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:image/image.dart'; import 'package:path_provider/path_provider.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import '../models/profile_model.dart'; class ProfileProvider with ChangeNotifier { final Uri _uriHost = Uri.parse(dotenv.get('BACKEND_URL_HOST')); bool _isSuccess = false; File? uploadTopImage; ProfileModel myProfile = ProfileModel( user: null, isSpecial: false, isKyc: false, nickname: '', topImage: null, createdAt: null, updatedAt: null, age: 0, sex: '', height: null, location: null, work: null, revenue: 0, graduation: null, hobby: null, passion: null, tweet: null, introduction: null, sendFavorite: null, receiveFavorite: null, stockFavorite: null, ); Future fetchMyProfile(String userId) async { _isSuccess = false; try { Dio dio = Dio(); List<Cookie> cookieList = await _prepareDio(dio); final Response<dynamic> profile = await dio.get( '/api/users/profile/$userId', options: Options( headers: { 'Authorization': 'JWT ${cookieList.first.value}', }, ), ); myProfile = _inputProfileModel(profile.data!); _isSuccess = true; } catch(error) { print(error); _isSuccess = false; } notifyListeners(); return _isSuccess; } Future pickTopImage() async { _isSuccess = false; final ImagePicker _picker = ImagePicker(); try { final XFile? image = await _picker.pickImage(source: ImageSource.gallery, imageQuality: 25); if (image != null) { final _imageDecode = decodeImage(File(image.path).readAsBytesSync()); if (_imageDecode != null) { var _imageResize; const int _imageLongSide = 720; if (_imageDecode.width > _imageDecode.height) { if (_imageDecode.width > _imageLongSide) { _imageResize = copyResize( _imageDecode, width: _imageLongSide, height: _imageLongSide * _imageDecode.height ~/ _imageDecode.width ); } } else { if (_imageDecode.height > _imageLongSide) { _imageResize = copyResize( _imageDecode, width: _imageLongSide * _imageDecode.width ~/ _imageDecode.height, height: _imageLongSide ); } } if (_imageResize != null) { File(image.path).writeAsBytesSync(encodePng(_imageResize)); } } uploadTopImage = File(image.path); } _isSuccess = true; } catch (error) { print("エラーが発生しました"); _isSuccess = false; } notifyListeners(); return _isSuccess; } Future createMyProfile(String userId) async { _isSuccess = false; try { Dio dio = Dio(); List<Cookie> cookieList = await _prepareDio(dio); FormData formData = FormData.fromMap({ "is_special": false, "is_kyc": false, "top_image": uploadTopImage != null ? await MultipartFile.fromFile( uploadTopImage!.path, filename: uploadTopImage!.path.split('/').last, ) : myProfile.topImage, "nickname": myProfile.nickname, "age": myProfile.age, "sex": myProfile.sex, "height": myProfile.height, "location": myProfile.location, "work": myProfile.work, "revenue": myProfile.revenue, "graduation": myProfile.graduation, "hobby": myProfile.hobby, "passion": myProfile.passion, "tweet": myProfile.tweet, "introduction": myProfile.introduction, "send_favorite": myProfile.sendFavorite, "receive_favorite": myProfile.receiveFavorite, "stock_favorite": myProfile.stockFavorite }); final Response profile = await dio.post( '/api/profiles/', options: Options( headers: { 'Authorization': 'JWT ${cookieList.first.value}', }, ), data: formData, ); myProfile = _inputProfileModel(profile.data!); _isSuccess = true; } catch(error) { print(error); _isSuccess = false; } notifyListeners(); return _isSuccess; } Future updateMyProfile(String userId) async { _isSuccess = false; try { Dio dio = Dio(); List<Cookie> cookieList = await _prepareDio(dio); FormData formData = FormData.fromMap({ "top_image": uploadTopImage != null ? await MultipartFile.fromFile( uploadTopImage!.path, filename: uploadTopImage!.path.split('/').last, ) : myProfile.topImage, "nickname": myProfile.nickname, "height": myProfile.height, "location": myProfile.location, "work": myProfile.work, "revenue": myProfile.revenue, "graduation": myProfile.graduation, "hobby": myProfile.hobby, "passion": myProfile.passion, "tweet": myProfile.tweet, "introduction": myProfile.introduction, }); final Response profile = await dio.patch( '/api/users/profile/$userId/', options: Options( headers: { 'Authorization': 'JWT ${cookieList.first.value}', }, ), data: formData, ); myProfile = _inputProfileModel(profile.data!); _isSuccess = true; } catch(error) { print(error); _isSuccess = false; } notifyListeners(); return _isSuccess; } ProfileModel _inputProfileModel(dynamic profile) { return ProfileModel( user: profile!['user'], isSpecial: profile!['is_special'], isKyc: profile!['is_kyc'], topImage: profile!['top_image'], nickname: profile!['nickname'], createdAt: profile!['created_at'], updatedAt: profile!['updated_at'], age: profile!['age'], sex: profile!['sex'], height: profile!['height'], location: profile!['location'], work: profile!['work'], revenue: profile!['revenue'], graduation: profile!['graduation'], hobby: profile!['hobby'], passion: profile!['passion'], tweet: profile!['tweet'], introduction: profile!['introduction'], sendFavorite: profile!['send_favorite'], receiveFavorite: profile!['receive_favorite'], stockFavorite: profile!['stock_favorite'], // profile!['fromLastLogin'], ); } Future<List<Cookie>> _prepareDio(Dio dio) async { dio.options.baseUrl = _uriHost.toString(); dio.options.connectTimeout = 5000; dio.options.receiveTimeout = 3000; // dio.options.contentType = 'application/json' or 'multipart/form-data'; Directory appDocDir = await getApplicationDocumentsDirectory(); String appDocPath = appDocDir.path; PersistCookieJar cookieJar = PersistCookieJar(storage: FileStorage(appDocPath+"/.cookies/")); dio.interceptors.add(CookieManager(cookieJar)); List<Cookie> cookieList = await cookieJar.loadForRequest(_uriHost); return cookieList; } }
CookieやDioの使い方はLoginの機能を作成したときと同じです
ここではmy_profileという変数を用意して、自己プロフィールを新規作成するメソッドと取得するメソッドと更新するメソッドを作成しました
Profileモデルが大きいのでコード量が大きく見えていますが、基本的にはバックエンドで作成したAPIのエンドポイントを呼んでいるだけです
ただ、HTTPクライアントで取得したデータは型がDynamicになってしまうため、少し面倒ですがProfile型に戻す処理をつける必要があり、その処理が_inputProfileModelです
また画像のアップロードも行えるロジックを実装しました
画像などのデータを含むフォームデータを作成する場合はFormData型でFormData.fromMapを使用するとよいところがポイントです
画像のアップロード
詳細はQiitaに記述しました
Flutter×Django(DRF)で画像をアップロードする方法 #provider - Qiita
この実装ではpickTopImageのメソッドでproviderにセットし、プロフィール作成時もしくは更新時にアップロードするようになっています
現段階の実装ではほかの画面に遷移したときに画像はクリアされませんが、実運用では画像のアップロードを取りやめたい場合の画像クリア機能をつける必要があります
動作確認
新規アカウントを作成したり、既存のアカウントを使用して、ログインします
そしてプロフィール作成や更新を行って、作成や更新が出来たらスナップバーが出現し作成・更新されたデータができていることをDjangoの管理画面などでも確認してください

プロフィール一覧閲覧機能&いいね機能の実装
次に異性を閲覧できるプロフィール一覧機能を作っていきます
プロフィール全件取得コードの作成
まずはProfileProviderにProfileデータを取り扱うための処理を記載していきます
まずは画面共有するための変数を定義します
ProfileProvider.dart
/// プロフィール機能
List<ProfileModel> profileList = [];
List<ProfileModel> profileApproachingList = [];
List<ProfileModel> profileApproachedList = [];
List<ProfileModel> profileMatchingList = [];
ProfileModel? profileDetail;
そしてバックエンドから全件データを取得する処理をまずは記載します
Provider内のプライベートメソッドにします
バックエンドで実装したAPIをHTTPクライアントのDIOで呼び出しています
またmy_profileの時と同様に、HTTPクライアントで取得したデータは型がDynamicになってしまうためProfile型に戻す処理をつける必要があり、これらはこの後も他のメソッドで頻繁に共通利用されるため_inputMessageModel、_inputMessageModelListのメソッドとして処理を外だししています
ProfileProvider.dart
/// 【プライベート】プロフィール全件取得 Future _fetchProfileAllList() async { _isSuccess = false; profileList.clear(); try { Dio dio = Dio(); List<Cookie> cookieList = await _prepareDio(dio); final Response profiles = await dio.get( '/api/profiles', options: Options( headers: { 'Authorization': 'JWT ${cookieList.first.value}', }, ), ); profileList = _inputProfileModelList(profiles.data!); _isSuccess = true; } catch(error) { print(error); _isSuccess = false; } notifyListeners(); return _isSuccess; } /// 【プライベート】バックエンドから取得したメッセージデータのProvider化 MessageModel _inputMessageModel(dynamic message) { return MessageModel( id: message!['id'], sender: message!['sender'], receiver: message!['receiver'], message: message!['message'], createdAt: message!['created_at'], ); } /// 【プライベート】バックエンドから取得したメッセージデータ一覧のProvider化 List<MessageModel> _inputMessageModelList(dynamic messageList) { return messageList.map<MessageModel>( (message) => _inputMessageModel(message) ).toList(); }
マッチングデータモデルの作成
次にプロフィール一覧画面ではすでにいいねを送ったユーザーやいいねをもらったユーザーやマッチングしているユーザーは表示させない仕様とするため、いいね機能であるマッチングモデルの処理をここで追加していきます
_matchingList はマッチング中のMatchingModelデータがリストで格納され、_approachingList はいいねを送ったユーザー、_approachedList はいいねをくれたユーザーを格納します
またフィルタリングの処理の都合上、_matchingUserIdList のように相手ユーザーのIDだけをリスト化したものも作成しておきます
ProfileProvider.dart
/// マッチング機能 MatchingModel? matching; List<MatchingModel> _matchingList = []; List<String> _matchingUserIdList = []; List<MatchingModel> _approachingList = []; List<String> _approachingUserIdList = []; List<MatchingModel> _approachedList = []; List<String> _approachedUserIdList = [];
そして、バックエンドから取得したマッチングに関する全件データをローカルでそれぞれのデータに振り分けていく処理を実装して、上記で作成した変数に振り分けたデータをmapやwhereを使ってフィルタリングし、それぞれ格納していきます
ProfileProvider.dart
/// 【プライベート】いいねしているユーザー・いいねされているユーザー・マッチングしているユーザーのそれぞれのマッチングリストを取得する Future _fetchMatchingList() async { _isSuccess = false; try { Dio dio = Dio(); List<Cookie> cookieList = await _prepareDio(dio); final Response matchingList = await dio.get( '/api/favorite/', options: Options( headers: { 'Authorization': 'JWT ${cookieList.first.value}', }, ), ); _matchingList = _inputMatchingModelList(matchingList.data!); _approachingList = _matchingList.where((matching) => matching.approaching == myProfile.user).toList(); _approachingUserIdList = _approachingList.map((matching) => matching.approached).toList(); _approachedList = _matchingList.where((matching) => matching.approached == myProfile.user).toList(); _approachedUserIdList = _approachedList.map((matching) => matching.approaching!).toList(); _matchingList = _matchingList.where((matching) => matching.approved == true).toList(); _matchingUserIdList = _matchingList.map((matching) => matching.approaching!).toList(); _isSuccess = true; } catch(error) { print(error); _isSuccess = false; } notifyListeners(); return _isSuccess; }
フィルタリングの実装
プロフィール一覧画面に表示するユーザーのフィルタリング
上記で格納した _approachingList などの変数を使用していいねを送ったユーザーといいねをくれたユーザーを取り除く処理を記述します
removeWhereを使います
実装はこちらで以上となります
ちなみに後で気づきましたが、_isSuccessの部分の実装は正しくない実装です
余力のある日とはtry catchで囲んで実装の修正をしておきましょう
まあこのままでも動きますが、今のところ
ProfileProvider.dart
/// ユーザーのプロフィール一覧を取得する(いいね・マッチング状態のユーザーは除外) Future fetchProfileList() async { _isSuccess = false; _isSuccess = await _fetchProfileAllList(); _isSuccess = await _fetchMatchingList(); profileList.removeWhere((profile) => _approachingUserIdList.contains(profile.user)); profileList.removeWhere((profile) => _approachedUserIdList.contains(profile.user)); notifyListeners(); return _isSuccess; }
いいねを送ったユーザーのフィルタリング
上記で格納した _approachingList の変数を使用していいねを送ったユーザーをフィルタリングしていく処理を記述します
whereを使用しています
特段上の実装と変わったところはありません
ProfileProvider.dart
/// いいねしたユーザーのプロフィール一覧を取得する(マッチング状態のユーザーは除外) Future fetchProfileApproachingList() async { _isSuccess = false; _isSuccess = await _fetchProfileAllList(); _isSuccess = await _fetchMatchingList(); profileApproachingList = profileList.where((profile) => _approachingUserIdList.contains(profile.user)).toList(); profileApproachingList.removeWhere((profile) => _matchingUserIdList.contains(profile.user)); notifyListeners(); return _isSuccess; }
いいねをもらったユーザーのフィルタリング
同様に
ProfileProvider.dart
/// いいねされたユーザーのプロフィール一覧を取得する(マッチング状態のユーザーは除外) Future fetchProfileApproachedList() async { _isSuccess = false; _isSuccess = await _fetchProfileAllList(); _isSuccess = await _fetchMatchingList(); profileApproachedList = profileList.where((profile) => _approachedUserIdList.contains(profile.user)).toList(); profileApproachedList.removeWhere((profile) => _matchingUserIdList.contains(profile.user)); notifyListeners(); return _isSuccess; }
マッチングしたユーザーのフィルタリング
同様です
ProfileProvider.dart
/// マッチングしているユーザーのプロフィール一覧を取得する Future fetchProfileMatchingList() async { _isSuccess = false; _isSuccess = await _fetchProfileAllList(); _isSuccess = await _fetchMatchingList(); profileMatchingList = profileList.where((profile) => _matchingUserIdList.contains(profile.user)).toList(); notifyListeners(); return _isSuccess; }
ユーザー一覧画面の作成
それではユーザー一覧画面を実装していきます
一覧画面は4画面で共通利用するので共通部分を共通化しておきます
リストビューで実装して画像とニックネームと年齢とつぶやきが表示されるような実装にしました
共通利用画面のWidget化
widgets/ProfileListWidget .dart
class ProfileListWidget extends StatelessWidget { ProfileListWidget({ Key? key, required List<ProfileModel> profiles, String? nextUrl, Function? nextAction }) : _profiles = profiles, _nextUrl = nextUrl ?? '/profile-detail', super(key: key); final List<ProfileModel> _profiles; final String _nextUrl; // Flutter Web の場合 dotenv.get('BACKEND_URL_HOST_CASE_FLUTTER_WEB') final Uri _uriHost = Uri.parse(dotenv.get('BACKEND_URL_HOST')); @override Widget build(BuildContext context) { return Consumer<ProfileProvider>( builder: (context, profileProvider, _) { return ListView.builder( itemCount: _profiles.length, itemBuilder: (BuildContext context, int index) { return ListTile( leading: Stack( children: <Widget>[ _profiles[index].topImage != null ? Image.network('${_profiles[index].topImage?.replaceFirst( dotenv.get('STORAGE_URL_HOST'), _uriHost.toString())}', errorBuilder: (context, error, stackTrace) { return Image.asset('images/nophotos.png',); }, ) : Image.asset('images/nophotos.png',), _profiles[index].isKyc ? const Icon( Icons.check_circle, color: Colors.greenAccent, size: 16,) : const SizedBox(), ], ), title: Text('${_profiles[index].nickname} ${_profiles[index].age}歳'), subtitle: Text(_profiles[index].tweet ?? ''), trailing: Icon( profileProvider.checkSendFavorite(_profiles[index].user ?? '') ? Icons.favorite : Icons.favorite_border, color: Colors.pinkAccent, ), onTap: () async { profileProvider.setProfileDetail(_profiles[index]); if(_nextUrl == '/message') await profileProvider.getMessageList(); Navigator.pushNamed(context, _nextUrl); }, ); }, ); } ); } }
プロフィール一覧画面の実装
screens/ProfilesScreen.dart
class ProfilesScreen extends StatelessWidget { const ProfilesScreen({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('プロフィール一覧'), ), body: Consumer<ProfileProvider>( builder: (context, profileProvider, _) { return ProfileListWidget(profiles: profileProvider.profileList); }, ), drawer: DrawerWidget(), bottomNavigationBar: BottomNavBarWidget(), ); } }
いいねを送ったユーザーのプロフィール一覧画面
上記とほぼ同じ実装です
以下の部分が主に違います
screens/ApproachingScreen.dart
return ProfileListWidget(profiles: profileProvider.profileApproachingList);
いいねをもらったユーザーのプロフィール一覧画面
上記と同様です
引数にprofileProvider.profileApproachedListを与えます
マッチング中のユーザーのプロフィール一覧画面
上記と同様ですが、引数にprofiles: profileProvider.profileMatchingList と それだけでなく、一覧のユーザーをタップしたときに、マッチング画面ではメッセージ画面に飛ぶようにnextUrl: '/message',の引数を加えておきます
ユーザー情報詳細画面の実装
一覧画面のうち特定のユーザーをタップすると詳細情報を見られるように詳細画面を作成します
ユーザー詳細選択ロジック
タップしたときにどのユーザーをタップしたかわかるようなロジックをProviderに実装します
ProfileProvider.dart
ProfileModel? profileDetail; /// 選択したユーザーのプロフィールをセットする void setProfileDetail(ProfileModel profile) { profileDetail = profile; notifyListeners(); }
詳細画面実装
詳細画面では先ほど格納したprofileDetail の情報を元に詳細を表示する画面を作成します
レイアウトは適当ですので、適宜手を加えて改善してください
ProfileDetailScreen.dart
class ProfileDetailScreen extends StatelessWidget { const ProfileDetailScreen({Key? key,}) : super(key: key); @override Widget build(BuildContext context) { final Uri _uriHost = Uri.parse('http://10.0.2.2:8000'); return Scaffold( appBar: AppBar( title: const Text('プロフィール詳細'), ), body: Consumer<ProfileProvider>( builder: (context, profileProvider, _) { return Padding( padding: const EdgeInsets.all(32.0), child: SingleChildScrollView( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Padding( padding: const EdgeInsets.all(8.0), child: Text(profileProvider.profileDetail!.nickname, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),), ), Stack( children: <Widget>[ profileProvider.profileDetail!.topImage != null ? Image.network('${profileProvider.profileDetail!.topImage?.replaceFirst('http://127.0.0.1:8000/', _uriHost.toString())}', width: 100, fit: BoxFit.fill, errorBuilder: (context, error, stackTrace) { return Image.asset('images/nophotos.png',width: 100, fit: BoxFit.fill,); }, ) : Image.asset('images/nophotos.png', width: 100, fit: BoxFit.fill,), profileProvider.profileDetail!.isKyc ? const Icon(Icons.check_circle, color: Colors.greenAccent, size: 16,) : const SizedBox(), ], ), Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ Text('${profileProvider.profileDetail!.age.toString()}歳',), Text(sexPicker[profileProvider.profileDetail!.sex] ?? '',), Text(locationPicker[profileProvider.profileDetail!.location] ?? '',), ], ), Padding( padding: const EdgeInsets.all(8.0), child: Text('送ったいいね ${profileProvider.profileDetail!.sendFavorite ?? ''}',), ), Padding( padding: const EdgeInsets.all(8.0), child: Text('もらったいいね ${profileProvider.profileDetail!.receiveFavorite ?? ''}',), ), Padding( padding: const EdgeInsets.all(8.0), child: Text('所持しているいいね ${profileProvider.profileDetail!.stockFavorite ?? ''}',), ), const Padding( padding: EdgeInsets.all(8.0), child: Text('いいねする',), ), Padding( padding: const EdgeInsets.all(8.0), child: IconButton( icon: Icon( profileProvider.checkSendFavorite(profileProvider.profileDetail!.user ?? '') ? Icons.favorite : Icons.favorite_border, color: Colors.pinkAccent ), onPressed: () async { if (!profileProvider.checkSendFavorite(profileProvider.profileDetail!.user ?? '')) { profileProvider.sendFavorite().then((isSuccess) { if (isSuccess) { showSnackBar(context, 'いいねを送りました'); } else { showSnackBar(context, 'エラーが発生しました'); } }); } }, ), ), Padding( padding: const EdgeInsets.all(8.0), child: Text(profileProvider.profileDetail!.tweet ?? '', style: const TextStyle(decoration: TextDecoration.underline),), ), Padding( padding: const EdgeInsets.all(8.0), child: Text('${profileProvider.profileDetail!.height ?? ''}cm',), ), Padding( padding: const EdgeInsets.all(8.0), child: Text('お仕事 ${profileProvider.profileDetail!.work ?? ''}',), ), Padding( padding: const EdgeInsets.all(8.0), child: Text('${profileProvider.profileDetail!.revenue ?? ''}万円',), ), Padding( padding: const EdgeInsets.all(8.0), child: Text(graduationPicker[profileProvider.profileDetail!.graduation] ?? '',), ), Padding( padding: const EdgeInsets.all(8.0), child: Text('趣味 ${profileProvider.profileDetail!.hobby ?? ''}',), ), Padding( padding: const EdgeInsets.all(8.0), child: Text('結婚願望 ${passionPicker[profileProvider.profileDetail!.passion ?? '']}' ,), ), const Padding( padding: EdgeInsets.all(8.0), child: Text('自己紹介',), ), Container( padding: const EdgeInsets.all(8.0), decoration: BoxDecoration(border: Border.all()), child: Padding( padding: const EdgeInsets.all(8.0), child: Text(profileProvider.profileDetail!.introduction ?? '',), ), ), ], ), ), ); }, ), drawer: const DrawerWidget(), bottomNavigationBar: const BottomNavBarWidget(), ); } }
いいね送信機能の実装
さて詳細画面ができたので、詳細画面からいいねを押すことができる機能を作成していきましょう
全体としてのコードはこちらです
sendFavoriteがいいねを送るロジックを記載したコードであり、相手とのいいねの状態によって処理が異なります
check〇〇Faoriteはコメントアウト通りの処理になっており条件判定に使います
ProfileProvider.dart
/// いいねをしたユーザーかどうかをチェックする bool checkSendFavorite(String userId) { return _approachingUserIdList.contains(userId); } /// いいねをくれたユーザーかどうかをチェックする bool checkReceiveFavorite(String userId) { return _approachedUserIdList.contains(userId); } /// いいねを送る・承認する Future sendFavorite() async { _isSuccess = false; try { String approachUserId = profileDetail != null ? profileDetail!.user! : ''; // 既にこちらからいいねを送っている場合は処理を行わない if (checkSendFavorite(approachUserId)) { // 何も処理を行わない } // 既に相手からいいねが来ている場合は上記のリクエストデータのapprovedをTrueにしていいねを行い、相手のマッチングモデルデータのapprovedもTrueに更新する else if (checkReceiveFavorite(approachUserId)) { // いいね新規作成処理 await _createFavorite(approached: approachUserId, approved: true); // いいねをくれたユーザーのマッチングリストの中でapproachUserIdと一致するマッチングデータのIDを探索する Iterable<MatchingModel> approachMatching = _approachedList.where((matching) => matching.approaching == approachUserId); int approachMatchingId = approachMatching.first.id ?? 0; // 相手のいいねデータに対する承認処理 await _patchApproved(id: approachMatchingId); // マッチングデータ再取得 await fetchProfileMatchingList(); } // どちらもいいねを送っていない場合は上記のリクエストデータを用いてマッチングデータを作成する else { // いいね新規作成処理 await _createFavorite(approached: approachUserId, approved: false); // いいねしたユーザー一覧データ再取得 await fetchProfileApproachingList(); } _isSuccess = true; } catch(error) { print(error); _isSuccess = false; } notifyListeners(); return _isSuccess; } /// 【プライベート】マッチングデータ新規作成 Future _createFavorite({required String approached, required bool approved}) async { Dio dio = Dio(); List<Cookie> cookieList = await _prepareDio(dio); return await dio.post( '/api/favorite/', options: Options( headers: { 'Authorization': 'JWT ${cookieList.first.value}', }, ), data: { "approached": approached, "approved": approved, }, ); } /// 【プライベート】マッチングデータ承認フィールド更新 Future _patchApproved({required int id}) async { Dio dio = Dio(); List<Cookie> cookieList = await _prepareDio(dio); return await dio.patch( '/api/favorite/$id/', options: Options( headers: { 'Authorization': 'JWT ${cookieList.first.value}', }, ), data: { "approved": true }, ); }
既にもらっているいいねに対する承認ロジックについて
条件分岐ごとの処理はコメントアウトに記載されている通りですが、すでにいいねをもらっている人にいいねを送る処理というのは言い換えるといいねを承認していることと同じです
相手からすでにいいねが来ているということは、相互でいいねを送り合うので、マッチング成立ととらえます
こちらからは承認済のいいね(マッチングモデルデータ)を送信して、相手からすでにもらっているいいねデータのapprovedを承認済みに変更します
approvedはメッセージ送信を許可するかどうかを判定するためのフィールドであるため必ず両者のマッチングデータのapprovedをTrueにします
相手のデータを更新する必要があるため、_patchApprovedのメソッドを作成しています(※Djangoの記事でバックエンドのPatchを拒否する設定になってたかもしれないので、そうなっていた場合は当該コードをコメントアウトするなり修正してください)
これにて、いいね機能を実装することができました
メッセージ機能の実装
最後にメッセージ機能を作成します
ModelとProviderの実装
Modelの実装
MessageModel .dart
class MessageModel { late int? id = 0; late String sender = ''; late String receiver = ''; late String message = ''; late String? createdAt; MessageModel({ this.id, required this.sender, required this.receiver, required this.message, this.createdAt, }); }
全件メッセージを取得するロジック
別ファイルに分けたいのは山々ですが、Provider間でのデータの受け渡しに関しては、理解できておらず状態管理がうまくできるコードを作成できないので、ProfileProviderが長大になるとは思いつつもこちらに追記していきます
上記で実装してきた容量で状態管理したい変数とロジックを作成していきます
ProfileProvider.dart
/// メッセージ機能 List<MessageModel> messageList = []; List<MessageModel> _sendMessageList = []; List<MessageModel> _receiveMessageList = []; String newMessage = '';
バックエンドからデータを取得してデータを格納する処理
ProfileProvider.dart
/// 【プライベート】送ったメッセージの内容を全件取得する Future _fetchSendMessageList() async { Dio dio = Dio(); List<Cookie> cookieList = await _prepareDio(dio); final Response<dynamic> message = await dio.get( '/api/dm-message/', options: Options( headers: { 'Authorization': 'JWT ${cookieList.first.value}', }, ), ); _sendMessageList = _inputMessageModelList(message.data!); } /// 【プライベート】受け取ったメッセージの内容を全件取得する Future _fetchReceiveMessageList() async { Dio dio = Dio(); List<Cookie> cookieList = await _prepareDio(dio); final Response<dynamic> message = await dio.get( '/api/dm-inbox/', options: Options( headers: { 'Authorization': 'JWT ${cookieList.first.value}', }, ), ); _receiveMessageList = _inputMessageModelList(message.data!); } /// 【プライベート】バックエンドから取得したメッセージデータのProvider化 MessageModel _inputMessageModel(dynamic message) { return MessageModel( id: message!['id'], sender: message!['sender'], receiver: message!['receiver'], message: message!['message'], createdAt: message!['created_at'], ); } /// 【プライベート】バックエンドから取得したメッセージデータ一覧のProvider化 List<MessageModel> _inputMessageModelList(dynamic messageList) { return messageList.map<MessageModel>( (message) => _inputMessageModel(message) ).toList(); }
特定のユーザーとのメッセージだけにフィルタリングするロジック
マッチング成立画面からメッセージ画面に移るときに呼び出されるAPIを実装したものが以下です
ProfileProvider.dart
/// 指定したユーザーとのメッセージの内容を取得する Future getMessageList() async { _isSuccess = false; messageList.clear(); try { // バックエンドから自身に関連するメッセージ一覧を取得する await _fetchSendMessageList(); await _fetchReceiveMessageList(); // 指定したユーザーとのメッセージだけをフィルタリングして新規メッセージ順に messageList に格納する messageList.addAll(_sendMessageList.where((message) => message.receiver == profileDetail!.user)); messageList.addAll(_receiveMessageList.where((message) => message.sender == profileDetail!.user)); messageList.sort((alpha, beta) => alpha.createdAt!.compareTo(beta.createdAt!)); // 処理成功 _isSuccess = true; } catch(error) { print(error); _isSuccess = false; } notifyListeners(); return _isSuccess; }
新規メッセージ作成
新たなメッセージを送る処理です
ProfileProvider.dart
/// 指定したユーザーに新規メッセージを送信する Future createMessage() async { _isSuccess = false; try { Dio dio = Dio(); List<Cookie> cookieList = await _prepareDio(dio); await dio.post( '/api/dm-message/', options: Options( headers: { 'Authorization': 'JWT ${cookieList.first.value}', }, ), data: { "receiver": profileDetail!.user, "message": newMessage, }, ); newMessage = ''; await getMessageList(); _isSuccess = true; } catch(error) { print(error); _isSuccess = false; } notifyListeners(); return _isSuccess; }
メッセージ画面の実装
まずはメッセージ画面を作成します
コードが長くなるので、MessageListWidgetで一部ウィジェット化しています
MessageScreen.dart
class MessageScreen extends StatelessWidget { const MessageScreen({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return Consumer<ProfileProvider>( builder: (context, profileProvider, _) { return Scaffold( appBar: AppBar( title: Text('${profileProvider.profileDetail!.nickname}とのメッセージ'), ), body: Column( children: [ Expanded(child: MessageListWidget(messages: profileProvider.messageList,),), Padding( padding: const EdgeInsets.only(left: 8.0, right: 8.0, bottom: 4.0), child: TextFormField( onChanged: (value) => profileProvider.newMessage = value, decoration: InputDecoration( labelText: 'メッセージを送りましょう', suffixIcon: IconButton( onPressed: () { profileProvider.createMessage().then((isSuccess) { if (isSuccess) { showSnackBar(context, 'メッセージが送信されました'); } else { showSnackBar(context, 'エラーが発生しました'); } }); }, icon: const Icon(Icons.send) ), ), maxLength: 500, ), ), ], ), drawer: const DrawerWidget(), bottomNavigationBar: const BottomNavBarWidget(), ); }, ); } }
Widget化したメッセージ一覧画面コンポーネントの実装
こちらもレイアウトは適当です
LINEのような言わゆるよく見るチャットのレイアウトを作成する実力はなかったのでひとまずこちらの実装となっています
実運用を目指すのであればブラッシュアップが必要です
widgets/MessageListWidget.dart
class MessageListWidget extends StatelessWidget { MessageListWidget({ Key? key, required List<MessageModel> messages, }) : _messages = messages, super(key: key); final List<MessageModel> _messages; // Flutter Web の場合 dotenv.get('BACKEND_URL_HOST_CASE_FLUTTER_WEB') final Uri _uriHost = Uri.parse(dotenv.get('BACKEND_URL_HOST')); @override Widget build(BuildContext context) { return Consumer<ProfileProvider>( builder: (context, profileProvider, _) { return ListView.builder( itemCount: _messages.length, itemBuilder: (BuildContext context, int index) { String imageUrl = profileProvider.myProfile.topImage ?? ''; String sender = profileProvider.myProfile.nickname; String createdAt = _messages[index].createdAt ?? ''; if(_messages[index].sender == profileProvider.profileDetail!.user) { imageUrl = profileProvider.profileDetail!.topImage ?? ''; sender = profileProvider.profileDetail!.nickname; } if(imageUrl != '') imageUrl.replaceFirst(dotenv.get('STORAGE_URL_HOST'), _uriHost.toString()); return ListTile( leading: imageUrl != '' ? Image.network(imageUrl, errorBuilder: (context, error, stackTrace) { return Image.asset('images/nophotos.png',); }, ) : Image.asset('images/nophotos.png',), title: Text('$index ${_messages[index].message}'), subtitle: Text('$sender $createdAt'), ); }, ); } ); } }
これでマッチングアプリに必要な機能をすべて実装することができました
お疲れ様です
動作確認を行って想定どおりに動くか確認しておきましょう
買った指輪が最高!給料3か月分で買った結婚指輪の魅力
はじめに
買ってよかった2023かきかきします~
2023年に買ってよかったもの
ずばり、給料3か月分の婚約指輪です
去年の2023年にダイキは結婚したのですが、その時に結婚指輪を購入しました
その時に買った給料3か月分の指輪が、本当にイマ思っても買ってよかったものだなと思っています
ダイヤの価値より思い出重視
ダイヤに関しては、調べると、ダイヤ自体の価値って右肩下がりになるような未来しか想定されていないようですね
自分の時期ではダイヤの値段が高騰していましたが、人工ダイヤモンドの技術革新があって、それとの差別化ができなくなってきているようですね
その部分に関しては、理解していました
しかし、それでもなお買いました
なぜなら、ダイヤが大事だからではないからです
僕が大事なのは妻です
その妻との思い出が一番大事だからです
給料3か月というエピソードこそが最も尊いものだと感じたからです
そして、実際に買ってみてその通りだったと信じています
ちなみに、これはプロポーズの後に知ったことですが、プロポーズにダイヤモンドを女性に贈るのは、ダイヤ売り会社のマーケティング戦略らしいですね
僕はまんまとその戦略に乗せられたようですが、全然それでよかったなと思います
むしろそういった王道ストーリーを用意してくれてよかったなと思います
給料3か月分とかそういうのも非常に王道で僕はこういうの好きです
しかし、最近だと給料3か月って言うのをやっている男性は少ないらしいですね
この辺の流れは、指輪を買いに行くまで知らなくて、周囲の男性はあんまし金使わないのかというのに驚きました
周囲の男性は一斉一大のイベントなんだから頑張ったらいいのにと思いますが、そのムーブメントのおかげで自分のやっていることが余計に妻想いのエピソードになったので、むしろありがたいかなと今では思っています
やはり、自分のしたことはすごく漢気を見せられたと思います
やはり男はかっこつけてなんぼですなと思いました
ダイヤ自体もいいものを買えた
あと、ダイヤの値段が高騰している時期でしたが、自分は滑り込みセーフでまだ価格が爆上がりしていない頃のダイヤを買うことができました
なので、値段の割にしょぼすぎるしょぼぼぼんというようなダイヤでもありませんでした
かなりいいものです
個人的にめちゃくちゃいいものを買えたと思っています
すごくきれいで、そのダイヤ一生見てられるくらいきれいでした
他のダイヤと違って何かが違うと思ってそれにしましたが、やっぱりなんか違うんですよね
僕たちに買われるためのダイヤだったんだろうなと思っています
おわりに
これから、プロポーズを控えている男性は、がんばれるなら背伸びしましょう
きっとものすごく喜んでくれるはずです
健闘を祈ります
▲ ここに「買ってよかった2023」を書こう
Three.jsでおみくじアプリを作ったよん
はじめに
Three.jsで何か作ろうかなーとずっと思っていましたが、パッと思いついてできそうなのがおみくじアプリだったので、作りました
こんな感じです!

詳細は別ブログで書くのではてブではこの辺にしときます~
いいかんじでしょ!
Three.js 学習記録 基本的な使い方
はじめに
Three.jsの学習を記録していきます
Three.jsを使いこなしてゆくゆくは3次元的なサービスを提供できるフロントエンジニアを目指します
ビルドツールとThree.jsの導入
Viteを利用します
ja.vitejs.dev
自分はユーザー名に半角スペースを含むため、yarnではコマンドが失敗するためnpmでインストールします
ユーザー名の半角スペースでつまずく場合の参考:https://qiita.com/koseidaiki/items/d2be30379a7f05c37f62
npm create vite@latest [プロジェクト名]
純粋なJSで開発する場合はvanilla
vueやreactで開発する場合はそちらを選択します
以下では純粋にJavaScriptで開発する方法で基本的な使い方を記載します
その後基本準備として
その後プロジェクトフォルダに移動して、
npm install three
package.jsonの内容をインストールする
npm install
Three.jsパッケージ使い方基本
JSで開発する場合にどうやって使うかですが、JSファイルに以下のモジュールをインポートして使います
import * as THREE from "three";
Three.js必要三要素と基本的な使い方
1. シーン 2. カメラ 3. レンダー
以下の3つの要素が必要であり、基本形は以下のようになる
1. シーン
2. カメラ
3. レンダー
main.js
import * as THREE from "three"; let scene, camera, renderer; window.addEventListener("load", init); function init() { // シーン scene = new THREE.Scene(); // カメラ(視野角、アスペクト比、開始距離、終了距離) camera = new THREE.PerspectiveCamera( 50, window.innerWidth / window.innerHeight, // ブラウザの横幅 / ブラウザの縦幅 0.1, 1000 ); camera.position.set(0, 0, 500) // レンダラ renderer = new THREE.WebGLRenderer({alpha: true}); // 背景を設定する場合は透過度を設定する renderer.setSize(window.innerWidth, window.innerHeight) document.body.appendChild(renderer.domElement); renderer.render(scene, camera); }
index.html
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>ThreeJs</title> </head> <body> <script src="main.js" type="module"></script> </body> </html>
4 テクスチャ 5. ジオメトリ 6. マテリアル 7. メッシュ
上記では物体が表示されないので物体を配置したい場合はテクスチャ、ジオメトリ、マテリアル、メッシュを設定する
基本形は以下
main.js
// テクスチャ = 柄 (https://threejs.org/docs/index.html?q=texture#api/en/loaders/TextureLoader) let textures = new THREE.TextureLoader().load("画像のPath") // ジオメトリ = 骨格 (https://threejs.org/docs/index.html?q=geometry#api/en/geometries/SphereGeometry) let ballGeometry = new THREE.SphereGeometry(100, 64, 32) // 例:球形 // マテリアル = 材質 (https://threejs.org/docs/index.html?q=material#api/en/materials/MeshPhysicalMaterial) let ballMaterial = new THREE.MeshPhysicalMaterial({ map: textures // テクスチャを設定 }); // メッシュ = ジオメトリ + マテリアル let ballMesh = new THREE.Mesh(ballGeometry, ballMaterial) scene.add(ballMesh) // 最後にシーンに追加する // 平行光源を設定することで物体を視認することができる let directionalLight = new THREE.DirectionalLight(0xffffff, 2); directionalLight.position.set(1,1,1) scene.add(directionalLight) // 最後にシーンに追加する
8. アニメーション
また、基本的に動きのある画面を作りたいはずなので、アニメーションを使う
以下のようにアニメーション関数を作って、その中にレンダリングを入れて、変更のたびに再レンダリングをするような実装を行う
main.js
function animate() { // おまじない requestAnimationFrame( animate ); // 動きのロジック - 例えばメッシュ(物体)の位置を変更するアニメーションをつけるなど // ballMesh.position.x += 時間に応じて加算 // レンダラをこちらに入れる renderer.render( scene, camera ); } // アニメーションを実行 animate();
ところで、これらにはバリエーションがありそのユースケースは以下のようになる
1. シーンについて
シーンについては基本的にバリエーションはなく思考停止で
scene = new THREE.Scene();
を使用すればよい
https://threejs.org/docs/index.html#api/en/scenes/Scene
2. カメラについて
カメラについては2つほどバリエーションがある
自分のアプリをどのように見せたいかを考えて適切なカメラを選択するとよい
1. PerspectiveCamera
遠近法(遠くのものは小さく)が適用される一番直観的な普通のカメラ
ユースケースは一番多いと思われる
https://threejs.org/docs/index.html#api/en/cameras/PerspectiveCamera
2. OrthographicCamera
遠近法が適用されないカメラ
遠くのものも近くのものも同じ大きさに見える
https://threejs.org/docs/index.html#api/en/cameras/OrthographicCamera
2.1. カメラや物体のコントロールについて
カメラや物体のコントロール制御をできる簡便な機能がある
基本的には一番直観的なOrbitControlsを使っておけばよい
https://threejs.org/docs/index.html?q=Controls#examples/en/controls/OrbitControls
他一人称視点でコントロールしたいときは、FirstPersonControlsがある
https://threejs.org/docs/index.html?q=Controls#examples/en/controls/FirstPersonControls
2.2 光源について
Three.jsでは光源を必要とするマテリアル(MeshPhysicalMaterial)と必要としないマテリアル(MeshBasicMaterial、MeshNormalMaterial)があり、光源を必要とするマテリアルを使う場合に光源を設定する必要がある
光源にも種類があり、およそ4パターンほどある
1. 全体光源 AmbientLight
全方向から均一の光が当たる光源です
ちなみにThree.jsでは同じ物体の表裏の影はシミュレーションで表現されるが、他の物体に光がさえぎられても影ができることは基本的になかったり、他の物体から反射する光などはシミュレーションされていないので、物体の裏側の影の強さを表現することに使ったりすることができます
https://threejs.org/docs/index.html?q=Light#api/en/lights/AmbientLight
2. 平行光源 DirectionalLight
名が体を表している光源です
https://threejs.org/docs/index.html?q=Light#api/en/lights/DirectionalLight
3. 半球光源 HemisphereLight
2つの平行光源が互いに逆方向からやってくる光源です色を変えるなどができます
ユースケースは空と大地の対比を表現したいときに便利です
https://threejs.org/docs/index.html?q=Light#api/en/lights/HemisphereLight
4. 点光源 PointLight
とある1点から拡散する光源です
豆電球のイメージです
https://threejs.org/docs/index.html?q=Light#api/en/lights/PointLight
5. スポットライト光源 SpotLight
スポットライトや懐中電灯の光源のイメージです
https://threejs.org/docs/index.html?q=Light#api/en/lights/SpotLight
6. RectAreaLight
スタジオにおいてある四角形に広がる光源です
THREE デフォルトにないパッケージであることに注意が必要です
https://threejs.org/docs/index.html?q=Light#api/en/lights/RectAreaLight
3. レンダーについて
こちらもあまりバリエーションは考えなくていい
WebGLRendererを使う
https://threejs.org/docs/index.html#api/en/renderers/WebGLRenderer
4 テクスチャ
テクスチャーは画像を用意して物体に貼るというユースケースが最も多いと思われる
その場合は思考停止でTextureLoaderを使えばよい
https://threejs.org/docs/index.html?q=texture#api/en/loaders/TextureLoader
5. ジオメトリ
ジオメトリがそもそも何なのかというと物体の表面の任意の頂点を結んだ格子上の骨格のことです
不規則な形をとる物体の場合、任意の頂点の数が多ければ多いほど再現度が高くなります
フラーレンのイメージです
ジオメトリは種類が多く、こちらから使いたい出来合いのジオメトリを使うことが便利です
https://threejs.org/docs/index.html?q=geometry
よく使うものとしてはBoxGeometry, PlaneGeometry, SphereGeometry, TorusGeometry あたりだと思います
任意の形を自作する場合はBufferGeometryのベーシッククラスを継承して作成するか他の3Dソフトで作成したものをインポートする必要がある
6. マテリアル
マテリアルは物体の材質に相当するものです
大きく分けて光源を必要とするものと光源を必要としないものに分かれます
光源を必要としないものはMeshBasicMaterialとMeshNormalMaterialがあります
光源を必要とするものにはMeshPhysicalMaterialがあります
他のパラメータとしては金属光沢や粗さ、色などがあります
パラメータが多くデザインの範囲なのでUIデバッグをつけると便利です
https://threejs.org/docs/index.html?q=material
7. メッシュ
フラーレンに皮をつければサッカーボールになるイメージです
メッシュも基本的には組み立て作業に相当するためバリエーションは特にありません
思考停止でMeshオブジェクトを生成します
これが物体に相当します
注意点としてはこれを作った後にシーンに追加することを忘れないようにします
例
const box = new THREE.Mesh( new THREE.BoxGeometry(1,1,1), new THREE.MeshNormalMaterial() ) scene.add(box) // これを忘れない
UIデバッグ
UIデバッグというのはわざわざコードに戻らなくても画面上でパラメータの変更ができる機能のことです
この機能を使うと画面右上にパラメータをいじれるUIが出てくる

lil-guiを使用する
インストール方法は下記
lil-gui.georgealways.com
インスタンスを生成して、パラメータを追加するとすぐに利用できる(CDNを利用)
sceneにメッシュを追加するのと同じ要領
import GUI from 'https://cdn.jsdelivr.net/npm/lil-gui@0.16/+esm'; const gui = new GUI() // boxというオブジェクトを作成 gui.add(box.position, "x") // x軸に沿って動かせるようになる gui.add(box.position, "x", -3, 3, 0.01) // ドラッグで動かせるようになる gui.add(box.position, "x").min(-3).max(3).step(0.01) // こういう書き方もできる
.nameを使用すると名前を付けることができる

THREE デフォルトにないパッケージを使う
THREE デフォルトにないパッケージを使う場合、jsmフォルダ内にある可能性がある
フォルダパス自体は以下
three.js-r[VERSION]\three.js-r[VERSION]\examples\jsm
光源のhelpers関数などがこちらに入っている場合がある
おわりに
今後は本格的なフロントエンド開発を行っていきたいので、Vue.jsにおけるThree.jsの使い方を調査していこうとおもいます
[追記]
残念ながらVue.js3.0でThree.js開発を行うことは現実的ではない様子
Three.jsをそのまま使う場合、Vueの書き方との相違が多くかなり使いずらいことが分かっている
そのためVueGLというライブラリが用意されているが、それもVue3.0系にはまだ対応しておらず、実質VueでのThree.js開発はあきらめた方が良いことが分かった
最近のフロントエンドはほぼReactによってきつつあるようにも見えるので、まだフロントエンドのどのフレームワークにも染まっていない人はReactを選んでおけば間違いがない気がします(Vue3.0簡単とか言ってるやつ、あれは詐欺です。Vueは書き方がありすぎてもはやブログなどで検索して出てきた情報が全くアテにならなかったりするので、ぶっちゃけ初心者には難しいです。Vueのドキュメントで読んで理解が進められる人はReactでも同じではと思ったりします。サポートされているライブラリもReactの方が多い印象ですし。)
Powershell上でyarnの実行ができない場合の対処法
Powershell上でyarnの実行ができない場合の対処法
PowerShellのセキュリティまわりの設定が原因である場合、以下の記事に対処法が書いてある
https://qiita.com/ponsuke0531/items/4629626a3e84bcd9398f
hoge.psはyarn.psに読み替える
結論としてはこのコマンドを実行する
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Process
powershellでyarnを実行させたいときは毎回このコマンドをたたくこと
根本的な解決方法ではなく、毎回コマンドを叩かないといけなくてめんどくさいが他の方法が分からなかったため暫定
※npmを実行できない場合も同様のコマンドで対処が可能となる
※以下が実行できない場合も同様
vue create [プロジェクト名]
【開発ナレッジ】Django実践アプリ機能実装ナレッジ~環境変数設定・決済機能・カスタムユーザー・ログイン処理・非同期処理・キャッシュ制御・ログ・OAuth認証・バッチ処理~
できること(抜粋)
講座1
講座2
目次
- 自環境
- ECサイトの構築
- Secretsフォルダの利用
- モデルとかテンプレートの作成
- 決済機能
- エラーハンドリング
- カスタムユーザモデルを作成する
- モデルの作成
- カスタムモデルを使用するという宣言をするためのsetting.pyの編集
- ユーザー作成用のフォームを作成する
- 管理者画面にカスタムユーザーを表示させられるように admin.py を編集する
- マイグレーション初期化(一度マイグレーションをしてしまった後に後からカスタムユーザーを適用する場合)
- マイグレーションをしてカスタムユーザーを適用する
- ログイン・サインアップ画面を作成する
- ユーザー情報変更用画面を作成する
- プロフィール変更用画面を作成する
- Viewを作成してログイン・サインアップ・アカウント情報更新機能を作成する
- ルーティングurls.pyを作成する
- ログアウト機能の実装
- Tips! Djangoテンプレートエンジン使用時のHTMLでのログイン判定
- カスタムユーザーを使用したログイン処理(関数ベースView)
- AJAX非同期処理とキャッシュ制御(関数ベースView)
- クラスベースビューを使用した主にログイン処理
- ログイン処理(クラスベースView)
- MiddleWareを利用したログ出力の実装(Pythonの基本ログ出力方法)
- MiddleWareを利用したログ出力の実装(Djangoの基本ログ出力方法)
- OAuth認証の実装
- OAuth認証実装 Djangoソースコード側の手順
- バッチ処理の実装・運用
- コンテキストプロセッサー
- TIPS! テンプレート構文を使用したときにHTMLのインデントがずれてしまうとき
- Tipa! 404 Not Foundページの実装
自環境
- OS:Windows 10
- シェル:powershell
- Python:3.8系
- エディター:PyCharm
- 仮想環境:Anaconda Navigator
ECサイトの構築
仮想環境の準備は以下参照
https://shinseidaiki.hatenablog.com/entry/2021/11/07/232602
使うパッケージ requirements.txt
asgiref==3.4.1 certifi==2021.5.30 charset-normalizer==2.0.3 Django==3.2.5 django-environ==0.4.5 idna==3.2 Pillow==8.3.2 pytz==2021.1 requests==2.26.0 sqlparse==0.4.2 stripe==2.60.0 urllib3==1.26.6
requirements.txtをインストールする場合は上記ファイルを任意のフォルダに配置して、
仮想環境になっていることを確認して
以下のコマンドを仮想環境で実行する
pip install -r requirements.txt
なお記事投稿者が記事記入時はDjangoが4にアップグレードされていたため、上記の方法で環境を合わせた
初期準備
django-admin startproject udemyec django-admin startapp base
フォルダの作成
templates, secrets, static
Tips!
setting.pyのBASE_DIRはpathlibの組み込み変数でプロジェクトのルートパスを差す
BASE_DIR / 'templates' の書き方でもOSに応じて臨機応変にパスを作ってくれる
Secretsフォルダの利用
GitHubにおおよそのことは書いていた
https://github.com/TakumaFujimoto/vegeket_project/blob/main/sec03/README.md
環境変数を扱うためにインストールする
django-environ
環境変数を作成する
secretsフォルダ直下に以下を作成
.env.dev .env.prod
環境に応じた環境変数を用意しておくことで開発を円滑に行うことが出来るようにする
.env.devは開発環境用
.env.prodは本番環境用(productの略)
環境変数に格納する値はクオテーションなしで登録
.env.dev(開発環境)
SECRET_KEY=django-xxxxxxxxxxxxxx DEBUG=True ALLOWED_HOSTS=*
.env.prod(本番環境 - デプロイする場合)
SECRET_KEY=django-xxxxxxxxxxxxxx DEBUG=false ALLOWED_HOSTS=[特定のホスト]
setting.pyに環境変数を適用
import environ env = environ.Env() root = environ.Path(BASE_DIR / 'secrets') # 本番環境 # env.read_env(root('.env.prod')) # 開発環境 env.read_env(root('.env.dev')) SECRET_KEY = env.str('SECRET_KEY') DEBUG = env.bool('DEBUG') ALLOWED_HOSTS = env.list('ALLOWED_HOSTS')
rootはプロジェクト直下に.envファイルを置く場合は記述する必要はない
本番環境と開発環境で自動で分ける場合はif文を使って条件分岐をする必要がある模様
具体的な例はわからなかったが、以下が参考になるかもしれない
https://ikura-lab.hatenablog.com/entry/2019/06/16/142339
.gitignoreに関してはこちらの記事などを参照にする
どこかでまとめる必要がある
https://leading-tech.jp/wiseloan/gitignore/
このあたりを記述
*.log *.pyc __pycache__/ secrets/ db.sqlite3
モデルとかテンプレートの作成
省略
この人のフォルダ構成はtemplatesフォルダ内に以下のフォルダとファイルがある
base.html
pagesフォルダ
snippetsフォルダ
modelsフォルダを作成してその直下に__init__.pyファイルを配置すればフォルダがmodelsとして認識される
modelsが増えてきた場合に有効
フォルダ化にはmodelごとに自由なファイル名のモデルを定義することができる
なお、initiは初回に読み込まれるので、以下のような記述を行っておくとモデルが読み込まれる
__init__.py
from .item_models import *
画像を別のストレージで使用する場合については別途調査が必要
Itemの外部キーのcategoryのondeleteについてはon_delete=models.SET_NULLにした方がよい
なぜならカテゴリ削除と同時にItemを削除したくはないから
カテゴリが削除された場合はNullを詰める
ManyToManyでリレーションをとる場合はtagsのような複数形の名前にする方が慣例の模様
ManyToManyの場合は中間テーブルが内部的に作成されて直接つながっていないためondeleteは設定する必要がない
ここではカスタムユーザーモデルは先に作ってない
https://github.com/TakumaFujimoto/vegeket_project/blob/main/sec06/README.md
admin画面はunregisterを使用すると非表示にすることもできる
クラスベースビューと関数ベースビューの違い
クラスベース
class IndexListView(ListView): model = Item template_name = 'pages/index.html'
関数ベースで書いた場合
def index(request): object_list = Item.objects.all() context = { 'object_list': object_list, } return render(request, 'index.thml', context )
Tips!
for文の中で使える便利なものが存在する
{{ forloop }}
for文が何回回ったかなどを出力できる
Tips! get post
def postやdef getをクラスベースビューで記述するとPOSTの時やGETメソッドの時に実行される関数を記述することができる
class AddCartView(View): def post(self, request):
Tips! OrderdDict
OrderedDict()はPython標準ライブラリで搭載されている
辞書の場合、追加するときにListと違って順番が保持されないために順番を保持するために使用できるメソッド
順序月の辞書が作れる
Tips! get_queryset, get_context_data
get_querysetについて
データをDBなどから取得するメソッド
ListViewがget_querysetをもともと持っており、デフォルトではモデルのすべてのアイテムを返すようになっている
カスタマイズする場合はget_querysetをオーバーライドする
また、obj.quantityやobj.subtotal のようにmodelsに定義されていないquantityのような値はviewにおいて新たに定義することのできるインスタンス変数となり、DBには保存されないがプログラムで自由に使用することができるようになる
あらかじめモデルで定義していなくても自由にインスタンス変数を追加することができる
self.queryset = []で定義してフォー分で回して、self.queryset.append(obj)したりしてquerysetを作成するようにして作る
get_context_data
テンプレートに値を渡すメソッド
context["total"] = self.total のような記述をすることでテンプレート上でtotalというキーで値にアクセスすることができるようになる
また以下のようにjsonから辞書型に変換して画面に値を渡すことも可能
context["items"] = json.loads(obj.items)
Tips!
管理画面者の編集
以下のadminの中のファイルローカルにコピーして上書きすることで編集することが可能(元ファイルは当然ながら編集しない)
https://github.com/django/django/tree/main/django/contrib/admin/templates
例えば、base_site.htmlをtemplateルートフォルダ下(setting.pyのDIRSのところで設定している場所、基本はプロジェクト直下にテンプレートを指定することが一般的)にadminフォルダを作成し、その下にgithub上のdjangoテンプレートと同じフォルダ構成でファイルを配置する
base_site.htmlの内容を上書きするとadminサイトに変更が反映される
CSSを書き換えたい場合は 管理画面はstatic: admin/css/base.cssを継承しているためこのファイルを作成して継承すればいい(CSSなのでstaticフォルダ以下であるところに注意)
Github上の場所は以下
https://github.com/django/django/tree/main/django/contrib/admin/static/admin/css
決済機能
決済機能についての簡単な説明がある
https://github.com/TakumaFujimoto/vegeket_project/blob/main/sec10/README.md
ここではStripeを利用する
Stripeで開発者向けの開発用の決済機能を無料で試すことができる
Stripe: 世界で最もシェアを獲得するクレジットカード決済代行サービス
各種セキュリティ・法規制に対応しているため決済代行サービスを使わないで課金が必要な自作サービスを実装する選択肢はほぼ皆無だと思われる(特に個人)
使用時の手数料3%以外の費用がないため個人でマッチングアプリを実装したい場合(年齢確認用)などで利用できる
Stripeのドキュメント
Stripe のドキュメント
Stripe使用準備
アカウントの作成
上記ストライプの公式サイトからアカウントを作成する(メール認証があるので認証を済ませる)
アカウントを作成するとダッシュボードに移動する
最初から開発環境を提供してくれている模様
本番環境を利用する場合は別途申請が必要となる
ここではすべて開発環境だけを使用する
Stripeパッケージのインストール
pip install stripe
Stripeに使用するテンプレート画面の作成
Stripeドキュメントに記載されるテンプレートを使用する
https://stripe.com/docs/payments/checkout
こちらにサンプルプロジェクトが記載されている
https://stripe.com/docs/checkout/quickstart
講座の時とは内容が更新されている模様
フィルタリングがあるので、HTML PYTHONを選択して今回はPythonのテンプレートを表示して模倣する
テンプレートとしては以下がある
- server.py
- checkout.html チェックアウト、ECサイトにおいてはショッピングカートページを差す画面の見本
- success.html 決済が成功した後に表示される画面
- cancel.html 決済が失敗した場合や戻るボタンを押された場合に表示する画面
テンプレートはあくまで三本であり、部分的に使用できる場合は、必要な箇所の機能だけを模倣すればよい
success.htmlを作成する
templates/pages/success.html
{% extends 'base.html' %}
{% block main %}
<div class="container my-5">
<div class="row">
<div class="col-12">
<h1>Thank you!</h1>
<!-- 注文履歴確認ページ -->
<p>注文履歴は<a href="/orders/">こちら</a></p>
</div>
</div>
</div>
{% endblock %}
cancel.htmlを作成する
templates/pages/cancel.html
{% extends 'base.html' %}
{% block main %}
<div class="container my-5">
<div class="row">
<div class="col-12">
<h1>Cancel</h1>
<p>うまく処理されませんでした。カートは<a href="/cart/">こちら</a></p>
</div>
</div>
</div>
{% endblock %}
.envを編集する
環境変数にSecrets情報を格納しておく
今回は開発環境のみなので、.env.devのみを編集する
secrets/.env.dev
STRIPE_API_KEY=[STRIPEのAPIキー] MY_URL=http://127.0.0.1:8000
[STRIPEのAPIキー]は公式のダッシュボードからコピーして貼り付ける
https://dashboard.stripe.com/
ホームの真ん中の方に開発者向けという項目があるので、シークレットキーをコピペする
なお、公開可能キーは自分でカスタマイズした決済ページを実装する場合に必要になってくるものになる
Stripeの決済ページにリダイレクトする今回のような方法の場合ではシークレットキーのみでよい
今使用しているシークレットキーは開発環境用のテスト用シークレットキーとなっており、本番環境で使用する場合は、別途申請を行って本番環境用のシークレットキーを入手して、 .env.prod に本番環境用のシークレットキーをコピペして使用する
setting.pyの編集
設定ファイルにシークレットキーの環境変数を読み込ませる
setting.pyに以下を追記
# Stripe API Key STRIPE_API_SECRET_KEY = env.str('STRIPE_API_SECRET_KEY') # スキーマ&ドメイン MY_URL = env.str('MY_URL')
ビューの作成
base/views/pay_views.py
from django.shortcuts import redirect from django.views.generic import View, TemplateView from django.conf import settings from stripe.api_resources import tax_rate from base.models import Item import stripe # Stripeのシークレットキーを読み込む stripe.api_key = settings.STRIPE_API_SECRET_KEY class PaySuccessView(TemplateView): template_name = 'pages/success.html' def get(self, request, *args, **kwargs): # 最新のOrderオブジェクトを取得し、注文確定に変更 # カート情報削除 del request.session['cart'] return super().get(request, *args, **kwargs) class PayCancelView(TemplateView): template_name = 'pages/cancel.html' def get(self, request, *args, **kwargs): # 最新のOrderオブジェクトを取得 # 在庫数と販売数を元の状態に戻す # is_confirmedがFalseであれば削除(仮オーダー削除) return super().get(request, *args, **kwargs) tax_rate = stripe.TaxRate.create( display_name='消費税', description='消費税', country='JP', jurisdiction='JP', percentage=settings.TAX_RATE * 100, # 100分率整数で渡す必要がある inclusive=False, # 外税(税別)を指定(内税(税込)の場合はTrue) ) def create_line_item(unit_amount, name, quantity): return { 'price_data': { 'currency': 'JPY', 'unit_amount': unit_amount, 'product_data': {'name': name, } }, 'quantity': quantity, 'tax_rates': [tax_rate.id] } class PayWithStripe(View): def post(self, request, *args, **kwargs): cart = request.session.get('cart', None) if cart is None or len(cart) == 0: return redirect('/') line_items = [] for item_pk, quantity in cart['items'].items(): item = Item.objects.get(pk=item_pk) line_item = create_line_item( item.price, item.name, quantity) line_items.append(line_item) checkout_session = stripe.checkout.Session.create( # customer_email=request.user.email, payment_method_types=['card'], line_items=line_items, mode='payment', success_url=f'{settings.MY_URL}/pay/success/', cancel_url=f'{settings.MY_URL}/pay/cancel/', ) return redirect(checkout_session.url)
PaySuccessViewは決済が成功した場合のリダイレクトのためのView
PayCancelViewは決済が失敗した場合のリダイレクトのためのViewであり、
実際の決済を行う処理はPayWithStripeとなる
PayWithStripeはpostのみでアクセス可能とする
公式ドキュメントのテンプレートのserver.pyを参考に作成していく
line_itemsはStripeの決済ページに表示させるデータを準備している
載せられるデータは詳しくはドキュメント参考だが、今回載せている'price_data'は価格であり、ドキュメントの'price'よりもより詳しく記載できている
'quantity': quantity, は購入数量
'tax_rates': [tax_rate.id] は任意で載せることができるが、これを載せていると自動で税金計算をしてくれるなど便利になる
checkout_sessionにline_itemsといったデータを追加送信すると決済ページにその情報を表示させることが可能となる
Stripeとの通信に必要な情報はcheckout_sessionを使用するので、これを必ず定義する
checkout_sessionの要素はドキュメント(https://stripe.com/docs/checkout/quickstart)を参照
なお、success_url=f'{settings.MY_URL}/pay/success/',のように{settings.MY_URL}を付与しておかないとStripeの決済画面のURLからの相対パスにリダイレクトしてしまうため、URLにドメインを追加しておく
最後は return redirect(checkout_session.url) でStripe公式がデフォルトで用意している決済画面にリダイレクトする
views.pyではなくviewsフォルダを作成してviewsを実装している場合はviews/__init__.pyのpay_viewsのインポートを忘れないように
from .pay_views import *
ルーティングの設定
'pay/checkout/'のURLエンドポイントはStripeの決済画面にリダイレクトするページとなっている
urls.py
# Pay path('pay/checkout/', views.PayWithStripe.as_view()), path('pay/success/', views.PaySuccessView.as_view()), path('pay/cancel/', views.PayCancelView.as_view()),
動作確認
サーバーを起動させる
商品を選択してカートからcheckoutボタンを押す
成功させたい場合は4242 4242 4242 4242
支払いに確認を必要とする場合は4000 0025 0000 3155
支払いが拒否される場合は4000 0000 0000 9995
を入力する
カード番号以外は適当な値を入力してかまわない
それぞれのパターンの挙動が正しければ動作確認はOK
これで基本的な決済機能を実装できるようになった
トラブルシュート
以下のようなエラーが発生した場合は、Stripeのアカウント名を変更する必要がある模様(参考https://note.com/daikinishimatsu/n/n029a7ff01f62)
InvalidRequestError at /pay/checkout/
In order to use Checkout, you must set an account or business name at https://dashboard.stripe.com/account.
ダッシュボードにアクセスし(https://dashboard.stripe.com/)、プロフィール>アカウントのところで「名称未設定」のアカウントと表示されていればアカウント名を登録する
登録方法は 設定(歯車アイコン)>アカウントの詳細>アカウント名 で好きなアカウント名を入力する
保存を押すことでアカウント名は保存される
エラーハンドリング
公式ドキュメント
https://docs.djangoproject.com/en/4.0/ref/views/#error-views
事前準備
setting.pyのDEBUGをFalseにする
DEBUGでない場合はホストALLOWED_HOSTの追加必要 '127.0.0.1'
プロジェクトのurls.pyにステータスコードを設定したハンドラーを追加する
handler404 = views.関数名()
なお、デフォルトではNot Foundエラーやサーバーエラー画面の味気ない画面が出力される
間違ったURLを正しいURLにリダイレクトなどカスタムしたい場合にエラーハンドリングをカスタマズする
例:間違ったURLの場合はリダイレクトする例
プロジェクト側 urls.py
handler404 = show_error_page
views.py
def show_error_page(request, exception): # return render(request, ' 404.html') return redirect('store:home')
カスタムユーザモデルを作成する
Djangoは通常models.pyにカスタムユーザーを使用することを明示せずにマイグレーションをすると、初めのマイグレーションでデフォルトユーザーを作成されてしまう
Djangoの公式ドキュメントに記述されている通りデフォルトユーザーを使用してしまうと後からの変更が困難となるので、基本的には最初からカスタムユーザーを作成しておくことが推奨されている
今回は一度マイグレーションを上記でしてしまっているが、この状態からカスタムユーザーを作成することも一応できる
後からカスタムユーザーを作成する大まかな流れとしては、カスタムユーザーをmodels.pyで作成して、DBを削除して、migrationsフォルダのinit以外のファイルを削除して、cacheも削除して、マイグレーションの初期化を行って、その後もう一度マイグレーションを行う(マイグレーションを一からやり直すことでカスタムユーザーを適用させることができる)
カスタムユーザーの作り方としてはAbstractUserを継承する方法と、AbstractBaseUserを継承する方法の2種類がある
- AbstractUser: すでに存在するフィールドをそのまま流用して、フィールドの増減を行うことができる
ただし、メールアドレスをログインIDにするなどのことができないため、限定的な利用にとどまる
- AbstractBaseUser: Userモデルをほとんどゼロベースから作成することが可能
自由度が高くこちらをカスタムユーザーとするのが一般的
以下はAbstractBaseUserのカスタム実装例
モデルの作成
models.pyにドキュメントを参考にしたモデルを作成する
https://docs.djangoproject.com/ja/3.2/topics/auth/customizing/#a-full-example
もしくは下の章で紹介しているカスタムモデルの例を参考にする
AbstractBaseUserを使用する場合は、データ挿入取得更新処理をするマネージャークラスを作成する必要がある
以下のコード例ではclass UserManager(BaseUserManager): の部分である
ほぼお決まりの書き方があり、通常ユーザーcreate_userと管理者ユーザーcreate_superuserの2種類のユーザー新規作成メソッドをオーバーライドする
自力で書こうとするとcreate_superuserを忘れがちなため注意が必要
カスタムモデルのログインIDを変更する場合はUSERNAME_FIELDに使用したいフィールドを記載する
ここでは'username'をログインIDにしているが、'email'を指定することもできる
'email'を指定した場合は、ログインIDにEmailを使用することが可能となる
REQUIRED_FIELDSはただの必須項目の設定である
また、ユーザーの生年月日やニックネームなどの情報はUserモデルに直接フィールドを持たせるよりも分離したProfileモデルに持たせるような実装で作成するのが慣習となっている
Userモデルと1to1のリレーションでテーブル分離したProfileモデルを作成する
models.OneToOneFields(User, ....) を使用すると1対1のリレーションを張ることができる
account_models.py
from django.dispatch import receiver from django.db.models.signals import post_save from django.db import models from django.contrib.auth.models import BaseUserManager, AbstractBaseUser class UserManager(BaseUserManager): def create_user(self, username, email, password=None): if not email: raise ValueError('Users must have an email address') user = self.model( username=username, email=self.normalize_email(email), ) user.set_password(password) user.save(using=self._db) return user def create_superuser(self, username, email, password=None): user = self.create_user( username, email, password=password, ) user.is_admin = True user.save(using=self._db) return user class User(AbstractBaseUser): id = models.CharField(default=get_random_string(22), primary_key=True, max_length=22) username = models.CharField( max_length=50, unique=True, blank=True, default='匿名') email = models.EmailField(max_length=255, unique=True) is_active = models.BooleanField(default=True) is_admin = models.BooleanField(default=False) objects = UserManager() USERNAME_FIELD = 'username' EMAIL_FIELD = 'email' REQUIRED_FIELDS = ['email', ] def __str__(self): return self.email def has_perm(self, perm, obj=None): "Does the user have a specific permission?" # Simplest possible answer: Yes, always return True def has_module_perms(self, app_label): "Does the user have permissions to view the app `app_label`?" # Simplest possible answer: Yes, always return True @property def is_staff(self): "Is the user a member of staff?" # Simplest possible answer: All admins are staff return self.is_admin class Profile(models.Model): user = models.OneToOneField( User, primary_key=True, on_delete=models.CASCADE) name = models.CharField(default='', blank=True, max_length=50) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) # 住所や年齢などアプリに必要なユーザー情報を設定する def __str__(self): return self.name # OneToOneFieldを同時に作成 @receiver(post_save, sender=User) def create_onetoone(sender, **kwargs): if kwargs['created']: Profile.objects.create(user=kwargs['instance'])
@receiverの処理はUserが作成されると同時にProfileモデルのフィールドにもデータを作成する処理となっているが、プロフィールはユーザーがアクティブなってから項目を入力されて新規作成されるものだとも考えられるので、この処理はあってもなくてもよい
ただこの処理があるとUserが作成された際にProfileが作成されるためProfileの新規作成処理を別途作成する必要がなくなる
カスタムモデルを使用するという宣言をするためのsetting.pyの編集
カスタムユーザーモデルを使用する宣言を行うために以下のコードを追加する
setting.py
AUTH_USER_MODEL = 'base.User'
※baseの部分はアプリ名
この設定を忘れるとDjangoのデフォルトユーザーモデルが作成されてしまう
またログインのURL情報や、ログイン成功時のリダイレクトURLもsetting.pyに記載することができる
ログアウトも同様
setting.py
LOGIN_URL = '/login/' LOGIN_REDIRECT_URL = '/' LOGOUT_URL = '/logout/' LOGOUT_REDIRECT_URL = '/login/'
ユーザー作成用のフォームを作成する
base/forms.pyを作成する
from django import forms from django.contrib.auth import get_user_model class UserCreationForm(forms.ModelForm): password = forms.CharField() class Meta: model = get_user_model() fields = ('username', 'email', 'password', ) def clean_password(self): password = self.cleaned_data.get("password") return password def save(self, commit=True): user = super().save(commit=False) user.set_password(self.cleaned_data["password"]) if commit: user.save() return user
clean_passwordはバリデーション
不正なパスワードはここで弾かれる
管理者画面にカスタムユーザーを表示させられるように admin.py を編集する
admin.py
from base.forms import UserCreationForm from django.contrib.auth.admin import UserAdmin class ProfileInline(admin.StackedInline): model = Profile can_delete = False class CustomUserAdmin(UserAdmin): fieldsets = ( (None, {'fields': ('username', 'email', 'password',)}), (None, {'fields': ('is_active', 'is_admin',)}), ) list_display = ('username', 'email', 'is_active',) list_filter = () ordering = () filter_horizontal = () add_fieldsets = ( (None, {'fields': ('username', 'email', 'is_active',)}), ) add_form = UserCreationForm inlines = (ProfileInline,) admin.site.register(User, CustomUserAdmin)
ちなみに行を分けると管理画面上の表示が改行されて見やすくなる
fieldsの中身は列挙すると順に横に表示されていく
fieldsets = (
(None.......), # 1行目表示
(None.........), # 2行目表示
)
またこの例ではInlineを利用して、ProfileをUserの管理画面に表示させている
管理者画面でのユーザー追加にはforms.pyで作成したUserCreationFormを指定することで管理者画面でユーザーを作成することができる
またこの例ではadd_fieldsetsにpasswordのフィールド項目が抜けているように思われる
マイグレーション初期化(一度マイグレーションをしてしまった後に後からカスタムユーザーを適用する場合)
アプリ(base)下のmigrationsフォルダ下の__init__.py以外の0001_initial.pyなどのファイルをすべて削除する
同様に、migrationsフォルダ下の__pycache__フォルダ下も同様にinitだけ残して削除する
また、データベースも削除するので、DjangoのデフォルトのSQLiteを使用している場合はdb.sqlite3を削除するだけでよい
別のデータベースを使用している場合はそのデータベースの中身を削除するか、setting.pyのデータベースの接続設定などを変えるなどしてDBを新規作成する
マイグレーションをしてカスタムユーザーを適用する
初めてのマイグレーションかもしくはマイグレーション初期化が終わったら、マイグレーションを行って、先ほど作成したカスタムユーザーを適用する
py .\manage.py makemigrations py .\manage.py migrate
管理者を作成できるか確認し、管理者画面にログインできるか確認する
py .\manage.py createsuperuser
ログイン・サインアップ画面を作成する
ログイン・新規登録兼用画面
login_signup.html
{% extends 'base.html' %}
{% block main %}
<div class="container my-5">
<div class="row ">
<div class="col-12">
<h1>
{% if 'login' in request.path %}
Login
{% elif 'signup' in request.path %}
Signup
{% endif %}
</h1>
<form method="POST">
{% csrf_token %}
<div class="form-row">
<div class="form-group col-md-4">
<input type="text" class="form-control" name="username" placeholder="Username">
</div>
</div>
<div class="form-row">
<div class="form-group col-md-4">
<input type="email" class="form-control" name="email" placeholder="Email" required>
</div>
</div>
<div class="form-row">
<div class="form-group col-md-4">
<input type="password" class="form-control" name="password" placeholder="Password" required>
</div>
</div>
<button class="btn btn-info btm-sm" type="submit">
{% if 'login' in request.path %}
Login
{% elif 'signup' in request.path %}
Signup
{% endif %}
</button>
</form>
</div>
</div>
</div>
{% endblock %}
ユーザー情報変更用画面を作成する
ユーザーが自身のアカウント情報を変更することができる画面を作成する
account.html
{% extends 'base.html' %}
{% block main %}
<div class="container my-5">
<div class="row">
<div class="col-12">
<h1>Account</h1>
<form method="POST">
{% csrf_token %}
<div class="form-row">
<div class="form-group col-md-4">
<label>Username</label>
<input class="form-control" type="text" name="username" placeholder="name" value="{{user.username}}">
</div>
</div>
<div class="form-row">
<div class="form-group col-md-4">
<label>Email</label>
<input class="form-control" type="email" name="email" placeholder="Email" value="{{user.email}}" required>
</div>
</div>
<button type="submit" class="btn btn-primary">Save</button>
</form>
</div>
</div>
</div>
{% endblock %}
プロフィール変更用画面を作成する
プロフィールを変更することのできる画面を作成する
profile.html
{% extends 'base.html' %}
{% block main %}
<div class="container my-5">
<div class="row">
<div class="col-12">
<h1>Profile</h1>
<form method="POST">
{% csrf_token %}
<div class="form-group ">
<label>Name</label>
<input class="form-control" type="text" name="name" placeholder="name" value="{{user.profile.name}}">
</div>
<div class="form-row">
<div class="form-group col-md-2">
<label>フォーム入力値1</label>
<input class="form-control" type="text" name="zipcode" placeholder="zipcode"
value="{{user.profile.zipcode}}">
</div>
<button type="submit" class="btn btn-primary">Save</button>
</form>
</div>
</div>
</div>
{% endblock %}
form-rowで入力項目を増やす場合はform-groupで囲われている箇所を繰り返し記述する
Viewを作成してログイン・サインアップ・アカウント情報更新機能を作成する
アカウント情報を画面に表示するためのViewを作成する
account_views.py
from django.views.generic import CreateView, UpdateView from django.contrib.auth.views import LoginView from django.contrib.auth import get_user_model from base.models import Profile from base.forms import UserCreationForm class SignUpView(CreateView): form_class = UserCreationForm success_url = '/login/' template_name = 'pages/login_signup.html' def form_valid(self, form): return super().form_valid(form) class Login(LoginView): template_name = 'pages/login_signup.html' def form_valid(self, form): return super().form_valid(form) def form_invalid(self, form): return super().form_invalid(form) class AccountUpdateView(UpdateView): model = get_user_model() template_name = 'pages/account.html' fields = ('username', 'email',) success_url = '/account/' def get_object(self): # URL変数ではなく、現在のユーザーから直接pkを取得 self.kwargs['pk'] = self.request.user.pk return super().get_object() class ProfileUpdateView(UpdateView): model = Profile template_name = 'pages/profile.html' fields = ('name', '..', ......) success_url = '/profile/' def get_object(self): # URL変数ではなく、現在のユーザーから直接pkを取得 self.kwargs['pk'] = self.request.user.pk return super().get_object()
SignUpViewは新規アカウント作成のためのView
LoginViewはログインのためのView
Viewはメソッドを二つに分ける模様
AccountUpdateViewはアカウント情報の更新用View
またユーザーアカウント情報の更新の際の注意点は、URLからIDを取得するのではなく、現在のユーザーから直接PKを取得する(self.request.user.pk)
またフィールドはリストでもよいが、タプルを使用するのが一般的らしい
LoginRequiredMixinを使用すると、ログイン制約をViewにつけることができる(ログインしていなければアクセスさせないということが実現できる)
LoginRequiredMixinはViewクラスの第一引数に記載すると自動的に適用される
ルーティングurls.pyを作成する
urls.py
from django.contrib.auth.views import LogoutView # Account path('login/', views.Login.as_view()), path('logout/', LogoutView.as_view()), path('signup/', views.SignUpView.as_view()), path('account/', views.AccountUpdateView.as_view()), path('profile/', views.ProfileUpdateView.as_view()),
ログアウト機能の実装
ログアウト機能は組み込みのViewが存在するため、それを利用する
上記で設定しているこの部分で実装が完了する
urls.py
from django.contrib.auth.views import LogoutView path('logout/', LogoutView.as_view()),
Tips! Djangoテンプレートエンジン使用時のHTMLでのログイン判定
{% if user.is_authenticated %} でログイン判定することが可能
カスタムユーザーを使用したログイン処理(関数ベースView)
Tips! Modelマネージャー
Modelクラスはテーブル定義を記述するだけでなくデータ挿入取得更新処理をするマネージャーに分ける場合がある
以下のユーザークラスはマネージャクラスを用いている
※ UserManager(BaseUserManager)の処理
ユーザー登録
以下のようなコードを書けばよい
models.py カスタムユーザーを使用する場合
from django.db import models from django.contrib.auth.models import ( BaseUserManager, AbstractBaseUser, PermissionsMixin ) class UserManager(BaseUserManager): def create_user(self, username, email, password=None): if not email: raise ValueError('Enter Email!') user = self.model( username=username, email=email ) user.set_password(password) user.save(using=self._db) return user def create_superuser(self, username, email, password=None): user = self.model( username=username, email=email, ) user.set_password(password) user.is_staff = True user.is_active = True user.is_superuser = True user.save(using=self._db) return user class Users(AbstractBaseUser, PermissionsMixin): username = models.CharField(max_length=255) age = models.PositiveIntegerField() email = models.EmailField(max_length=255, unique=True) is_active = models.BooleanField(default=False) is_staff = models.BooleanField(default=False) picture = models.FileField(null=True, upload_to='picture/') USERNAME_FIELD = 'email' REQUIRED_FIELDS = ['username'] class Meta: db_table = 'users'
views.py
def regist(request): regist_form = forms.RegistForm(request.POST or None) if regist_form.is_valid(): try: regist_form.save() return redirect('accounts:home') except ValidationError as e: regist_form.add_error('password', e) return render( request, 'accounts/regist.html', context={ 'regist_form': regist_form, } )
form.py
from django.contrib.auth.password_validation import validate_password class RegistForm(forms.ModelForm): username = forms.CharField(label='名前') age = forms.IntegerField(label='年齢', min_value=0) email = forms.EmailField(label='メールアドレス') password = forms.CharField(label='パスワード', widget=forms.PasswordInput()) confirm_password = forms.CharField(label='パスワード再入力', widget=forms.PasswordInput()) class Meta(): model = Users fields = ('username', 'age', 'email', 'password') def clean(self): cleaned_data = super().clean() password = cleaned_data['password'] confirm_password = cleaned_data['confirm_password'] if password != confirm_password: raise forms.ValidationError('パスワードが異なります') def save(self, commit=False): user = super().save(commit=False) validate_password(self.cleaned_data['password'], user) user.set_password(self.cleaned_data['password']) user.save() return user
ユーザーを仮登録してその後本登録する処理の実装
方針
Djangoのユーザーの場合はユーザークラスにもともと登録されているis_activateを使用して制御する
Djangoのシグナル機能を利用してユーザー作成時作成後などの特定のイベントに起因するイベント処理を簡単に実行することができる
この機能をDjangoではシグナルと言う
@receiverアノテーションを関数の先頭に付与して使うことができる
@receiver(post_save, sender=User)
def_save_profile(sender, instance, **kwargs):
instance.profile.save()
のような使い方となる
この場合はユーザーモデルが保存されたタイミングで処理が実行される関数となる
他にもpre_save, pre_delete, post_deleteなどがある
このシグナルを利用して、Djangoでユーザーが作成されたタイミングでトークンを発行するような処理を作る
コンソールにトークンを表示する処理を記述する(本番サービスではメール認証にするべき)
models.py
from django.db.models.signals import post_save from django.dispatch import receiver class UserActivateTokens(models.Model): token = models.UUIDField(db_index=True) expired_at = models.DateTimeField() user = models.ForeignKey( 'Users', on_delete=models.CASCADE ) objects = UserActivateTokensManager() class Meta: db_table = 'user_activate_tokens' @receiver(post_save, sender=Users) def publish_token(sender, instance, **kwargs): user_activate_token = UserActivateTokens.objects.create( user=instance, token=str(uuid4()), expired_at=datetime.now() + timedelta(days=1) ) # 本番サービスではメールでURLを送る実装とする print(f'http://127.0.0.1:8000/accounts/activate_user/{user_activate_token.token}')
accounts/activate_user/{user_activate_token.token}
のエンドポイントでアカウントを有効化する処理をviews.pyの中に追記する
models.pyに有効化処理をまず記述してそのあとviews.pyにこの処理を呼び出す処理を追加
models.py
class UserActivateTokensManager(models.Manager): def activate_user_by_token(self, token): user_activate_token = self.filter( token=token, expired_at__gte=datetime.now() ).first() user = user_activate_token.user user.is_active = True user.save()
エンドポイントから呼び出される処理 (urls.pyのルーティング処理などは基本なので省略)
views.py
def activate_user(request, token): user_activate_token = UserActivateTokens.objects.activate_user_by_token(token) return render( request, 'accounts/activate_user.html' )
ログインの処理
models.py
特になし
views.py
def user_login(request): login_form = forms.LoginForm(request.POST or None) if login_form.is_valid(): email = login_form.cleaned_data.get('email') password = login_form.cleaned_data.get('password') user = authenticate(email=email, password=password) if user: if user.is_active: login(request, user) messages.success(request, 'ログイン完了しました。') return redirect('accounts:home') else: messages.warning(request, 'ユーザがアクティブでありません') else: messages.warning(request, 'ユーザかパスワードが間違っています') return render( request, 'accounts/user_login.html', context={ 'login_form': login_form, } )
forms.py
class LoginForm(forms.Form): email = forms.CharField(label="メールアドレス") password = forms.CharField(label="パスワード", widget=forms.PasswordInput())
ログアウト処理 ( & ログアウト完了時のメッセージについてmessageフレームワークを使用する )
django.contrib.messagesのパッケージに各種エラーやデバッグ・ログイン時ログアウト時などのタイミングでメッセージを簡単に表示させることのできるmessagesというパッケージがある
以下のような書き方となる
messages.debug(request, 'メッセージ') messages.info(request, 'メッセージ') messages.success(request, 'ログイン完了しました') messages.warning(request, '注意') messages.error(request, 'エラー発生')
例:ログアウトの処理などで以下のように書くとよい
views.py
from django.contrib.auth.decorators import login_required @login_required def user_logout(request): logout(request) messages.success(request, 'ログアウトしました') return redirect('accounts:home')
テンプレートでは使用するテンプレートごとに (user_login.htmlなど) にmessagesを書いておく
user_login.html
{% if messages %}
{% for message in messages %}
<div>{{ message.message }}</div>
{% endfor %}
{% endif %}
パスワード変更処理
パスワードは変更後にsession情報を更新する必要がある
またパスワード確認フィールドもあることもポイント
フォームの情報でDBの (上書き) 更新は以下のような形になる
第二引数のinstanceに上書きしたい情報が格納される
forms.PasswordChangeForm(request.POST or None, instance=request.user)
この情報のままで保存するのは危険であるので、viewsのtryの処理のようなvalidationチェックを行う
update_session_auth_hash(request, request.user)でセッション情報を更新する
models.py
特になし
views.py
@login_required def change_password(request): password_change_form = forms.PasswordChangeForm(request.POST or None, instance=request.user) if password_change_form.is_valid(): try: password_change_form.save() messages.success(request, 'パスワード更新完了しました。') update_session_auth_hash(request, request.user) except ValidationError as e: password_change_form.add_error('password', e) return render( request, 'accounts/change_password.html', context={ 'password_change_form': password_change_form, } )
forms.py
class PasswordChangeForm(forms.ModelForm): password = forms.CharField(label='パスワード', widget=forms.PasswordInput()) confirm_password = forms.CharField( label='パスワード再入力', widget=forms.PasswordInput()) class Meta(): model = Users fields = ('password', ) def clean(self): cleaned_data = super().clean() password = cleaned_data['password'] confirm_password = cleaned_data['confirm_password'] if password != confirm_password: raise forms.ValidationError('パスワードが異なります') def save(self, commit=False): user = super().save(commit=False) validate_password(self.cleaned_data['password'], user) user.set_password(self.cleaned_data['password']) user.save() return user
ユーザー情報変更処理
models.py
特になし
views.py
@login_required def user_edit(request): user_edit_form = forms.UserEditForm(request.POST or None, request.FILES or None, instance=request.user) if user_edit_form.is_valid(): messages.success(request, '更新完了しました。') user_edit_form.save() return render(request, 'accounts/user_edit.html', context={ 'user_edit_form': user_edit_form, })
forms.py
class UserEditForm(forms.ModelForm): username = forms.CharField(label='名前') age = forms.IntegerField(label='年齢', min_value=0) email = forms.EmailField(label='メールアドレス') picture = forms.FileField(label='写真', required=False) class Meta: model = Users fields = ('username', 'age', 'email', 'picture')
AJAX非同期処理とキャッシュ制御(関数ベースView)
Ajax
画面遷移せずにクライアント側からjQueryを用いてサーバサイドにリクエストを投げてデータをやり取りする技術
クライアント側
$.ajax({ url : “create_post/”, // 実行するURLを指定 type : “POST”, // HTTPメソッド data : { the_post : $(‘#post-text’).val() }, // 送信するデータ success : function(json) { // 実行が成功した場合の処理 }, error : function(xhr,errmsg,err) { // 実行が失敗した場合の処理 } });
サーバーサイド側はJSONを返す処理を記述する
from django.http import JsonResponse from django.core import serializers if request.is_ajax: # 処理 json_instance = serializers.serialize(‘json’, [ instance, ]) # jsonに変換する return JsonResponse({“instance”: json_instance}, status=200) # レスポンスを返す または return HttpResponse( json.dumps(response_data), content_type="application/json" )
CACHEの種類
1. Memcached: メモリー上キャッシュ、処理が高速、最も一般的に使われ複数サーバで共有も可能
2. Database: データベースに保存するキャッシュ、取得速度遅いが大容量
3. File system: ファイル上に分割して保存するキャッシュ、速度は遅いが管理が楽
4. Local memory: ローカルPCメモリー上に保存するキャッシュ、デフォルトキャッシュ
5. Dummy: 開発環境用ダミーキャッシュ
Djangoのドキュメント Cache
Django’s cache framework | Django documentation | Django
キャッシュの設定例
setting.py
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.memcached.MemcachedCache',
'LOCATION': [
'172.19.26.240:11211',
'172.19.26.242:11212‘,
]
}
}
キャッシュ操作
from django.core.cache.cache import * cache.set(‘KEY’, ‘VALUE’) cache.get(‘KEY’, ‘DEFAULT’) # 存在しない場合はデフォルト値が返ってくる cache.clear() cache.delete('KEY')
Ajax実装例
JQuery CDNのインポート
jQuery CDN
uncompressedをクリック
スクリプト部分をコピーしてbase.pyのようなHTMLファイルのheadタグ部分にペーストする
Ajaxの処理を記述
base.pyのbodyタグ内にブロックを記述し、使いたいテンプレートで処理を記述する
<body> {% block javascript %}{% endblock %} </body>
例: フォームの一時保存機能を追加する場合
テンプレートのフォームコンポーネントにidを付与してscriptブロックにscriptタグで処理を記述する
<form method="POST"> {% csrf_token %} {{ post_comment_form.as_p }} <input type="button" value="一時保存" id="save_comment"> <input type="submit" value="コメント送信"> </form> {% block javascript %} <script> $("#save_comment").click(function(){ var comment = $("#id_comment").val(); $.ajax({ url: "{% url 'boards:save_comment' %}", type: "GET", data: {comment: comment, theme_id: "{{ theme.id }}"}, dataType: "json", success: function(json){ if(json.message){ alert(json.message); } } }); }); </script> {% endblock %}
urlでセーブ用のViewの処理のエンドポイントを呼ぶ処理を実装しているので、viewの処理も実装する
urls.pyの実装例は省略
キャッシュを設定する処理
views.py
from django.core.cache import cache from django.http import JsonResponse def save_comment(request): if request.is_ajax: comment = request.GET.get('comment') theme_id = request.GET.get('theme_id') if comment and theme_id: cache.set(f'saved_comment-theme_id={theme_id}-user_id={request.user.id}', comment) return JsonResponse({'message': '一時保存しました!'})
saved_comment-theme_id={theme_id}-user_id={request.user.id} は長いがただのKEYである
例: キャッシュを取出す処理
views.py
def post_comments(request, theme_id): # フォームにレンダリングするためのデータを渡す処理 saved_comment = cache.get(f'saved_comment-theme_id={theme_id}-user_id={request.user.id}', '') post_comment_form = forms.PostCommentForm(request.POST or None, initial={'comment': saved_comment})
画面読み込み時にレンダリングするためのデータを渡す処理の先頭にキャッシュされたデータを取得する処理を記述しておく
フォームの第二引数 initial にキャッシュの初期値を設定する
また、フォーム画面でデータをサーバー側に保存した場合はcache.deleteを使用してキャッシュデータを削除しておくことも忘れないようにしておく
クラスベースビューを使用した主にログイン処理
Djangoはクラスベースビューを用いる方が一般的で実践的
ただし別のフレームワークでフロントエンド開発をする場合はDjangoはREST APIとして使用するため関数ベースビューで記述する方が一般的かもしれない
Djangoで画面まで作成する場合にはクラスベースビューで作成する方が便利だと思われる
以下のテンプレートがある
| View | 全てのViewの元になるView |
| TemplateView | テンプレートを表示する。ホーム画面など |
| CreateVIew | データベースにデータを挿入するView。データ作成画面 |
| UpdateView | データを更新するView |
| DeleteView | データを削除するView |
| ListView | 特定のテーブルのデータ一覧を表示するView |
| DetailView | テーブルのレコードの詳細を表示するView |
| FormView | Formを表示してデータを送信するView |
| RedirectView | リダイレクトを行うView |
View
上記のテンプレートでうまく実装できなさそうな処理を記述するときに継承するViewクラス
リクエストに応じた処理を記述する
class MyView(View): def get(self, request, **kwargs): # GETの場合の処理 def post(self, request, **kwargs): # POSTの場合の処理
リダイレクトビュー(RedirectView)
urls.pyに直接記述
from django.views.generic.base import RedirectView urlpatterns = [ path('/search/<term>/', RedirectView.as_view(url='https://google.co.jp/?q=%(term)s')), ]
SuccessMessageMixin
データ更新削除時等のメッセージ表示に便利なクラス
Pythonは複数クラスの継承ができるため作成するViewの2つ目のクラスとしてSuccessMessageMixinを継承する
例 views.py
class BookUpdateView(UpdateView, SuccessMessageMixin): model = Books success_message # 成功時メッセージ(静的) def get_success_message(self, cleaned_data): # 成功時メッセージ(動的
そのほかのテンプレートビューは使用する際に配布資料参照のこと
ログイン処理(クラスベースView)
ログインログアウト処理
クラスベースのログインビューが存在するためそれを利用する
from django.views.generic import View class LoginView(View): def post(self, request, *args, **kwargs): username = request.POST[‘username’] # usernameを取得 password = request.POST['password’] # passwordを取得 user = authenticate(username=username, password=password) # userが存在するか確認 if user is not None: if user.is_active: login(request, user) # ログイン処理 return render(request, "index.html") class LogoutView(View): def get(self, request, *args, **kwargs): logout(request) # ログアウト処理 return HttpResponseRedirect(settings.LOGIN_URL)
ログイン制約
LoginRequiredMixin
ログインユーザーのみViewにアクセスできるように制限するクラス
使用するviewに継承する
class MyView(LoginRequiredMixin, View): # ログインが必要なViewに継承させる login_url = '/login/' redirect_field_name = 'redirect_to’
@method_decorator(login_required)
アノテーションでもログイン制約を付与することができる
Viewの中の一部のメソッドのみログイン制約をつけたい場合に使用できる
class ProtectedView(TemplateView): @method_decorator(login_required) def dispatch(self, request, *args, **kwargs):
ログインセッション時間について
セッション時間はデフォルトで2週間となっている
変更する場合はsettingにセッション情報を追記する
プログラム中で個別にセッション時間を指定したい場合には request.session.set_expiry(value) を使用する
settings.py
SESSION_COOKIE_AGE: セッションの保存時間(秒)
https://docs.djangoproject.com/ja/3.1/ref/settings/#std:setting-SESSION_COOKIE_AGE
request.session.set_expiry(value): セッションの保存時間を引数の時間(秒)に変更する
※なお、引数が0の場合、ブラウザを閉じるとセッションが破棄される
value に datetime または timedelta オブジェクトを指定すると指定された日時に破棄される
https://docs.djangoproject.com/ja/3.1/topics/http/sessions/#django.contrib.sessions.backends.base.SessionBase.set_expiry
ログイン実装例(クラスベース)
例: ユーザー登録
class RegistUserView(CreateView): template_name = 'regist.html' form_class = RegistForm
例: ログイン
class UserLoginView(LoginView): template_name = 'user_login.html' authentication_form = UserLoginForm def form_valid(self, form): remember = form.cleaned_data['remember'] if remember: self.request.session.set_expiry(1200000) return super().form_valid(form)
def form_validはセッション情報保持ボタンを押すとログイン情報を保持することができるような処理にしている
※講座の中でデフォルトのセッション時間を5秒にしているため
実際の実装の場面ではform_validの部分は省いて実装するケースがほとんどだと思う
FormViewで定義する例
class UserLoginView(FormView): template_name = 'user_login.html' form_class = UserLoginForm def post(self, request, *args, **kwargs): email = request.POST['email'] password = request.POST['password'] user = authenticate(email=email, password=password) next_url = request.POST['next'] if user is not None and user.is_active: login(request, user) if next_url: return redirect(next_url) return redirect('accounts:home')
例: ログアウト
class UserLogoutView(LogoutView): pass
Viewで定義する例
class UserLogoutView(View): def get(self, request, *args, **kwargs): logout(request) return redirect('accounts:user_login')
例: ログイン制約
# @method_decorator(login_required, name='dispatch') class UserView(LoginRequiredMixin, TemplateView): template_name = 'user.html' # @method_decorator(login_required) def dispatch(self, *args, **kwargs): return super().dispatch(*args, **kwargs)
MiddleWareを利用したログ出力の実装(Pythonの基本ログ出力方法)
ログ出力をする意義
以下の理由にて実務で必須
- エラー解析
- パフォーマンス解析(レスポンスタイム)
出力方法
loggingモジュール(デフォルトでログレベルはwarning)
import logging # ログの出力 logging.critical(‘エラー内容') .. rtc
ファイルへの出力
logging.basicConfig(filename=‘app.log’, filemode=‘w’)
よく使うテクニック
ogging.error(‘%s raised error’, name) # 変数をログに出力
logging.error(f‘{name} raised error’) # 変数をログに出力(3.6以降)
logging.error(f‘{name=} raised error’) # 変数をログに出力(3.8以降)
logging.error(“”, exc_info=True) #スタックトレースの出力
logging.basicConfig(format=‘%(asctime)s-%(process)s-%(levelname)s-%(message)s’) # 出力するログのフォーマットの設定%(asctime): 出力時間、%(process): プロセスID、%(levelname): ログレベル、%(message):メッセージ
datefmt: 時刻のフォーマットを指定スタックトレースは特によく使う模様
原因特定するために何行目のログ情報かの情報がとても有益となる
ログの一般的な使い方 tryとかで使う
try: method() except Exception as e logggin.error(e, exc_info=True) logging.error(“”, exc_info=True) #スタックトレースの出力
ロガーのインスタンス化
logger = logging.getLogger(__name__)
__name__はファイル名を差し、ロガーの識別子となる
使用を推奨されている
handlerを設定する
logger.setLevel(logging.DEBUG) # ログレベルのデフォルトはwarning
ロガーの情報を設定ファイルに記述して使用する方法
confフォルダを作成して、設定ファイルを logger.conf 作る
[loggers] # loggerの一覧を設定
[handlers] # Handlerの一覧を設定
[formatters] # Formatterの一覧を設定
[logger_${logger名}] # 各loggerの設定
[handler_${handler名}] # handlerの設定
[formatter_${formatter名}] # 各formatterの設定
logger.conf 例
[loggers] keys=root [handlers] keys=consolehandler, filehandler [formatters] keys=sampleformatter [logger_root] level=DEBUG handlers=consolehandler, filehandler [handler_consolehandler] class_StreamHandler level=INFO formatter=samleformatter args=(sys.stdout,) [handler_filehandler] class=FileHandler level=Error formatter=samleformatter args=['logs/app.log', 'a', 'utf-8'] [formatter_sampleformatter] format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
ログのインスタンスを使用してログを出力させる
logging.py
import logging import logging.config logging.config.fileConfig(fname='conf/logger.conf') logger = logging.getLogger(__name__) logger.debug('') logger.info('') logger.warning('') logger.error('') logger.critical('')
logsフォルダを作ってログをファイルとして出力させる
今回はinfo以上のログが出力される
複数のloggerを設定して別々のログを出力させることも可能
confファイル
[loggers] keys=root, samplelogger [handlers] keys=consolehandler, filehandler, sampleconsolehandler [formatters] keys=sampleformatter [logger_root] level=DEBUG handlers=consolehandler, filehandler [logger_samplelogger] level=DEBUG handlers=filehandler, sampleconsolehandler qualname=samplelogger propagate=0 [handler_consolehandler] class=StreamHandler level=INFO formatter=sampleformatter args=(sys.stdout,) [handler_filehandler] class=FileHandler level=ERROR formatter=sampleformatter args=['logs/app.log', 'a', 'utf-8'] [handler_sampleconsolehandler] class=StreamHandler level=DEBUG formatter=sampleformatter args=(sys.stdout,) [formatter_sampleformatter] format=%(asctime)s-%(name)s-%(levelname)s-%(message)s
logging.pyファイル
import logging import logging.config logging.config.fileConfig(fname='conf/logger.conf') logger = logging.getLogger(__name__) logger.debug('デバッグログ') logger.info('インフォログ') logger.warning('ワーニングログ') logger.error('エラーログ') logger.critical('クリティカルログ') logger = logging.getLogger('samplelogger') logger.debug('デバッグログ') logger.info('インフォログ') logger.warning('ワーニングログ') logger.error('エラーログ') logger.critical('クリティカルログ')
ローテーティングファイルハンドラーを利用する
一定の条件を設定して、別ファイルにログを新たに切り出す設定
管理上見やすくしたり1ファイルのファイルサイズの肥大化を防ぐことができる
例: logger.confファイルに以下を追記
[handler_fileHandler]
class=handlers.RotatingFileHandler
level=INFO
formatter=sampleFormatter
args=('./logs/log.out', when='S', interval=10, backupCount=5, encoding=‘utf-8’,
maxBytes=1000)時間によるローテーションを設定したい場合は以下
[handler_fileHandler] class=handlers.TimedRotatingFileHandler level=INFO formatter=simpleFormatter args=(‘logger.log’,when=’D’,interval=1,backupCount=3,encoding=‘utf-8’)
MiddleWareを利用したログ出力の実装(Djangoの基本ログ出力方法)
上記はpythonの基本のログ出力方法
以下ではDjangoに設定する方法
Djangoログ出力 setting.py
Djangoのログ出力は setting.py に記述する
ロギング | Django ドキュメント | Django
setting.py
LOGGING = {
‘version’: 1, # ロガーバージョン通常は1
‘disable_existing_loggers’: False, # デフォルトログの無効化通常はFalse
'formatters': {
: # フォーマッターの一覧を記載
},
'handlers': {
: # ハンドラーの一覧を記載
},
'loggers': {
: # ロガーの一覧を記載
}
}
setting.py 例
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'simple': {
'format': '%(asctime)s %(levelname)s [%(pathname)s:%(lineno)s] %(message)s',
}
},
'handlers': {
'console_handler': {
'level': 'DEBUG',
'class': 'logging.StreamHandler',
'formatter': 'simple',
},
'timed_file_handler': {
'level': 'INFO',
'class': 'logging.handlers.TimedRotatingFileHandler',
'filename': os.path.join('logs', 'application.log'),
'when': 'S',
'interval': 10,
'backupCount': 10,
'formatter': 'simple',
'encoding': 'utf-8',
'delay': True,
},
'timed_error_handler': {
'level': 'ERROR',
'class': 'logging.handlers.TimedRotatingFileHandler',
'filename': os.path.join('logs', 'application_error.log'),
'when': 'S',
'interval': 10,
'backupCount': 10,
'formatter': 'simple',
'encoding': 'utf-8',
'delay': True,
},
'timed_performance_handler': {
'level': 'INFO',
'class': 'logging.handlers.TimedRotatingFileHandler',
'filename': os.path.join('logs', 'application_performance.log'),
'when': 'S',
'interval': 10,
'backupCount': 10,
'formatter': 'simple',
'encoding': 'utf-8',
'delay': True,
}
},
'loggers': {
'application-logger': {
'handlers': ['console_handler', 'timed_file_handler'],
'level': 'DEBUG',
'propagate': False,
},
'error-logger': {
'handlers': ['timed_error_handler'],
'level': 'ERROR',
'propagate': False,
},
'performance-logger': {
'handlers': ['timed_performance_handler'],
'level': 'INFO',
'propagate': False,
}
}
}
ログの出力の処理
views.pyやmodels.pyなど
import logging # 前処理 application_logger = logging.getLogger('application-logger') error_logger = logging.getLogger('error-logger') # メソッド内で記述する application_logger.debug('エラーメッセージ') error_logger.error('エラーメッセージ')
ミドルウェアを利用する
リクエスト・レスポンス処理にフックを加えて入力と出力の値を置き換えるフレームワークのことをミドルウェアという
ミドルウェア (Middleware) | Django ドキュメント | Django
django.utils.deprecation.MiddlewareMixinを継承する
Djangoのビューを呼び出す前に実行される処理を記述する場合は
def process_view(self, request, view_func, view_args, view_kwargs):のメソッドを記述する
def process_viewのreturnは通常Noneを返す
ビューで例外が発生した場合に実行される処理を記述する場合は
def process_exception(self, request, exception) のメソッドを追記する
returnはNoneとHttpResponseオブジェクトを返す
Noneをreturnする場合は他のエラーハンドリング処理を記述する
ミドルウェアの定義の逆順に実行される(setting.pyのMIDDLEWAREの下から順番に実行される)
ビューの実行後に呼び出される場合
def process_template_response(self, request, response):
middleware.pyを記述してsetting.pyに記述する
middleware.py 例(プロジェクトフォルダに作成)
# middleware.py import logging from django.utils.deprecation import MiddlewareMixin import time application_logger = logging.getLogger('application-logger') error_logger = logging.getLogger('error-logger') performance_logger = logging.getLogger('performance-logger') class MyMiddleware(MiddlewareMixin): def process_view(self, request, view_func, view_args, view_kwargs): # viewを呼び出す前に実行 application_logger.info(request.get_full_path()) def process_exception(self, request, exception): error_logger.error(exception, exc_info=True) class PerformanceMiddlware(MiddlewareMixin): def process_view(self, request, view_func, view_args, view_kwargs): start_time = time.time() request.start_time = start_time def process_template_response(self, request, response): response_time = time.time() - request.start_time performance_logger.info(f'{request.get_full_path()}: {response_time}s') return response
setting.py 例
MIDDLEWARE = [
'class_based_view.middleware.PerformanceMiddlware', # リクエストでは最初・レスポンスでは最後に実行される
# ...
'class_based_view.middleware.MyMiddleware' # リクエストでは最後・レスポンスでは最初に実行される
]
OAuth認証の実装

offlineアクセスのオフラインとはネットワークにつながっていないということではなくユーザーがいない状態のことを差す
google OAuth認証を試してみる
settings.pyのINSTALLED_APPに以下のものを追加する
settings.py
'django.contrib.sites', 'allauth', 'allauth.account', 'allauth.socialaccount', ‘allauth.socialaccount.providers.google’, # google認証
ログイン処理時に認証で行うクラスにallauthを追加する
settings.py
AUTHENTICATION_BACKENDS = ( “django.contrib.auth.backends.ModelBackend”, # デフォルトの認証 "allauth.account.auth_backends.AuthenticationBackend", # allauthの認証 )
settings.pyの下部に記載
SITE_ID = 1 # django_site テーブル上の認証に用いるサイトの指定 通常1(django_siteは最初のマイグレーションで作成されるテーブル)
ACCOUNT_EMAIL_REQUIRED = True # 認証にメールアドレスが必要か(デフォルトはFalse)
ACCOUNT_USERNAME_REQUIRED = False # 認証にユーザ名が必要か(デフォルトはTrue)
ACCOUNT_AUTHENTICATION_METHOD = ‘email’ # 認証に利用する要素(email or username or
username_email)
SOCIALACCOUNT_PROVIDERS = { # プロバイダーごとの設定
'google': {
‘SCOPE‘: [ # Google APIで何を取得するか
'profile',
'email',
],
'AUTH_PARAMS': {
‘access_type’: ‘onine’, #バッチ処理の場合はオフラインで利用する
}}}アクセスタイプのofflineはネットにつながっていないことでなくユーザーがいない状態例えばバッチetc. のこと
SOCIALACCOUNT_PROVIDERSの中身は以下のドキュメントのProviderの項目を見て記述する
https://django-allauth.readthedocs.io/en/latest/installation.html
google Cloud platformにプロジェクトを登録する
1
google cloud platformで検索してgcpのトップページにアクセス
2
利用規約に同意
3
コンソール画面でプロジェクトの選択>新しいプロジェクト
プロジェクト名と場所(組織なし)を入力して作成
4
プロジェクトを選択をおし
APIサービス>OAuth同意画面
UserTypeを外部に選択して作成
5
アプリ名には先ほどのプロジェクト名を
ユーザーサポート名には適当なメールアドレスを入力する
デベロッパーの連絡先情報は適当なメールアドレスを入力する
保存して次へ
6
スコープはそのままで保存して次へ
テストユーザーもそのままで保存して次へ
概要もそのままでダッシュボードに戻る
7
APIサービス>認証情報作成>認証情報を作成>OAuthクライアントIDを選択
アプリケーションの種類:ウェブアプリケーション
名前:ウェブクライアント1
承認済のJavaScript生成元にローカルのURLを記載
http://127.0.0.1:8000
承認済のリダイレクトURIにも以下のようなURIを記載
http://127.0.0.1:8000/oauth_accounts/google/login/callback/
作成
8
作成されたクライアントIDとシークレットキーをメモなどどこかに保存する
OAuth認証実装 Djangoソースコード側の手順
エディタに戻ってDjangoのソースコードを編集していく
マイグレーションを実行する
py manage.py migrations
これでマイグレーションの履歴を見るとsocialaccount_○○というOAuthで使用されるDBテーブルが4つほど作成されていることがわかる
スーパーユーザーを作成する
py manage.py createsuperuser
作成した管理者ユーザーで管理者画面にログインする
SOCIAL ACCOUNTSの中に
social accounts
social application tokens
social applications
テーブルがあることがある
この中にOAuthの設定を記載していく
まず、Siteのテーブルの中にexampleのドメイン名があるがこれを127.0.0.1に変更する
次にSocial Applicationsをクリックして以下を追加する
Provider: google
name: OAuth App
クライアントIDは先ほどコピーした値
シークレットキーも先ほどコピーした値
Keyは空のまま
Sitesの127.0.0.1を左から右に移動させる
これで保存する
こうすることでGoogle認証でログインすることが可能となる
この後、実際にOathでログインしたユーザーはsocial accountsテーブルにメースアドレスや名前などが追加される
画面遷移を作成する
urls.pyにOauthの画面への遷移を追加する
urls.py
path('oauth_accounts/', include('allauth.urls’))ユーザーログインのテンプレート画面にgoogleログインのためのリンクを貼り付ける
user_login.html
{% load socialaccount %}
<a href=“{% provider_login_url ‘google’ %}”>googleログイン</a>例:user_login.html
{% extends 'base.html' %}
{% load socialaccount %}
{% block content %}
<form method="POST">
{% csrf_token %}
{{ form.as_p }}
<input type="hidden" name="next" value="{{ request.GET.next }}">
<input type="submit" value="ログイン">
</form>
<a href="{% provider_login_url 'google' %}">Googleログイン</a>
{% endblock %}
動作確認
Djangoサーバーを起動してアプリケーションにアクセス
ユーザーログイン画面からgooglerログインを選択して、よく見るグーグル認証の画面に遷移して、その後認証が通って元の画面にリダイレクトされればよい
DBの中身を確認するとSOCIAL ACCOUNTSテーブルの中にログインされたUserのEmailアドレスなどが格納されていることがわかる
Account Userの中身を見るとユーザーのEmailが見れることが確認できれば良い
画面をきれいにする
http://127.0.0.1/oauth_accounts/signup/
などと指定すると、oauthのライブラリが持っている変な画面が表示されてしまう
これを表示させたくないない場合はurls.pyに記述していた箇所を以下のように書き換える
from allauth.socialaccount.providers.google.urls import urlpatterns as google_url
path('oauth_accounts/', include(google_url)),こうするとピンポイントにgoogleのurlだけを入れることができる
END
バッチ処理の実装・運用
ドキュメント
https://docs.djangoproject.com/ja/3.1/howto/custom-management-commands/
基本利用方法
アプリケーションフォルダの直下にmanagementフォルダを作成して、commandsフォルダを作成してその中にコマンド(sample.py)を配置
app\management\commands\sample.py
from django.core.management.base import BaseCommand class Command(BaseCommand): def handle(self, *args, **options): print("バッチ実行")
このバッチファイルを実行する場合は以下のコマンドで実行できる
py manage.py sample
storesアプリケーションフォルダにも同じようなフォルダ階層でバッチファイルを作成することもできる
stores\management\commands\sample.py
from django.core.management.base import BaseCommand class Command(BaseCommand): def handle(self, *args, **options): print('storeのバッチを実行')
なお別のアプリケーションに同じ名前のバッチファイルを作成した場合は、setting.pyに記載したアプリの上から順番に実行される(sample.pyが二つあった場合は、py manage.py sampleでは先に登録されているアプリ(app)の方のsample.pyが実行される)
引数の追加
add_argments: コマンド内で利用する引数を追加する
app/management/sample.py
class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument(‘first’) # 第1引数をキーfirstに格納する parser.add_argument(‘second’) # 第2引数をキーsecondに格納する parser.add_argument(‘--option1’) # option1で指定した引数をキーoption1に格納する def handler(self, *args, **options): print(f’{options[“first”]}’) # add_argumentsで格納した値を取り出す print(f’{options[“option1”]}’) # add_argumentsで格納した値を取り出す
-
- を使うとマップ型のkeyとして定義することができる
呼び出す場合は必ず引数を必要となり、以下のようなコマンドで呼び出すことができる
python manage.py sample A(第1引数) B(第2引数) --option1 C(option1に対する値)
コマンド内で利用する引数の追加
type: 数値型(int)、文字列型(str)で指定する
help: 説明文の追加 「python manage.py help コマンド名」 で使用できる
python manage.py help コマンド名で表示することができる
default: デフォルトの値(格納する引数が存在する場合に指定した値が格納される)
nargs: 格納する値の数を指定してリスト型で格納。呼び出す際はコマンドの引数に配列の数だけ引数を列挙して渡して実行する
action: store_true(引数が存在する場合はTrue)、store_false (引数が存在する場合はFalse)が格納される
choices: 格納できる値の一覧を記述する
例: sapmle.py
from django.core.management.base import BaseCommand class Command(BaseCommand): help='ユーザ情報を表示するバッチです' def add_arguments(self, parser): parser.add_argument('name', type=str, help='名前') # 1引数 parser.add_argument('age', type=int) # 2引数 parser.add_argument('--birthday', default='2020-01-01') parser.add_argument('three_words', nargs=3) parser.add_argument('--active', action='store_true') # 引数--activeを指定した場合にTrueを格納する parser.add_argument('--color', choices=['Blue', 'Red', 'Yellow']) # 例えばBlueを指定すると以下のハンドルの対応する青が表示される def handle(self, *args, **options): name = options['name'] age = options['age'] birthday = options['birthday'] three_words = options['three_words'] active = options['active'] print( f'name = {name}, age = {age}, birthday = {birthday}, three_words = {three_words}' ) print(active) color = options['color'] if color == 'Blue': print('青') elif color == 'Red': print('赤') elif color == 'Yellow': print('黄')
応用
Ordersモデルで定義されたDBの中のOrdersテーブルのデータをファイルに出力するバッチファイルの作成
例: stores/management/commands/export_orders.py
from django.core.management.base import BaseCommand from stores.models import Orders from ecsite_project.settings import BASE_DIR from datetime import datetime import os import csv class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument('--user_id', default='all') def handle(self, *args, **options): orders = Orders.objects user_id = options['user_id'] if user_id == 'all': orders = orders.all() else: orders = orders.filter(user_id=user_id) file_path = os.path.join(BASE_DIR, 'output', 'orders', f'orders_{datetime.now().strftime("%Y%m%d%H%M%S")}_{user_id}') with open(file_path, mode='w', newline='\n', encoding='utf-8') as csvfile: fieldnames = ['id', 'user', 'address', 'total_price'] writer = csv.DictWriter(csvfile, fieldnames=fieldnames) writer.writeheader() for order in orders: writer.writerow({ 'id': order.id, 'user': order.user, 'address': order.address, 'total_price': order.total_price, }) || こうすることで日繰りでファイルにデータをレポートとして出力させるバッチにしたりすることができる ** メッセージフレームワーク *** messages.html を作成して、base.htmlに配置してすべての画面でメッセージを出力できるようにしておく まずはsnippetsに以下のように {% for msg in message %} を使用したメッセージ出力用の画面を作成しておく messages.html >|html| {% for msg in messages %} <nav class="py-2 mb-0 {{ msg.tags }}"> <div class="container"> {{ msg }} </div> </nav> {% endfor %}
この中で
次にbase.htmlのどこかに{% include 'snippets/messages.html' %}を取り込んでおく
例 base.html
<header> {% block header %} {% include 'snippets/messages.html' %} {% include 'snippets/header.html' %} {% endblock %} </header>
こうすることですべての画面にメッセージが適用されるようになる
settings.pyにメッセージの重要度ごとのCSS設定を記述しておく
settings.pyに以下のような記述をしておく
from django.contrib import messages
# messages
MESSAGE_TAGS = {
messages.ERROR: 'rounded-0 alert alert-danger',
messages.WARNING: 'rounded-0 alert alert-warning',
messages.SUCCESS: 'rounded-0 alert alert-success',
messages.INFO: 'rounded-0 alert alert-info',
messages.DEBUG: 'rounded-0 alert alert-secondary',
}
メッセージフレームワークをビューの処理の成功時・失敗時のメッセージ制御に使用する
ビューの処理毎にmessagesを付け加えることでメッセージを表示させる
成功した場合の例
from django.contrib import messages class View(CreateView): def form_valid(self, form): messages.success(self.request, '成功しました') return super().form_valid(form)
失敗した場合の例
from django.contrib import messages class Login(LoginView): def form_invalid(self, form): messages.error(self.request, '失敗しました') return super().form_invalid(form)
これで処理毎に想定したメッセージが表示されていればよい
コンテキストプロセッサー
コンテキストプロセッサーとは動的なデータを表示させようと思うと画面毎に個別にViewsで画面にコンテキストを渡す必要があるが、あらゆる画面で共通して表示させたい情報があった場合はview毎に明示して記載すると面倒でかつメンテナンス性も落ちてしまうため、コンテキストプロセッサーとは記載しておけば、全ての画面にコンテキストとしてデータを渡す機能である
コンテキストプロセッサーのファイルを作成する
プロジェクト(config)フォルダの中にcustom_context_processors.pyとして作成することが一般的な模様
例 config/custom_context_processors.py
from django.conf import settings from base.models import Item def base(request): return { 'TITLE': settings.TITLE, 'POPULAR_ITEMS': items.order_by('-sold_count') }
上はタイトルや人気のアイテムをすべての画面にコンテキストとして渡す処理となっており、こうすることでどの画面でも人気商品をいつでも表示できるようになる
ちなみにコンテキストプロセッサーの変数は大文字で記載するのが慣習なのかもしれない
settings.pyにコンテキストプロセッサーを使用する設定を追記する
主にTEMPLATESにcontext_processorsの項目に先ほど作成したファイルのパスを記述する
configはプロジェクト名になるため、別の名前になっている場合はその名前に合わせる
settings.py
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'config.custom_context_processors.base', # 追記
],
},
},
]
# custom_context_processors
TITLE = 'VegeKet'
テンプレートにおけるコンテキストプロセッサの表示
通常のテンプレートに変数を表示させるのと同じようにブラケット記法で {{ TITLE }} 表示させることができる
例: header.html
<nav> <a class="py-2 text-white" href="/">{{ TITLE }}<!--VegeKet--></a> </nav>
TIPS! バッジの作成と表示
Webコンポーネントで言及されるバッジとは、Webアプリの通知(ベルのマーク)などで何件通知があるかなどを表示してくれるコンポーネントのことである
ヘッダーなどに設定する
コンテキストプロセッサーに登録した変数を使用すると便利
header.html
<a class="py-2 d-none d-md-inline-block text-white" href="/cart/">Cart {% if request.session.cart and request.session.cart.items|length != 0 %} <span class="badge badge-danger">{{request.session.cart.items|length}}</span> {% endif %} </a>
表示の方法としてはclass="badge badge-danger"クラスでbadgeを指定するだけでよい(Bootstrapの場合)
今回はコンテキストプロセッサーではなくセッションのcartデータから値を取得している
TIPS! テンプレート構文を使用したときにHTMLのインデントがずれてしまうとき
特にテンプレート内で比較構文を入れるとこれが起きやすい
例 {% if compare < 10 %} の < がHTMLのタグと認識されることが起きたりする
これは文法的にも問題はなく対処法も特にないので、見過ごすしかない模様
Tipa! 404 Not Foundページの実装
templatesフォルダ直下に 404.html を作成して、settings.pyの
DEBUG=False
ALLOWED_HOSTS = ['127.0.0.1']
を設定すると、404.htmlの内容が表示されるようになる
DEBUGがTrueだったり、ALLOWED_HOSTSが設定されていなかったりすると、エラーが返ってくる
500.htmlなど他のステータスコードも同様に実装することができる
404.html
<!--省略--> <div>404 Not Found アクセスしようとしたページは見つかりません</div> <!--省略-->
Next.js テスト実装方法 基本編勉強
この記事はUdemyの学習メモです
テストに関して解説してくれる教材はなかなか見つからないのでかなり重宝させてもらいました
www.udemy.com
Next.jsで実装するプロジェクトのテストの導入と静的テストの基本形の内容だけ記載しておきます
目次
テスト準備
1. プロジェクト作成手順
https://github.com/GomaGoma676/nextjs-testing/blob/main/README.md
この通りにやるとテストの導入ができます
1-2. 必要 module のインストール
インストールすると以下のようなメッセージが出ます
- axios@0.24.0
- swr@1.0.1
- msw@0.35.0
added 92 packages from 86 contributors and audited 534 packages in 28.619s
96 packages are looking for funding
run `npm fund` for detailsfound 0 vulnerabilities
上記のパッケージ警告は0件だがWindows(OSに関係ないのかもしれないが)でReactでパッケージインストールしていると結構な数の警告が出ます
警告が出るのは気持ち悪い派の人のために対処法(https://shinseidaiki.hatenablog.com/entry/2021/10/09/204921)はまとめてみましたが、あんまり意味がないようなので警告が出た場合は放置でいいと思います
2-1.
jest-css-modulesはテスト時に不具合を出す可能性のあるCSSのモック化のためのライブラリ
2-2.
windowsユーザーはtouch使えないので右クリックでファイルを作る
2-3.
testPathIgnorePatternsはテストとは関係ないフォルダを無視する記述
"moduleNameMapper"はCSSファイルがプロジェクトに存在した場合はmock化する記述
2-4.
package.jsonのscripts部分はnpm run ○○にコマンドを設定できる部分なので、testを記述してnpm run testでテスト実行できるようにしている
デフォルトでは全テストケースの成功失敗しか出力されない
-
- env=jsdom --verboseオプションをつけると各テストケースの成功失敗が出力される
3-1.
windowsユーザーは touch は右クリック作成
3-3.
npm run devを実行するとtsconfig.jsonの内容が自動的に生成されます
3-4.

apiフォルダは削除
4-2.
初期化コマンドを実行すると設定ファイルが2つ自動生成される
4-3.
これはpagesやcomponentsフォルダ下の実際に使用されているtsxファイルのユーティリティファイルに対してデプロイするときに自動生成されるCSSファイルについて、記載したフォルダのものだけをCSSに自動生成する記述
これを設定しておかないと存在するtailwindのクラスユーティリティすべてがCSSに出力されてファイルサイズが膨大になる
テストの基本形
5.2のところ
Next.jsのテストはテストフォルダ__test__に○○.test.tsxという形のファイルとして保存する

ファイルの中身としては以下のように書く
○○.test.tsx
import { render, screen } from '@testing-library/react' import '@testing-library/jest-dom/extend-expect' import コンポーネント from '../pages/index' it('テストケース説明', () => { render(<コンポーネント />) expect(screen.getByText('期待する文字列')).toBeInTheDocument() })
renderはコンポーネントに変数などがあれば変数に値を代入してHTMLを作り出します
画面としては実際に表示しませんがitの中では仮想的に画面をいじっているのと同じ環境を作り出しています
screenはrenderでレンダリングした画面を差していて、getByTextで画面の中に'期待する文字列'、教材の場合は'Hello Nextjs'があるかどうか探します
toBeInTheDocumentでは探した文字が画面上に表示されていれば正解であると判定します
これが基本形です
npm run test でテストが失敗した場合は原因などの情報を含めて出力される
この失敗のケースでは"期待している文字列"が見つからなかったので、コンポーネントの方に"期待している文字列"をちゃんと実装しなさいと言っています
しかしながら"期待している文字列"もスペルミスなどで間違う可能性があるので真に受けすぎないのも大事です
FAIL __tests__/Home.test.tsx
× Should render hello text (40 ms)● Should render hello text
TestingLibraryElementError: Unable to find an element with the text: Hello Nextjs2. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.
......
Test Suites: 1 failed, 1 total
Tests: 1 failed, 1 total
Snapshots: 0 total
Time: 3.296 s
Ran all test suites.
npm ERR! Test failed. See above for more details.
また上記のテスト結果だけでは原因を特定できない時にテストのデバッグがscreen.debug()でできます
ただし明示的にscreen.debug()を入れなくてもテスト失敗の時はデフォルトでデバッグ出力は出力されます
it('テストケースの説明', () => {
render(<コンポーネント />)
screen.debug()
expect(screen.getByText('期待している文字列と違う文字列')).toBeInTheDocument()
})デバックを入れているときのテストが成功する場合のコンソールへの出力例
(テスト失敗の時はデフォルトでデバッグ出力されている模様)
レンダリングされているHTMLの要素を出力させている
console.log
<body>
<div>
本当の文字列
</div>
</body>
Static Site Generation 実装パターンのテスト作成
静的なコンテンツをテストするパターン
テストするための画面作り
まずはテストするための画面を2画面作成します
Next.jeではすべての画面で共通して使われるベースの画面components/Layout.tsxを作成して各画面はそれを継承するのが通常の実装になる
Layout.tsx 基本形
interface TITLE { title: string } const Layout: React.FC<TITLE> = ({ children, title = 'Next.js' }) => { return ( <div></div> ) } export default Layout
Layout.tsx のreturnの中身は Headとheaderとmainやfooterなどで通常構成される
Link href="/"タグはルーティングでSPAとして差分だけを読み込みに行ってくれる
対してa href="/"タグを使うと全量ページを読み込みに行くのでLayout.tsxのヘッダーに使われる画面遷移ではLinkを使うのが一般的
ホーム画面とブログ画面の2画面に遷移できるようにしておく
Layout.tsxのreturn の中身だけ記載
return ( <div> <Head> <title>{title}</title> </Head> <header> <nav> <Link href="/"> <a data-testid="home-nav" >Home</a> </Link> <Link href="/blog"> <a data-testid="blog-nav" >Blog</a> </Link> </nav> </header> <main> {children} </main> </div> )
使用しているHeadやLinkのインポートはこちら
import Head from 'next/head'; import Link from 'next/link';
次に画面を作ります
ホーム画面となるindex.tsxを編集します
Layout.tsxをベースに作成するので、return 直下をLayoutで囲みます
index.tsx
import Layout from "../components/Layout"
const Home: React.FC = () => {
return (
<Layout title="Home">
<p>ホーム画面です</p>
</Layout>
)
}
export default Home次に2つ目の画面となるblog-page,tsxをpagesフォルダ下に作成します
next.jsはpagesフォルダ直下に格納したファイル名から自動的にルーティングするので、blog-pageという名前を使うと/blogでblog-page,tsxが自動的に参照されるような仕掛けとなっています
blog-page,tsxもindex.tsxと同じようなコードになります
blog-page,tsx
import Layout from '../components/Layout'
const BlogPage:React.FC = () => {
return (
<Layout title="Blog">
<p>ブログページです</p>
</Layout>
)
}
export default BlogPage
テスト実装
1画面目のテスト、静的テスト基本形
まずはindex.tsxの静的な画面をテストします
これはテスト基本形のところで学んだ通り__test__フォルダを作成してHome.test.tsxを作成し、以下のような手順でコードを作成していっててテストを実装していく
まずは基本的なテストモジュールのインポートをする
import {render, screen} from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';次にテストしたい画面モジュールをインポートする
import Home from '../pages/index'
次にテストケースのひな型を書く
describe('テストケースの説明_○○を確認する', () => {
it('テストケース1_正常系_○○', async () => {
})
it('テストケース2_異常系_○○', async () => {
})
})テストケースの中身を書く
it('テストケース_○○', async () => {
render(<Home />) # テストしたい画面のレンダリング
expect(screen.getByText('期待する文字列')).toBeInTheDocument() # 検証する内容
})このようにしていくと完成形は以下のようになる
Home.test.tsx
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom/extend-expect'
import Home from '../pages/index'
it('テストケース説明', () => {
render(<Home />)
expect(screen.getByText('ホーム画面です')).toBeInTheDocument()
})これが静的テストの基本形にあたり、Home画面の表示項目が増えたらexpectを増やしていくことで確認したい項目を増やしてテストを作っていく
2画面目のテスト、画面遷移を伴うテスト
次にBlog画面のテストだが、リンクをクリックして画面遷移をする挙動に関してもテストが行えるため画面遷移のテストもここで行うことにする
そのためには画面遷移つまりLinkコンポーネントのテストには別途必要なコンポーネントがあるためインストールする
npm install next-page-tester
では2つ目の画面をテストするテストファイル__tests__/NavBar.test.tsxを作成する
NavBar.test.tsxでは画面上のリンクのクリックを模擬するのでテスターがユーザーイベントをするためのモジュールをインポートします
またそれに伴い各種モジュールもインポートします
NavBar.test.tsx
import useEvent from '@testing-library/user-event'; // テスターが画面上の要素をクリックできるモジュール import { getPage } from 'next-page-tester'; import { initTestHelpers } from 'next-page-tester'; // 初期化を行う initTestHelpers()
次にルーティングからページを取得してきてレンダーする処理を施します
今回は画面が遷移するのでコンポーネントを直接レンダリングするのではなくルーティング経由にします
NavBar.test.tsx
const { page } = await getPage({
route: '/index',
})
render(page)今回はクリックイベントが発生するのでクリックイベントのコードは以下になります
useEvent.click(screen.getByTestId('実装時のタグに付与したtestId属性の値'))
なおクリックイベントで画面遷移をするにはLinkタグをクリックする必要があり、テスト上ではuseEvent.clickを使用することでクリックできるが、何をクリックするかのキーとなる部分に関してはgetByTestIdを使用しており、実装時の段階でLayout.tsxのLinkタグの中にdata-testidの属性を付与する必要があることは注意が必要
Layout.tsx
<Link href="/blog"> <a data-testid="blog-nav" ... >Blog</a> </Link>
※クリックイベントにはタグ属性にtestIdを付与する必要がある
これらを実装した画面遷移を伴うテストであるNavBar.test.tsxテストの完成形はこちら
import { render, screen } from '@testing-library/react' import '@testing-library/jest-dom/extend-expect' import userEvent from '@testing-library/user-event' import { getPage } from 'next-page-tester' import { initTestHelpers } from 'next-page-tester' initTestHelpers() describe('画面遷移を伴うテスト', () => { it('テストケース1', async () => { const { page } = await getPage({ route: '/index', }) render(page) userEvent.click(screen.getByTestId('blog-nav')) expect(await screen.findByText('ブログページです')).toBeInTheDocument() }) })
Next.jsのSSG(Static Site Generation)に関する4パターンの整理
Next.js は基本的にデフォルトでビルド時にHTMLを事前生成する
ビルド時にDBからPre-FetchをしてHTMLを事前生成する方式とアプリ運用時のサーバーアクセス時にDBからHTMLを生成する方法が大きく4種類あるので整理する
SSGはStatic Site Generationの略でユーザーに渡す情報は基本的には静的データを渡そうというコンセプトのもとに作られたアプリがユーザーにデータを渡す仕組みである
従来のデータ配信方式ではDBに格納された情報を毎回動的にHTMLにレンダリングして渡していたが、SSGで実装されたアプリでは一度HTMLにレンダリングされた情報は静的コンテンツにしておいて次回のアクセス時には静的コンテンツとして配信される
このHTMLにレンダリングするタイミングの違いによってSSGは以下の4種類のパターンに分類される
SSGについて例えを出すならば今までは飲食店で注文を受けたらその都度料理していたが、開店前に作り置きを作っておいて注文を受けたらそれを出すようなものです
また飲食店は新規メニューのオーダーも都度受け付けていて新規メニューの注文があればその場で料理を行って提供し、その後は作り置きを提供するような仕組みになっています
ISRはIncremental Static Regenerationの略で最新情報をHTMLにレンダリングしておいて静的コンテンツにしておく技術である
ちなみにややこしい話ですがSSRという単語もあって、こちらはServer Side Renderingの略でHTMLは従来クライアント側で生成していたものをサーバ側で作成して渡すようにしたことにあえて名前がついたものになっています
SSRについて例えを出すならば今までコープで食材だけもらって料理は自宅でするのが当たり前だったが生活様式が変化してきてUserEatsを使う人が増えたので"出前"という言葉がはやり始めたみたいな状況です
1.SSG
DocumentやHelpなどのように「完全に静的なページ」
SEOは特に意識しない
代表的なものとしては Index ページがこの実装にあたる場合が多いと思われる
あとはDocumentやHelpページ
2.SSG + Prefetch
ビルド時にデータベースから取得してきた情報を事前にHTMLにレンダリングして静的データとしておくパターンです
Googleのクローラーに読み込まれるため、SEO対策に有効です
使われるページの例としてはブログページや商品一覧などが考えられています
ただしそのままだとビルド時の情報のみ使用されるため最新情報を反映させ合たい場合は別途対策が必要です
最新情報を取得する場合にはISRが必要になり、基本的にはISRをセットで利用する場合が多いと思われます
ISRを加えた2.1のパターンがNext.jsで一番よく実装されるであろうと考えられます
3.SSG + Client side fetching
SEOを意識する必要はないが、最新情報を取得したいページに利用されるパターンです
通常の「create-react-app」(従来のウェブアプリの実装)と似たようなレンダリングの仕組みになります
Client side fetching(通常のAxiosなどのAPI経由でデータを取得する方法)を使って、その都度バックエンドから取得してきたデータをレンダリングしてページを表示させます。
最新データはHTMLにレンダリングされないために新規作成もしくは更新された情報はDBから情報を取得して毎回レンダリングされる仕様となります
DBから情報を取得しないとレンダリングされないのでグーグルのクローラーは有益な情報は何もないと判断してしまい最新の情報に関してはSEOされることはありません
代表的なものとしてはDashboardやTodoリストなどのようなページです
4.SSG + Prefetch + Client side fetching(useSWR)
データをリアルタイムで作成・更新する必要があって、かつ、最新情報にもSEO対策を施す必要があるようなページに利用されるパターンです
2のパターンとほぼ同様ですが、Client side fetchingを使用することで、データの更新をリアルタイムに反映することができます
ただしこの状態では画面表示においてビルド時の過去データと最新情報に乖離がある場合に過去データが表示されたのちに最新データに更新されるという挙動が起きてしまうので、以下の最新データから静的コンテンツを作成しておくISRと組み合わせて使う実装が実用的である
4-1. SSG + Prefetch + Client side fetching(useSWR) + ISR
Next.jsはビルド時の情報が使われてその後情報が更新されません。
後々情報を更新したい場合はISR(DBの最新データで静的サイトを作り直す技術)を使って更新します
ビルド時にデータを事前にレンダリングするだけでなく、ビルド以降に作成・更新された最新情報に関してもHTML事前レンダリングが実行されて(初回アクセスがトリガー)グーグルのクローラーに分析してもらえるようになり、最新の情報にもSEO対策が施されます
初回アクセス以降は事前レンダリングされた最新情報のHTMLが配信されるためISRを使用しないと過去データが表示されたのちに最新データが表示される現象も解決する(具体的にはISRのrevaridateを設定する必要がある)
Next.jsのテスト実行時に.env.test.localの環境変数が読み込まれない時の対処
はじめに
Next.js開発においてAPIエンドポイントのURLを環境変数化した場合にアプリ自体は動くがテストが通らなくなる事案が発生
調べによるとJESTは環境変数を.env.test.localに記載してもうまく読み込んでくれない場合がある模様
環境
OS: Windows 10
node -v: v14.18.0
yarn -v: 1.22.17
package.json
{
"name": "hoge-project",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"test": "jest --env=jsdom --verbose",
"gen-types": "graphql-codegen --config codegen.yml"
},
"dependencies": {
"@apollo/client": "^3.4.17",
"@apollo/react-hooks": "^4.0.0",
"@heroicons/react": "^1.0.5",
"autoprefixer": "^10.4.0",
"cross-fetch": "^3.1.4",
"graphql": "^16.0.1",
"next": "11.1.2",
"postcss": "^8.3.11",
"react": "17.0.2",
"react-dom": "17.0.2",
"setimmediate": "^1.0.5",
"tailwindcss": "^2.2.19"
},
"devDependencies": {
"@babel/core": "^7.16.0",
"@graphql-codegen/cli": "2.3.0",
"@graphql-codegen/typescript": "^2.4.1",
"@graphql-codegen/typescript-operations": "2.2.1",
"@graphql-codegen/typescript-react-apollo": "3.2.2",
"@testing-library/dom": "^8.11.1",
"@testing-library/jest-dom": "^5.15.0",
"@testing-library/react": "^12.1.2",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.0.3",
"@types/node": "^16.11.8",
"@types/react": "^17.0.35",
"babel-jest": "^27.3.1",
"eslint": "7",
"eslint-config-next": "12.0.4",
"jest": "^27.3.1",
"jest-css-modules": "^2.1.0",
"msw": "^0.35.0",
"next-page-tester": "^0.30.0",
"typescript": "^4.5.2"
},
"jest": {
"testPathIgnorePatterns": [
"<rootDir>/.next/",
"<rootDir>/node_modules/"
],
"moduleNameMapper": {
"\\.(css)$": "<rootDir>/node_modules/jest-css-modules"
}
}
}
対処法
対処1 Home.test.tsx以外の全てのテストファイルの import 直下に下記環境変数を定義してからyarn testを実行
process.env.NEXT_PUBLIC_HASURA_URL = 'https://xxx.hasura.app/v1/graphql'
こちらはテストをGitHubにアップロードするのをためらってしまうため実用的ではない
対処2 環境変数を各テストケースで毎回読み込む
以下の記事に解決策は書いてあったものその通りの方法を実施した場合ではエラーが出たため自環境では各テストケースで環境変数を読み込む実装とした
Jestを用いたNext.jsのテスト内で環境変数を利用する - Breath Note
プロジェクト直下に
test/setupEnv.ts を作成し、以下を実装
import { loadEnvConfig } from '@next/env'
export const setupEnv = async (): Promise<void> => {
loadEnvConfig(process.env.PWD || process.cwd())
}各テストのテストケースで上記のsetupEnvを実行する
XXXXX.test.tsx
// ..........................
import { setupEnv } from "../test/setupEnv"
//.......................
describe('.................................', () => {
it ('.................................', async () => {
await setupEnv()
// ......................................こちらは各テストケースに記載する手間はあるが、外部ファイルから環境変数を読み込む形でテストを利用できる
対処3 環境変数をテスト準備時に読み込む
対処2ではhandlersがsetupEnvより前に記述されている場合に環境変数を読み込んでくれないという事案が発生する
解決方法としては await setupEnv() の処理をテスト準備処理をしているコード上部に持ってきてawaitを取り除いてsetupEnv() を行う
こうすることでsetupEnv()を各テストケースに記載する手間も省けるのでこの形が最も良い
XXXXX.test.tsx
// ..........................
import { setupEnv } from "../test/setupEnv"
setupEnv()
//.......................テスト準備の処理
// 本チャンテストにsetupEnvを記述する必要なし
describe('.................................', () => {
it ('.................................', async () => {
// ......................................
