マッチングアプリ個人開発解説【Django×Flutter】

婚活
  1. はじめに
  2. マッチングアプリ作りへの意気込み
  3. 要件定義
    1. 目的
    2. 達成条件
    3. 業務要件
      1. MUST要件
      2. TRY要件
    4. 機能要件
      1. MUST要件
      2. TRY要件
    5. システム構成
  4. 基本設計
    1. 画面遷移図
    2. 画面詳細
    3. ER図
    4. RestAPI エンドポイント設計
  5. 開発環境
  6. 【バックエンド】Django編
    1. 環境構築
      1. 仮想環境作成
      2. パッケージインストール
      3. PyCharmと仮想環境の紐づけ
      4. プロジェクトとアプリの作成
      5. サーバーの起動確認
    2. Djangoの初期設定
      1. 空フォルダ作成
      2. シークレット情報の環境変数化
      3. setting.pyの編集
        1. アプリケーション適用
        2. 環境変数適用
        3. パッケージ適用
        4. アクセス制限の適用
        5. DB設定
        6. カスタムユーザー適用準備
        7. ロケーション変更
        8. StaticRootの設定
        9. Mediaファイルの取り扱い設定
      4. URLの設定
        1. プロジェクト側urls.pyの設定
        2. アプリ側urls.pyの設定
        3. .gitignoreの編集
    3. Modelの実装
      1. カスタムユーザーの実装
        1. ユーザーマネージャーの実装
        2. ユーザークラスの実装
      2. Profileクラスの実装
      3. Matchingクラスの実装
      4. DirectMessageクラスの実装
      5. Migrationの実行
    4. 管理者サイトの整備
      1. カスタムユーザーのモデルクラスを管理画面で扱えるようにする
        1. 管理者画面にカスタムユーザーを追加
        2. Profileクラスをカスタムユーザーの詳細画面に挿入する
      2. 実装したモデルクラスを管理画面で扱えるようにする
        1. Prolileを管理者画面に追加
        2. Matchingを管理者画面に追加
        3. DirectMessageを管理者画面に追加
      3. 管理者ユーザー(Superuser)の作成
      4. 管理画面の動作確認
    5. Serializersの実装
      1. ユーザーシリアライザーの作成
        1. コード解説
      2. ProfileSerializerの作成
      3. マッチングシリアライザーの作成
      4. ダイレクトメッセージシリアライザーの作成
        1. 実装解説
        2. マッチング中のユーザーのみをフィルタリングする
        3. DMシリアライザー実装
    6. Viewsの実装
      1. クラスベースビューで使用するモジュール
        1. generic viewsで使用可能なクラス
        2. viewsetsで使用可能なクラス
      2. RESTAPI ユーザー作成機能の実装
      3. RESTAPI ユーザー取得更新機能の実装
      4. RESTAPI プロフィールCRUD機能の実装
      5. RESTAPI 自己プロフィール取得更新機能の実装
      6. RESTAPI マッチング機能の実装
      7. RESTAPI メッセージ送信機能の実装
      8. RESTAPI メッセージ受信機能の実装
    7. URL ルーティングを設定する
      1. Djasorを利用したときのJWTでのユーザー認証のエンドポイントについて
    8. 動作確認
      1. 確認するエンドポイントの整理
      2. ユーザー新規作成とJWT認証
        1. ユーザー新規作成
        2. ログイン認証処理とJWT発行
        3. JWTを使用したアクセス認証
      3. ログイン中のユーザー情報の取得と更新
        1. emailとユーザー名のPATCH変更
        2. パスワードのPATCH変更
        3. 他のユーザーのidをpkに入れても見れないことを確認する
      4. ログイン中のユーザー情報の取得と更新
        1. ユーザー情報取得の確認(GET)
        2. ユーザー情報更新の確認(UPDATE)
      5. ログイン中のプロフィール新規作成と異性プロフィール一覧取得
        1. プロフィール新規作成の確認(CREATE)
        2. 異性をフィルタリングしたプロフィール一覧取得の確認(GET)
      6. プロフィール取得
      7. マッチングデータ作成・マッチング一覧取得
      8. マッチングデータ取得・更新
      9. DM送信側処理
        1. 送信側DM一覧取得(CREATE, GET)
        2. DM送信取得・更新取得(GET, PATCH /{pk}/ )
      10. DM受信側一覧取得
      11. トークン期限切れ確認
    9. 決済機能
      1. Stripeについて
      2. アーキテクチャ設計
        1. 新規ユーザークレジット決済アクティベーションフロー
        2. REST API 設計
      3. 環境構築
        1. Stripeアカウント登録とStripeのインストール
        2. Email機能の設定
        3. 決済機能の設定
      4. Model実装
        1. アクティベーション機能の仕様
        2. トークンモデルの実装
        3. トークン検証機能の実装
        4. トークン発行機能の実装
        5. Email送信機能の実装
      5. ルーティング実装
      6. View実装
        1. Stripeを利用するための実装
        2. 決済スキップをされないようにコードを修正する
        3. 決済がキャンセルされたときの処理の実装
        4. ユーザーアクティベーションの処理の実装
      7. 動作確認
        1. ユーザー作成時のトークン生成とEmail通知の確認
        2. Stripe決済機能とアクティベーション機能の確認
        3. 不正アクティベーション実行の確認
  7. 【フロントエンド】Flutter編
    1. 環境構築
    2. プロジェクト作成
    3. 状態管理パッケージの導入
    4. ログイン機能の実装
      1. 必要なパッケージインストール
      2. Providerの実装
      3. メニュー画面の実装
      4. ログイン画面の実装
      5. 新規ユーザー作成画面の実装
      6. LoginProviderの実装
        1. コード解説
        2. ログインの動作確認
        3. サインアップの動作確認
    5. 自己プロフィール画面の実装
      1. 自己プロフィール画面の実装
      2. 共通部品
        1. スナックバー
        2. ボトムナブバー
        3. ドロワー
        4. ピッカー
        5. ラジオボタン
      3. 環境変数の導入
      4. ProfileProviderの実装
        1. 実装コード
        2. コード解説
        3. 画像のアップロード方法について
        4. 動作確認
    6. プロフィール一覧閲覧機能&いいね機能の実装
      1. プロフィール全件取得コードの作成
      2. マッチングデータモデルの作成
      3. フィルタリングの実装
        1. プロフィール一覧画面に表示するユーザーのフィルタリング
        2. いいねを送ったユーザーのフィルタリング
        3. いいねをもらったユーザーのフィルタリング
        4. マッチングしたユーザーのフィルタリング
      4. ユーザー一覧画面の作成
        1. 共通利用画面のWidget化
        2. プロフィール一覧画面の実装
        3. いいねを送ったユーザーのプロフィール一覧画面
        4. いいねをもらったユーザーのプロフィール一覧画面
        5. マッチング中のユーザーのプロフィール一覧画面
      5. ユーザー詳細画面の実装
        1. ユーザー詳細選択ロジック
        2. 詳細画面実装
      6. いいね送信機能およびマッチング機能の実装
        1. 実装コード
        2. マッチング: 既にもらっているいいねに対する承認ロジックについて
    7. メッセージ機能の実装
      1. Modelの実装
      2. 全件メッセージ取得ロジック
        1. 状態管理したい変数
        2. ロジック
      3. 特定のユーザーとのメッセージだけにフィルタリングするロジック
      4. 新規メッセージ作成
      5. メッセージ画面の実装
      6. メッセージ一覧画面共通部品の実装
    8. 動作確認
  8. おわりに

はじめに

こちらはマッチングアプリを自作してみようという挑戦的な記事です。

DjangoとFlutterの実際のコードを見せながら一緒にマッチングアプリを開発していきます。

マッチングアプリを作りたいと思っているそこのあなたは是非最後まで記事をご覧になってください。

マッチングアプリ開発の注意点

マッチングアプリの運用は有償無償を問わず「インターネット異性紹介業」の開業を警視庁に申請しなければなりません。
また、免許証もしくはクレジットカードを必ず扱うため個人情報の扱いを厳重に行う必要があります。
実運用には高いセキュリティが求められるため実際にリリースする際には十分にお気を付けください。

マッチングアプリ作りへの意気込み

世の中的に見ればマッチングアプリ業界は既にレッドオーシャン、近づくなかれの市場であるように見えるかもしれません。

しかし、本当にそうでしょうか?

マッチングアプリの本来の目的は理想のパートナーをくっつけることにありますが、いまだかつてその本旨を全うしたサービスは存在したでしょうか?

否、いまだにマッチングアプリの世界では阿鼻叫喚の地獄絵図が広がっています。

つまり現在のマッチングアプリ業界はまだまだ不完全なサービスであり、発展途上の業界なのです。

そして恋愛における需要は人によって千差万別であり、全ての人間の需要をカバーしきれているとは言えない状況であります。

つまりまだまだ我々の開拓の余地があるのです。

そして、マッチングアプリ自体はそれほど難しいアプリケーションの仕組みは必要ではないため、ちょっと勉強すれば誰でも作れます。

今こそあなたの最高のアイデアを実現させ、世の中に理想のカップルを爆増させていきましょうではないか。

そのための記事がこちらです。

俺たちの手で最高のマッチングアプリを作ろうじゃないか!

……

………

それではあったまってきたところで、早速マッチングアプリ作りを始めていきましょう

ダイキっち
ダイキっち

実装に入る前にまずはアプリケーションの設計をしていきましょう。

なお以下の記載内容は女性に飢えている独身男性を想定して書いています。

要件定義

まずは作る目的、業務要件、機能要件、システム構成をざっくり整理しましょう。

開発プロジェクトの最も基本であり、目的が重要であればあるほど作るモチベーションにも影響します。

目的

自作マッチングアプリでモテモテになる。

男なら一回は考えたことがあるんじゃなかろうか

達成条件

自作マッチングアプリで集客した女性100人と出会う

自作アプリなら無敵だよな

業務要件

業務要件を定義します。

MUST要件

  • 本サービスに認可・認証されたユーザーのみが利用することができる
  • 年齢確認をすることができる
  • ユーザーは異性ユーザーのプロフィールを一覧で閲覧することができる
  • ユーザーは異性ユーザーに「いいね」を送ることができる
  • ユーザー双方が「いいね」を送り合うと「マッチング」が成立する
  • マッチングが成立したユーザー同士はメッセージを使用することができる

TRY要件

  • 本人確認をすることができる
  • ユーザーはユーザーごとにレコメンドされた異性のプロフィールを一覧画面で閲覧することができる

機能要件

機能要件を定義します。

MUST要件

  • ログイン認証機能
  • クレジットカード決済ができる
  • WEBアプリとして当然備えているべき一通りの基本的な機能が実装されている

クレジット決済は「インターネット異性紹介業」の法律上の要件を満たすために必須。

TRY要件

  • (プロフィール・免許証)画像のアップロードができる
  • email認証(アカウント作成時)
  • eKYC(Know Your Customer) 機能が備わっている
  • OAuth認証

システム構成

以下の技術スタックを用いてアプリケーションを構成する。

  • バックエンド: Django Rest API
  • フロントエンド: Flutter

基本設計

次に簡単な基本設計を記載しておきましょう。

画面遷移図

flowchart TD TOP(トップ画面) --> USER{サインイン} USER --> |新規登録| SIGNUP(サインアップ画面) USER --> |ログイン| SIGNIN(ログイン画面) SIGNUP --> |決済| CREDIT(クレジット決済画面) CREDIT --> |認証成功| PROFILE SIGNIN --> |認証成功| PROFILE subgraph 認証済み PROFILE(プロフィール一覧画面) --> PROFILEDETAIL(プロフィール詳細画面) MYPROFILE(自己プロフィール画面) MESSAGE(メッセージ画面) PROFILE --- MYPROFILE --- MESSAGE end

画面詳細

画面名詳細
トップ画面サービス概要について閲覧ができる。
サインアップ画面ユーザーの仮登録が可能。
クレジット決済画面クレジット決済が完了すると本登録となる。
ログイン画面本登録ユーザーの認証画面
プロフィール一覧画面異性のプロフィールを一覧で閲覧できる。
プロフィール詳細画面異性の詳細プロフィールを閲覧でき、いいねを送ることができる。
自己プロフィール画面自己プロフィール情報の閲覧・更新を行うことができる。
メッセージ画面メッセージの送受信を行うことができる。

ER図

erDiagram USER {} PROFILE { string id PK, FK string userName } MATCHING { string id PK string sender FK string receiver FK bool approved } DIRECTMESSAGE { string id PK string sender FK string receiver FK string message } USER ||--|| PROFILE: is USER ||--o{ MATCHING: favorite USER ||--o{ DIRECTMESSAGE: message

マッチングモデルではいいねの送信者と受信者が双方向にいいねを送り合い、双方のapprovedがtrueになった場合にマッチングとする。

RestAPI エンドポイント設計

エンドポイント機能内容
/api/authen/ユーザー認証に利用される
/api/users/create/ユーザーを作成することができる
/api/users/{pk}/自分自身のユーザー情報を取得・編集できる。他人のユーザー情報は取得・編集できない
/api/users/profile/{pk}自分自身のプロフィール情報を作成・取得・編集できる。他人のプロフィール情報は取得・編集できない
/api/profiles/自身のプロフィールを作成したり、異性のプロフィール一覧を取得することができる。自分のプロフィールがない状態ではプロフィール一覧を取得することはできない
/api/favorite/いいねを送ったユーザといいねをくれたユーザ一覧を取得したりいいねを送ったり、いいねを承認したりすることができる
/api/dm-message/ユーザー自身が送信したDMの一覧を取得する。新たにメッセージを送信することができる
/api/dm-inbox/ユーザー自身が受信したDMの一覧を取得する

開発環境

簡単に開発環境の紹介をしておきます。

  • OS: Windows 10
  • バックエンドプログラミング言語: Python 3.8
  • バックエンドフレームワーク: Django 4.0.2
  • 仮想環境:Anaconda Navigator Individual Edition
  • エディタ:PyCharm
  • フロントエンドフレームワーク: Flutter 2
  • エディタ: Android Studio
  • 決済機能: Stripe

さて、こんなところでしょうか。

では、早速開発を行っていきましょう。

なお、以下の実装の説明でうまく行かない場合は、こちらのリポジトリコードも参照してみてください。

ダイキっち
ダイキっち

それではバックエンドの構築から行っていきましょう。長いですがお付き合いください。

【バックエンド】Django編

まずは、バックエンドの開発を行っていきます。

環境構築

Django開発では、PythonとAnaconda NavigatorとPyCharmを利用します。
インストールしていない人はインストールしてください。

仮想環境作成

まずは仮想環境を作成しましょう。

手順

  1. Anaconda Navigatorを起動する
  2. 新たな仮想環境作成を選択(Environments>Create)
  3. 画面に従って、Nameに「Matching_App_API」などを入力していきcreateを押下

パッケージインストール

ターミナルを開き、必要なパッケージをインストールしていきます。

手順

  1. Anaconda Navigatorで先ほど作った環境の再生ボタン(△ボタン)をクリックしてターミナルを開く
  2. pip installで、以下のパッケージをインストールする

インストールパッケージ

  • django: これから開発に使用するバックエンドのフレームワーク
  • djangorestframework: Rest APIが使用できるようになる
  • djangorestframework-simplejwt: JWTと呼ばれる最近使用されるセキュリティレベルの高いTokenを使用した認証機構
  • djoser: JWTを便利に使用するためのパッケージ
  • django-cors-headers: バックエンドとフロントエンドをクロスエンジンでつなぐ
  • pillow: 画像を扱うためのパッケージ
  • django-environ: 環境変数を扱うパッケージ

PyCharmと仮想環境の紐づけ

PyCharmに上記で作成した仮想環境を紐づける

手順

  1. 任意の場所にプロジェクトルートとなるフォルダを作成する
    例:C:\Users[YOURNAME]\projects\matching-app
  2. Pycharmを起動する
  3. 作成したフォルダを開く
  4. File>Settingを選択
  5. Project>Python Interpreterを選択して、画面の歯車マークからAddを選択する
  6. Existing environmentを選択して、…マークを選択する
  7. %USERPROFILE%フォルダ以下で envs\[仮想環境の名前]\python.exe を選択する
    例: envs\Matching_App_API\python.exe
  8. Python Interpreterに先ほど作成した仮想環境Matching_App_APIが適用され、Djangoのコマンドなどが利用できるようになるので、OKを押してPyCharmを再起動する
  9. 仮想環境が適用されることを確認する

プロジェクトとアプリの作成

それではdjangoのプロジェクトを作成していきましょう.。

PyCharmのターミナルを開いて、まずは以下のコマンドを入力して matchingappapi プロジェクトという名前でバックエンドのプロジェクトフォルダを作成しましょう

django-admin startproject matchingappapi

プロジェクトを作成したら、PyCharmで今作成したプロジェクトフォルダ直下にフォルダを開き直します。

なお、同じフォルダが二つ作成されていますが、プロジェクトフォルダ直下と言っているのは matching-app下のmatchingappapiフォルダのことです。

(参考)フォルダ階層例
matching-app
┗matchingappapi
 ┗ matchingappapi

なお、フォルダを開きなおすと先ほどの「PyCharmと仮想環境の紐づけ」の手順をもう一度行う必要があります。

次にアプリをbasicapiという名前で作成します。

django-admin startapp basicapi

ここまで終わるとフォルダ構成が以下のようになっているはずです

(参考)フォルダ階層例
matching-app
┗ matchingappapi
 ┗ matchingappapi
 ┗ basicapi
 ┗ manage.py

サーバーの起動確認

ここまででWebアプリの初期導入は完了しています。

なので、一度サーバーを起動してアプリが立ち上がるかを動作確認してみましょう。

手順

  1. プロジェクト直下の manage.py を右クリックしてrun 'manage'をクリック
  2. そのあと、PyCharm画面の右上にmanageボタンが表示されるので、クリックしてEdit Configurationsを選択する
  3. parametersの箇所に runserver を追記する

この設定を行うことでPyCharmの右上の再生ボタンを押すことでサーバーを起動させることができるようになります。

サーバーを起動させるとターミナルに起動URL(http://127.0.0.1:8000/)が表示されるはずなので、ブラウザを開いてそのURLにアクセスすると、Djangoのロケットが打ちあがる初期起動画面が見えるはずです。

ロケットの画面が見えれば動作確認は成功です。

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を使用する設定とする
大文字になっている箇所は任意の名前を入力する

setting.pyの編集

アプリケーション適用

まずはプロジェクトにアプリケーション(basicapi)を適用します

settings.py

INSTALLED_APPS = [
'basicapi.apps.BasicapiConfig',
]

環境変数適用

次にsetting.pyに先ほど.envに外部ファイル化した環境変数を適用していきます

settings.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パッケージをこれから作るアプリに適用していきます

settings.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

# 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

basicapi/__pycache__/
basicapi/migrations/__pycache__/
matchingappapi/__pycache__/
media/
secrets/
db.sqlite3

こちらで初期設定は完了です

Modelの実装

それでは本題のモデルを実装していきましょう

カスタムユーザーの実装

まずはDjango推奨の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

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クラスの実装

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

上記は最低限のフィールドでしたが、より実際のプロフィールの項目に合わせたものを以下に記載します。

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が格納されコード上では辞書型として提供されます。
最終ログイン日を教えてくれるサービスがあるので本アプリもそれにならいます。

今回年齢を格納するフィールドはProfileに持たせましたが、年齢は年を経るごとに更新されていくので生年月日の項目が必要であるのと、ユーザー新規作成時に年齢を入力してもらいたいという観点から、ユーザークラスに新規登録時年齢registered_ageと生年月日のフィールドを持っておいた方が良い設計かもしれない。
またプロフィールは表示年齢として生年月日から自動計算にする実装にした方が良い。

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)

DirectMessageクラスの実装

最後に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)

Migrationの実行

ModelをDBに適用するためにマイグレーションを実行します

py .\manage.py makemigrations 
py .\manage.py migrate

これで先ほど作ったモデルがDBに構築されました

管理者サイトの整備

Djangoにデフォルトで用意されている管理者画面を扱いたい場合はアプリフォルダ(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クラスをカスタムユーザーの詳細画面に挿入する

インラインを利用するとテーブルが分かれているクラスを同一画面で見ることができて管理画面が利用しやすくなるのでUserにProfileクラスを挿入していきます。
インラインの行をUserAdminに追加します。

admin.py

from .models import User, Profile


class UserAdmin(BaseUserAdmin):
    # ......
    inlines = (ProfileInline,)

実装したモデルクラスを管理画面で扱えるようにする

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の実装

SerializerとはModelとViewをつなぐインターフェースであるSerializerの定義を行いましょう。
basicapi/serializers.pyを作成します。

ユーザーシリアライザーの作成

まずはユーザーのシリアライザーを作成します。
全CRUDをサポートする便利な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の作成

次にプロフィールのシリアライザーを作成します。

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}}

ユーザークラス以外のモデルに関しては基本的にはMetaクラスの属性を編集することで実装できます。
fieldの作成日時と更新日時はミリ秒以下は不要のためフォーマットしてデータを格納します。
作成日時は編集する必要がないので読み取り専用にしておきます。
また特に外部キーに使用している user も変更されると一大事となるのでこちらも読み取り専用にしておきます。

マッチングシリアライザーの作成

同様にマッチングシリアライザーを作成します

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を利用する際はクラスベースビューを使用したほうが楽で見通しが良いです。

それではViewの実装をしていきましょう
アプリ(basicapi)フォルダ下のviews.pyを編集していきます

クラスベースビューで使用するモジュール

クラスベースビューで使用するdjango-rest-frameworkのモジュールの機能は以下のようになっている。

generic viewsで使用可能なクラス
クラス操作
CreateAPIView登録(POST)
ListAPIView一覧取得(GET)
RetrieveAPIView取得(GET)
UpdateAPIView更新(PUT、PATCH)
DestroyAPIView削除(DELETE)
docs

なお、下記のようなクラスも存在する。
例:ListCreateAPIView (POST、GET) = ListAPIView + CreateAPIView

viewsetsで使用可能なクラス
クラス操作
ModelViewSet登録取得更新削除(GETPOSTPUTPATCHDELETE)
ReadOnlyModel一覧取得・取得(GET)
docs

ModelViewSetはCRUDすべてのメソッドがサポートされているメソッドで、特別な理由がない限りは思考停止でModelViewSetを使用すればよいクラスとなっています。
ReadOnlyModelはデータの取得と一覧取得のみができるクラスで読み取り専用にしたい場合に利用します。

Viewの実装は特別な理由がなければこちらのviewsetsのどちらかのクラスを継承すればよさそうです。

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の驚異的なところです

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

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の一通りの実装は完了です。

次は動作確認を行っていきましょう。

動作確認

それではAPIが想定通り正しく動作するか確認を行っていきましょう。
Djangoに用意されているRest APIのテスト実行環境を使います。

DjangoのREST APIテスト実行環境を使用するにあたって必要なことはサーバーを起動して/api/のエンドポイントにアクセスするだけです。

それでは動作確認を始めましょう。

確認するエンドポイントの整理

確認するエンドポイントの整理をしておきましょう。
前回の記事でURL設計を作成しましたが、この動作確認ではそのエンドポイントの各挙動を確認していきます。
これらは後でテストケースとしても使用できます。

エンドポイントメソッド確認内容
/api/authen/jwt/createPOSTログイン後JWTトークンが返ってくる
/api/users/create/POST認証なしでユーザー新規作成ができる
/api/users/{pk}/GET, PATCHpkで指定したユーザーの情報が取得できることを確認する。pkで指定したユーザーのemailとパスワード更新ができてパスワードはハッシュ化されている。ただしpkは自分自身に限り、他人のpkは使用しても何もできないことも確認する。PATCHのみの使用を想定し、PUTの使用は想定しない
/api/users/profile/{pk}GET, PUT, PATCHpkで指定したユーザーのプロフィール情報の取得ができることを確認する。pkで指定したユーザーのプロフィール情報の更新ができることを確認する。ただしpkは自分自身に限り、他人のpkは使用しても何もできないことも確認する。
/api/profiles/CREATE, GETユーザーのプロフィールを作成できることを確認する。すでに自身のプロフィールが作られている場合は作成できないことを確認する。異性だけがフィルタリングされたプロフィール一覧が取得できることを確認する。自分のプロフィールがない状態では誰のプロフィールも取得できないことを確認する
/api/profiles/{pk}GETpkで指定したユーザーのプロフィールが取得できることを確認する。このエンドポイントでは更新(UPDATE)はできないことを確認する
/api/favorite/CREATE, GETいいねすなわちマッチングレコードが作成できることを確認する。approachingとapproachedが自分であるようにフィルタリングされたマッチングモデルのデータ一覧を取得できることを確認する。
/api/favorite/{pk}/GET, PATCHapprovedをtrue, falseに変更することができることを確認する。PATCHの利用を想定しUPDATEは想定しない
/api/dm-message/CREATE, GETapproavedがTrueでかつapproach"ed"が自分であるマッチングデータが存在する場合にDMデータが作成できることを確認する。senderが自分自身のDMだけが一覧で取得されることを確認する。
/api/dm-message/{pk}/GET, UPDATEpkで指定したDMを取得できる。DMのメッセージを変更できる。PATCHの利用を想定しUPDATEは想定しない。
/api/dm-inbox/GETreceiverが自分のDM一覧が取得できることを確認する。
/api/dm-inbox/{pk}/GETpkで指定した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........................................."
}

念のため管理画面でユーザーが作成できているかを確認しましょう。
作成したユーザーがあればよいです

そして本アプリの仕様として作成したユーザーのis_activeはデフォルトでFalseとしていたので、管理画面上で今作ったユーザーを使えるようにアクティベーションしておきます
is_activeフィールドを見つけてTrueにチェックを入れておいてください

ログイン認証処理と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のテスト実行環境を用意してくれるソフトウェアを利用します。

いきなりで申し訳ないですが、まずは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トークンを貼り付けてトークンを使用するチェックを付けます。
「Authorization: JWTeyJ......」

これでトークンがリクエストヘッダーにくっついてくるようになりました
では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に関してはポストマンで動作確認しましょう。

emailとユーザー名のPATCH変更

まずはemailの変更ができるかを試します
ポストマンで先ほどのように必要な項目を埋めていきます
メソッド:PATCH
URL:http://127.0.0.1:8000/api/users/{pk}
body>form-data: email, username の各値
を正しく入力してSendを押します
すると更新後に再度GETが実行されてユーザー情報が更新されたjsonデータが返ってきます

管理画面でも更新されていることが確認しておいてください

パスワードの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のユーザー作成の方法よりも管理画面からユーザーを作成する方が便利です。

ユーザーが作成できたら実行しましょう

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
}

ユーザー情報更新の確認(UPDATE)

更新ができるかも確認しましょう。
/api/users/profile/{pk}/でアクセスしている画面の下にフォームがあるので、そちらを変更してPUTを押して想定通りに変更されているかを確認します。
上記と同様に変更後のデータがJSONで返ってくるので変更した内容に書き換わっているかを確認できれば成功です。

ログイン中のプロフィール新規作成と異性プロフィール一覧取得

確認エンドポイント: /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について

今回の決済機能の実装に当たってはStripeを利用します。

Stripeとはクレジット決済機能代行サービスです。
クレジットのみならず Apple Pay や Google Play の電子マネーの決済にもデフォルトで対応しており、豊富なドキュメントと実績があり、料金は決済時の手数料の3%のみというわかりやすさで、アプリケーション開発における決済代行機能プラットフォームのデファクトスタンダードといってもよいでしょう。

決済機能の自作について

決済に関しては各種法令・業務要件・仕様が非常にシビアです。
素直に決済代行サービスを利用することをオススメします

なお、今回マッチングアプリの実装で決済機能を導入する最大の目的は年齢確認のためですので、機能としては最もシンプルな実装にとどめたいと思います

アーキテクチャ設計

決済機能にまつわるアーキテクチャー部分について簡単に整理しておきます

新規ユーザークレジット決済アクティベーションフロー
--- title: 新規ユーザークレジット決済アクティベーションフロー --- stateDiagram-v2 NewUser: 新規ユーザー NewUser --> NewUserCreateScreen: Signup with email and passward NewUserCreateScreen: 新規ユーザー作成画面 note right of NewUserCreateScreen User is_active=False. end note NewUserCreateScreen --> DRF: 仮ユーザー作成 DRF: Django Rest API DRF --> TempUser: Email with Token and URL TempUser: 仮ユーザー TempUser --> StripeScreen: 案内URLクリック StripeScreen: Stripe画面 state is_success <<choice>> StripeScreen --> is_success: 番号入力 is_success --> TempUser: failed is_success --> DRF2 : success DRF2: Django Rest API note right of DRF2 /api/users/{pk}/activate/ User is_active=True. end note DRF2 --> ActivatedUser: Email notify about success ActivatedUser: ユーザー

Emailに記載されたURLからStripe画面に遷移してクレジット決済が完了すればユーザーが本登録される仕組みとなっています。

REST API 設計
エンドポイントメソッド機能
/api/users/{str:token_id}/payment/GETStripeが提供するクレジット決済決済画面にリダイレクトして、決済が成功した場合はユーザーアクティべーションのエンドポイントへリダイレクトし、キャンセルされた場合はキャンセルのエンドポイントにリダイレクトする。
有効でないToken_idを指定したり、ユーザー新規作成からXX日以内にアクセスを行わない場合はStripeへのリダイレクトを行わず、決済画面へリダイレクトを行わない旨のメッセージを返す。
/api/users/payment/cancel/GETStripeクレジット決済画面でキャンセルさせた場合にリダイレクトされてキャンセルされた旨のメッセージをレスポンスする。
/api/users/{str:activate_token}/activation/GETStripeクレジット決済画面で決済が成功した後に呼び出されてユーザーのis_activeをtrueにすることでアクティベーションする。
なおAPIとして公開されているためactivate_tokenは秘匿しておく必要がある。

環境構築

各種機能を使用するための設定や環境変数の設定を行う

まずはStripeを利用するための設定を行いましょう。
続いてEmailの機能を利用する設定を行いましょう。
Email機能はDjangoがデフォルトで用意しているものを使います。

Stripeアカウント登録とStripeのインストール

Stripeアカウントを持っていない方はアカウントを作成してください

手順

  1. メール認証も済ませてアカウントをアクティベーションしてください
  2. アカウントを作成するとダッシュボードが表示されていると思います
  3. デフォルトでテスト環境になっていることを確認します

なお本番利用する場合はアプリサービスごとに都度申請が必要です。
うっかり課金が起こってしまうことがないので初心者には心強いです。

さて、アカウントを作成したらStripeパッケージをインストールします

pip install stripe

次にDjangoでEmailとStripeを利用するための設定をsettting.pyを編集することで行います。

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の生パスワードか二段階認証を有効化した時に使えるアプリパスワードを設定します。

.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

私は横着して生パスワードで一度試しましたが、googleアカウントのセキュリティで弾かれたので、弾かれた場合はGoogleアカウントの設定(Chromeブラウザの設定ではないことに注意)>セキュリティ>安全性の低いアプリのアクセスを「オン」にすれば使用できるようなります。

決済機能の設定

次に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に関してはドキュメントの販売商品の定義のところで自作した商品のコードを入力します。
以下のようにテスト商品を作成することができるのでPRICE_IDに入った文字列をSTRIPE_ITEM_PRICEに格納します。

Stripe Item

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の認証情報を入力させた後に本登録を完了させる実装が一般的かと思います。

実装の具体的な方法はいろいろあるかと思いますが、今回は有効期限付きのトークンを発行して有効なトークンを持っているユーザーが期限内に決済を完了させた場合にアクティベーションをする実装とします。

トークンモデルの実装

トークンを格納するためのモデルを作成します。

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()

token_idは決済画面へリダイレクトするために用いてactivate_tokenは決済を通過した後のユーザーのアクティベーションのために用います。
トークンとして用いるのは議論の余地がありそうですがトークンにはどちらもUUIDを使用しています
expired_atは有効期限です。

ここで UserActivateTokens のモデルはユーザーの時のようにマネージャークラスを使っているのでマネージャークラスを実装します。

トークン検証機能の実装

トークンとユーザーのデータを検証してアクティベーションをする機能の実装します。

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

マネージャーの中のactivate_user_by_tokenの処理がまさしくユーザーのアクティベーションを行っている処理です。
user.is_active = Trueをしている箇所からわかると思います。

また前処理としてアクティブにするユーザーは 正しいactivate_tokenと、かつ、有効期限が切れていないデータを持っているユーザーだけにフィルタリングしています。

つまりactivate_token(※実際に渡してるのはtoken_id)をユーザーを新規作成した本人にしか分からない渡し方、つまりemailで渡すと、トークンはその人しか持っていないはずなので、ここでアクティベーションされるユーザーは、ユーザーを新規作成した本人のアカウントであるという認証を行うことができる仕掛けとなっています。

トークン発行機能の実装

トークンを発行する処理を実装します

さて、ユーザーを作成した時に同時にトークンを発行して、そのトークンをemailに乗せて送信出来たら便利ですが、Djangoにはちゃんとそのための機能が備わっています。

シグナルズ(django.db.models.signals)といいます。

シグナルズとは何らかの処理がプログラム上で起こったときに、事前に設定した条件で暗黙的にメソッドを自動実行してくれる機能です。
また、記述量が減るのでコードの見通しが良くなります。

例えば、ユーザーが新規作成された場合に、自動的にトークンを発行してメール送信するといったような機能に利用できます。

シグナルズの使い方ですが、アノテーションを利用することで使えます

例えば、@receiver(post_save, sender)です。

こちらはsenderに定義したモデルが保存の処理を行うたびに、アノテーション直下のメソッドが自動的に呼び出されるものになっています。

つまり今回の実装ではユーザーが新規作成されたりフィールドが更新されて保存されたタイミングでメソッドが自動的に呼び出されています。

このようなものがシグナルズです。

前置きはこのあたりにして、シグナルズを活用して実装したトークン発行機能のコードは以下となります。

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を送信する処理   

userにはシグナルズでuser.save()メソッドを呼んだユーザー、つまり新規作成されたユーザーのインスタンスが入ってきます。

expired_atの有効期限は、現在時刻にsettings.ACTIVATION_EXPIRED_DAYSで定義した3日を足した時刻を格納しています。

アクティベーションのメソッドを呼び出すのはこの日時までだよという意味にあたるのがexpired_atになります。

また、expired_atの日付の格納に関してもAwareやNaiveなどのややこしい話があって、本実装ではDjangoのコンソールに警告文が出てしまうのですが、今回は気にしないことにします。

Email送信機能の実装

トークンをEmailで送信する処理の実装をします

Emailで送信する処理ですが、Djangoはsend_mail()メソッドを呼ぶだけでメールを送ることができます。

引数の意味はぱっと見でわかると思いますので以下実装で示します。

上のpublish_activate_token()メソッドの続きにEmail処理を記述して以下のようにします。

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)  

メール本文に {settings.MY_URL}/api/users/{user_activate_token.token_id}/payment/ を記述しており、これをクリックするとStripeの決済画面に遷移する動きになっています。

また、post_saveはマネージャークラスでis_activeをtrueにするときにも反応するのでアクティベーション完了のメールも送信する設定にしました。

ルーティング実装

ルーティングURLの実装をします

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をもしusers/{str:token_id}/activation/のようにアクティベーションのURIに使ったとしたら、ユーザーがURLのエンドポイントの/activation/を推測できると決済を通さずにアクティベーションできてしまうというリスクがあるからでした。

なので、token_idを決済画面のユーザー識別に使って、activate_tokenをアクティベーションのトークンとして使い分ける実装が必要です。

Stripe決済画面呼び出しメソッドを以下のように修正します。

これでトークンが流出するか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)

決済がキャンセルされたときの処理の実装

キャンセルされたときの処理はメッセージを返すだけです。

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)

動作確認

さ、て簡単に動作を確認していきます。

DBの変更を伴っているのでマイグレーションを行ってから起動しましょう

ユーザー作成時のトークン生成と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」を利用して操作を進めます。

チェックがかかり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.

手順

  1. ダッシュボードにアクセスし、プロフィール>アカウントのところで「名称未設定」のアカウントと表示されていた場合、アカウント名を登録する。
  2. 登録方法は 設定(歯車アイコン)>アカウントの詳細>アカウント名 で好きなアカウント名を入力し保存を押す

不正アクティベーション実行の確認

決済を通さないで不正にアクティベーションの実行を試みた場合に失敗するかを確認します。

存在しないもしくは適当に自身で生成したuuidをactivate-token-uuidの部分に代入してアクティベーションを試みます。

http://127.0.0.1:8000/api/users/[発行されていないactivate-token-uuid]/activation/

以下のエラーが返ってくるかを確認する。

{
    "message": "エラーが発生しました"
}

失敗のメッセージが返ってきて、どのユーザーも勝手にアクティベーションされていないことを確認しておきます。

これで、動作確認は終了しました。
お疲れ様です。

以上でバックエンドの構築は完了です。

ダイキっち
ダイキっち

バックエンドの構築お疲れ様です。次はフロントエンドについての解説です。

【フロントエンド】Flutter編

次に、フロントエンドの開発を行っていきます。

環境構築

本実装では、Flutterバージョン2を使用しています。
動作を合わせたい場合はこちらのバージョンに合わせて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
 ┗他......

サンプルプログラムの実行を忘れずに行って動作確認をしておきましょう。

以下のコマンドで実行することができます。

flutter run

なお、デバイスをインストールしていない方は仮想デバイスADVをインストールしましょう。

状態管理パッケージの導入

本アプリケーションでは状態管理パッケージのProviderを導入します。

pubspec.yamlファイルにproviderを追記します

dependencies:
  flutter:
    sdk: flutter
  provider: ^6.0.2(最新バージョンを指定)

なお、yamlファイルはインデントが大事なので注意してください。

その後、パッケージをインストールします。

flutter pub dev

ログイン機能の実装

最初にログイン機能を実装していきましょう。
ログイン画面にアクセスするためのメニュー画面も作っておきましょう。

ログインをするためには正しい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: [最新版]

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にはバリデータをつけています。

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やパスワード情報を保持したり認証処理を行ったりする役割を担っています。

providers/login_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: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;
  }

}

コード解説

togglePasswordVisibleのメソッドは例えば画面上でパスワードの表示非表示を切り替えるためのメソッドとなります。

またauthのメソッドはいわゆるログインのメソッドとなっております。

ログインの処理の流れとしてはまずはDioを呼び出してDioを使用する準備を整え、ログイン後に取得できるtokenをCookieに格納するための下準備をCookieJarなどを使って準備を行っています。

また、スマートフォンはストレージのアクセス権などが厳しいため、明示的にCookieの情報をストレージに書き込めることをpath_provider.dartのパッケージで明示しています。
※明示しなければストレージは読み取り専用です

そして、saveFromResponseでクッキーを保存しています

これで認証情報を取得する処理は出来ました。

認証情報を利用してAPIを呼び出す場合はヘッダーに 'Authorization': を付与します。

正しい認証情報を持っていればアクセスができて、そうでなければエラーが返ってきます。

なお、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 を使用することでクッキーの認証情報をストレージに格納して永続的に利用できるようになります

ログインの動作確認

Djangoを起動しつつ、Flutterを起動しましょう。

コンソールで以下のコマンドを打つと起動できます。

flutter run

エミュレータが表示されたら、ログインができることを確認してください。

なお、下記で実装予定のMyProfile等を実装していないことによってエラーが出る場合は、エラー発生部分を適宜コメントアウトすることで対応してください。

サインアップの動作確認

ログインと同様、サインアップもに利用できることを確認しておきましょう

サインアップは、バックエンド開発の時に行ったように、ユーザーを新規作成するとEmailかバックエンド側コンソールにアクティベーション用のURLが表示されるので、スマートフォンエミュレータのブラウザを開いて、そのURLにアクセスしましょう。

今回は開発環境のためURLのホストは12.7.0.0.1の部分を10.0.2.2に書き換えてアクセスします。

Stripe画面が呼び出されてクレジット決済が完了したら、ユーザーがアクティベーションされます

その後同じユーザーでログインすることが出来れば大丈夫です。

なお、今回は決済完了後の案内が適当なため、実際にアプリを運用する場合は導線を整理する必要があるでしょう。

自己プロフィール画面の実装

さて、ログインが完了すると最初は自己プロフィール画面に遷移させるようにしたので、自己プロフィール画面を実装していきます。

自己プロフィール画面の実装

まずは、自己プロフィール画面の実装を行います。
以下が画面のコード全体です。

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(),
    );
  }
}

スナックバーなどを外出ししています

共通部品のコードは以下です

共通部品

スナックバー・ボトムナブバー・ドロワー・ピッカー・ラジオボタンを共通部品化しています

スナックバー

ボタンを押すなどのユーザーアクション後にメッセージが表示されてくるコンポーネントです。
トーストともいわれます。

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');
                    });
                }
              ),
            ],
          );
        }
      )
    );
  }
}

ピッカー

実装したピッカーの一部を記載します。
他のピッカーに関しては下記の例を参考に作成してください。

pickers/graduation_picker.dart

final Map<String, String> graduationPicker = {
  '' : '',
  'junior_high_school' : '中卒',
  'high_school' : '高卒',
  'trade_school' : '短大・専門学校卒',
  'university' : '大卒',
  'grad_school' : '大学院卒',
};

pickers/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),
                                ),
                              )
                            ],
                          ),
                        );
                      }
                  );
                },
              ),
            ],
          );
        }
    );
  }
}

ラジオボタン

続いてはラジオボタンの実装ですが、ラジオボタンもピッカーと同じような実装になります。

ただし、ピッカーと異なり、別建てでWidgetは作りません。

my_profile_screen.dart上で、ラジオボタンを実装しています。

表示させるデータだけをpickerファイルに分離させて実装しています。

pickers/sex_picker.dart

final Map<String, String> sexPicker = {
  'male' : '男性',
  'female' : '女性',
};

環境変数の導入

開発環境や本番環境の分離やシークレット情報の保持などのために使用する環境変数を設定します。

flutter_dotenvという名前の環境変数用のパッケージをインストールして、インストールサイトのマニュアルに記載された設定を行っていきます。

設定が完了したら、.env.devと.env.prodをプロジェクト直下に作成して、pubspec.ymlの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/

main.dartに環境変数を読み込む記述を行います

main.dart

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);
      ...
  }
}

ProfileProviderの実装

次に、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を使用するのがミソです。

画像のアップロード方法について

本アプリの実装ではpickTopImageのメソッドでproviderにセットし、プロフィール作成時もしくは更新時にアップロードするようになっています。

現段階の実装ではほかの画面に遷移したときに画像はクリアされませんが、実運用では画像のアップロードを取りやめたい場合の画像クリア機能をつける必要があります。

画像アップロードの詳細な実装方法につきましては、Qiitaで解説記事を記載していますので参考までにご確認ください。

動作確認

新規アカウントを作成したり、既存のアカウントを使用して、ログインします。

プロフィール作成や更新を行って、作成や更新が出来たらスナップバーが出現することを確認してください。

また、作成・更新されたデータがDBにも反映されていることを、Djangoの管理画面などで確認してください。

プロフィール一覧閲覧機能&いいね機能の実装

次に異性を閲覧できるプロフィール一覧機能といいねを送信できる機能を作っていきます。

プロフィール全件取得コードの作成

まずはProfileProviderにProfileデータを取り扱うための処理を記載していきます。

まずは画面共有するための変数を定義します。

ProfileProvider.dart

/// プロフィール機能
  List<ProfileModel> profileList = [];
  List<ProfileModel> profileApproachingList = [];
  List<ProfileModel> profileApproachedList = [];
  List<ProfileModel> profileMatchingList = [];
  ProfileModel? profileDetail;

そしてバックエンドから全件データを取得する処理をまずは記載します。

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();
  }

Provider内のプライベートメソッドにします。

バックエンドで実装したAPIをHTTPクライアントのDIOで呼び出しています。

またmy_profileの時と同様に、HTTPクライアントで取得したデータは型がDynamicになってしまうためProfile型に戻す処理をつける必要があります。
これらはこの後も他のメソッドで頻繁に共通利用されるため_inputMessageModel、_inputMessageModelListのメソッドとして処理を外だししています。

マッチングデータモデルの作成

次にプロフィール一覧画面ではすでにいいねを送ったユーザーやいいねをもらったユーザーやマッチングしているユーザーは表示させない仕様とするため、いいね機能であるマッチングモデルの処理をここで追加していきます。

ProfileProvider.dart

  /// マッチング機能
  MatchingModel? matching;
  List<MatchingModel> _matchingList = [];
  List<String> _matchingUserIdList = [];
  List<MatchingModel> _approachingList = [];
  List<String> _approachingUserIdList = [];
  List<MatchingModel> _approachedList = [];
  List<String> _approachedUserIdList = [];

_matchingList はマッチング中のMatchingModelデータがリストで格納され、_approachingList はいいねを送ったユーザー、_approachedList はいいねをくれたユーザーを格納します。

またフィルタリングの処理の都合上、_matchingUserIdList のように相手ユーザーのIDだけをリスト化したものも作成しておきます。

そして、バックエンドから取得したマッチングに関する全件データをローカルでそれぞれのデータに振り分けていく処理を実装して、上記で作成した変数に振り分けたデータを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を使って実装していきます。

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;
  }

なお、そのままでも動きますが、_isSuccessの部分の実装は例外処理を含むため、余力のある方はtry catchで囲む修正をしておきましょう。

いいねを送ったユーザーのフィルタリング

上記で格納した _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化

共通で利用する画面は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);

いいねをもらったユーザーのプロフィール一覧画面

同様にプロフィール一覧画面とは別にいいねをもらったユーザーのプロフィール一覧画面を作成します。

差分はこちらです。

screens/ApproachingScreen.dart

return ProfileListWidget(profiles: profileProvider.profileApproachedList);

マッチング中のユーザーのプロフィール一覧画面

同様にプロフィール一覧画面とは別にマッチング中のユーザーのプロフィール一覧画面を作成します。

差分はこちらです。

screens/ApproachingScreen.dart

return ProfileListWidget(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(),
    );
  }
}

いいね送信機能およびマッチング機能の実装

さて詳細画面ができたので、詳細画面からいいねを押すことができる機能を作成していきましょう。

また、いいね送信機能を実装するとマッチング機能も実現できるため同じく解説します。

実装コード

コードの全体は以下です。

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
      },
    );
  }

sendFavoriteがいいねを送るロジックを記載したコードであり、相手とのいいねの状態によって処理が異なります。

check〇〇Faoriteはコメントアウト通りの処理になっており条件判定に使います。

マッチング: 既にもらっているいいねに対する承認ロジックについて

マッチングに関するロジックが複雑なため補足で説明します。

条件分岐ごとの処理はコメントアウトに記載されている通りですが、すでにいいねをもらっている人にいいねを送る処理というのは、言い換えるといいねを承認していることと同じです。

つまり、相手からすでにいいねが来ているということは、相互でいいねを送り合うので、マッチング成立ととらえます。

こちらからはapprovedを承認済にしたいいね(マッチングモデルデータ)を送信します

そして同時に相手からすでにもらっているいいねデータのapprovedを承認済みに変更します。

これで両者のマッチングデータのapprovedが承認済みとなりました。

approvedはメッセージ送信を許可するかどうかを判定するためのフィールドであるためこれで両者はメッセージを送り合うことが可能になります。

なお、相手側がいいねを送ってきたデータを更新する必要があるため、_patchApprovedのメソッドを作成しています。

なお、Djangoの記事でバックエンドのPatchを拒否する設定になってたかもしれないので、そうなっていた場合は当該コードをコメントアウトするなどして修正してください

これにて、いいね機能およびマッチング機能を実装することができました

メッセージ機能の実装

最後にメッセージ機能を実装します

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で実装していきます。

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();
  }

特定のユーザーとのメッセージだけにフィルタリングするロジック

特定のユーザーとのメッセージだけにフィルタリングする実装を行います。

こちらはマッチング成立画面からメッセージ画面に移るときに呼び出される処理です。

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化したメッセージ一覧画面コンポーネントの実装を行います。

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'),
            );
          },
        );
      }
    );
  }
}

これで、マッチングアプリに必要な機能をすべて実装することができました

動作確認

動作確認を行い想定通りに動くかどうかを確認しましょう。

動作確認シナリオとしては、以下のものを実行すると良いでしょう。

  1. TOP画面にアクセスする
  2. 新規ユーザーを作成する
  3. 仮ユーザーでログインを試みて失敗することを確認する
  4. Emailに記載のURLからクレジット決済を行いユーザーが本登録される
  5. ユーザーでログインを行う
  6. マイプロフィール画面から自身のプロフィールを編集する
  7. ユーザー一覧で異性のユーザーが一覧で見られることを確認する
  8. ユーザーを選択し、詳細画面で相手のプロフィールを確認する
  9. いいねを送信する。
  10. いいねを送信したユーザーがいいね送信画面に移動していることを確認する
  11. いいねを送られたユーザーで再度ログインしなおし、いいねをもらった画面にそのユーザーが表示されていることを確認する
  12. そのユーザーにいいねを送り返す
  13. マッチングが成立することを確認する
  14. マッチング画面に移動していることを確認する
  15. メッセージ画面に遷移し、メッセージを送信できることを確認する

これらのシナリオがクリアできた場合、動作確認はばっちりです。

お疲れさまでした。

ダイキっち
ダイキっち

お疲れ様です。ここまで来たあなたはマッチングアプリを作れるようになりました。最強のマッチングアプリを世の中に生み出しましょう。

おわりに

お疲れ様です。

これであなたはマッチングアプリのコア機能を作れるようになりました。

後はあなたの思いつく最強の機能を盛り込んで史上最高のマッチングアプリをこの世界に生み出しましょう!!

以上、この記事が気に入っていただけたらブックマークや拡散のほどをお願いします。

また、微力ながら支援してくださるという方もいらっしゃれば、以下のボタンから支援可能ですので、支援いただけると非常に嬉しいです。

コメント

タイトルとURLをコピーしました