アプリ開発ナレッジ

アプリ開発のナレッジを掲載します

マッチングアプリ個人開発【Flutter画面編】

はじめに

個人開発のスキルアップの教材としてマッチングアプリは非常に良いお手本かと思うので、マッチングアプリとして最低限必要な機能を備えたMVPを作りました
マッチングアプリ業界はすでにレッドオーシャンのように見えますが、マッチングアプリを使用するユーザーの真の目的は理想のパートナーを見つけることにありますが、実際のマッチングアプリ業界に身を投じたユーザーの阿鼻叫喚具合を観察していると目的を達成しているとはいいがたく、筆者視点ではまだまだ開拓の余地がある領域だと思っています
これらの課題はマッチングアプリとしての機能面では解決できないことがもはや明確であるためどのように運用するかが勝敗を決めると思います
ほぼ供給者のアイデア勝負の領域になっていると思われますし、昨今の技術革新でアプリは誰でも簡単に作ることができるようになっています
理想のパートナーを見つけるというのは人類の根源的な欲求の一つでもあり、この崇高な理想を叶えようとする人が技術面の障壁で躓かないように、誰でもマッチングアプリを作れる記事を今回作成しました

想定する読者としてはプログラミングの知識はある程度持っていてアプリ開発については概要レベルしか知識がない人です
いつか自力で何かしらアプリを作りたいけど具体的には何作ろうかなと思っている感じの人には特に参考になるのではないのかなと思っています

こちらの記事はフロントエンド編です
Flutterを使用してモバイルにもWebにも対応したアプリを作ってみようと思います
また、バックエンドには定番のFirebaseを利用せずにDjangoを利用しているのでFirebaseを利用しない場合にFlutterの実装をどうしようか悩んでいる人の参考になれば幸いです

画面の実装をこれから行っていきますのでフルスタックな技術を身に着けたい人はバックエンドの記事も参照してください
前回までのバックエンドの実装記事はこちらです
1.
マッチングアプリを個人開発する~Djangoバックエンドその1 環境構築~ - アプリ開発ナレッジ

2.
マッチングアプリを個人開発する~Djangoバックエンドその2 モデル作成~ - アプリ開発ナレッジ

3.
マッチングアプリを個人開発する~Djangoバックエンドその3 REST API作成~ - アプリ開発ナレッジ

4.
マッチングアプリを個人開発する~Djangoバックエンドその4 動作確認~ - アプリ開発ナレッジ

5,
マッチングアプリを個人開発する~Djangoバックエンドその5 決済機能実装~ - アプリ開発ナレッジ


また、実装済のコードはこちらです
実装しながら記事を書いていたので、ところどころ食い違いがあります
記事どおりに進めて詰まった時はこちらのリポジトリのコードを参照してください
GitHub - orekyo/matchingapp-oreyome-pub: 公開用リポジトリ


では初めて行きましょう

目次

Flutterとは

Google製のクロスプラットフォーム対応のアプリケーション開発フレームワークです
もう少しかみ砕いていうと1つのコードでiOSアプリとAndroidアプリとWebアプリすべてのアプリを実装することができて最近人気の技術です

Flutter環境構築

Flutterを使用していくので環境を構築してください
ここでは Windows を使用します

フラッターが導入できている前提で話を進めます
Flutterの環境構築に関してはYoutubeなどで構築手順を解説してくれているものがアップロードいたりするのでそちらを参考にするとよいかと思います

開発環境

Flutter プロジェクトを作成する

プロジェクトを新規作成する

プロジェクトを新規作成してください
このブログではmatchingappwebというプロジェクト名にしました

プロジェクト構成のガワを作成する

まずは以下のように空フォルダ・ファイルを作成しましょう

matchingappmobile
┗lib
 ┗models
  ┗message_model.dart
  ┗matching_model.dart
  ┗profile_model.dart
  ┗user_model.dart
 ┗providers
  ┗login_provider.dart
  ┗message_provider.dart
  ┗profiles_provider.dart
 ┗screens
  ┗menu.dart
  ┗login.dart
  ┗signup.dart
  ┗他画面......
 ┗services
 ┗utils
 ┗widgets
┗test
 ┗providers
 ┗screens
 ┗services
 ┗widgets
 ┗他......

modelsフォルダのファイル構成は基本的にDjangoで実装したモデル構成に準じています
providersは状態管理のライブラリで、フロントエンド側で扱うためのデータを一元管理します
screensは画面フォルダです

Flutterでコードを実装していく

さあ軽くジャブを打っていきましょう
さて環境構築を終えて最初のプロジェクトを作成するとカウンターのサンプルプログラムがあるのですが、とりあえずサンプルを実行してみましょう
flutter runのコマンドを打つことで実行することができます
なお、デバイスをインストールしていない方は仮想デバイスADVをインストールしましょう

次に、コードを実行することが出来たら軽くコードを眺めてみましょう
Dart言語(Flutterで扱っている言語のこと=ここではDartとFlutterはほとんど同じ意味で使ってます)を初めて見た人はぎょっとするコードかもしれません
特にJavaScriptのコードを少しいじったことがある人は少し違和感を感じるかもしれません
違和感の正体は、HTMLがないところにあろうかと思います
そうです、このDartという言語はHTMLやCSSを使用しません
このDartという言語は処理と画面を一つの言語で記述することができます
初めてアプリ開発を行う人にとっては何が何だかと思う類の話ですが、初心者としては他の言語を学ぶ必要がないうえに、Flutterだけで通常のアプリで作りたいであろう機能はほとんどカバーされているし、さらに作ったアプリは公開できるプラットフォームの幅も格段に大きくて、応用範囲が広いという点で、Flutterはプログラミング初学者に最もおすすめできるアプリ開発フレームワークだと思われます
ただし、チュートリアルや日本語の記事がまだまだ少ないので、そこが難関ではあるのですが......
今のところはプログラミング初心者は、無料のプログラミング学習サイトでJavaScriptやHTML、CSSを一通り習得してからFlutterを始める方が理解が早いかと思います


さて、サンプルのコードは大変ありがたいのですが、今回はプロバイダーを使用して状態管理をするためこの形を書き替えていきます
Providerとは状態管理のFlutterのライブラリです

状態管理パッケージのProviderを使ってみる

静的 Stateless と 動的 Statefull について

そもそもアプリというものは基本的にはデフォルトでは静的なものであって、あれこれ工夫することによってはじめて動的な処理を施すことができます
ちなみに静的(Stateless)というのはユーザーの操作に対してアプリの挙動が変化しないもののことを差し、動的(Statefull )とはその逆です
具体的には静的アプリはWikiペディアや阿部寛のサイト(阿部寛のホームページ)のようなものを想像すればよいと思いますし、動的アプリは私たちが日常的に使っているサイト全般です(例:Amazon などのECサイト

FlutterアプリはStatefull Widgetを継承することで動的アプリを実装できるようにしておりこれが基本形なのですが、この実装ではアプリの実装が進んでいくにつれて保守性が下がるため、通常はProviderといったデータの状態管理用のライブラリを導入するのが鉄板となっています

Providerの導入

ではProviderを導入していきます
pub.dev

pubspec.yamlファイルにproviderを追記します
なお、yamlファイルはインデントが大事なので、インデントは以下のようになります

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

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

flutter pub dev
Providerの基本形

そして、pub dev サイトの Example のコードを参考にしながら以下のようなコードを書きます
これがプロバイダーの使用の基本形です
Exampleではプロバイダーの値の取り出しにcontext.read()を使用していますが、以下のコードではConsumerを利用しています
どちらがベストプラクティスかは不明ですが、Consumerを利用する方が画面の再描画を行うのがピンポイントで制御できるため処理速度向上の観点から優れているといわれています
分からない人は後でまた説明します

lib/main.dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

void main() {
  runApp(
      MultiProvider(
        providers: [
          ChangeNotifierProvider(create: (_) => UserProvider()),
        ],
        child: const MyApp(),
      ),
  );
}

class UserProvider with ChangeNotifier {
  String _nickname = '';

  String get nickname => _nickname; notifyListeners();

  void recommend() {
    _nickname = 'あなたの最高のお相手Aさんです';
    notifyListeners();
  }
}

class MyApp extends StatelessWidget  {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Menu(),
    );
  }
}

class Menu extends StatelessWidget {
  const Menu({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('メニューページ'),
      ),
      body: Center(
        child: Consumer<UserProvider>(
          builder: (context, provider, _) {
            return Column(
              mainAxisAlignment: MainAxisAlignment.center,
              mainAxisSize: MainAxisSize.min,
              children: <Widget>[
                Text('さあ最高のマッチングアプリを始めましょう!'),
                Text(provider.nickname),
                ElevatedButton(onPressed: () => provider.recommend(), child: Text('あなたにふさわしいお相手を表示します'))
              ],
            );
          },
        ),
      ),
    );
  }
}

この実装で動的処理(状態管理)を実装することができました

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

いい感じですね
今はボタンを押してもプログラミング内部で定義したデータを表示しているだけですが、次はバックエンドからとってきたデータを表示させていってみましょう

この処理が何を行っているかについて

さてバックエンドからデータをとってくるその前に、main.dartの中身、特にProviderの説明を軽くしておきます

まずそもそもアプリが起動するということが何なのかということを考えたいのですが、Flutterで定義されたアプリはFlutter runとかでアプリが実行されると一番最初にmain()が実行されます
↓こいつですね

void main() {
}

このmain()の中でrunApp()が呼び出され、最初にMyAppが呼ばれているという構図です
MyApp はMenuを呼んでおり、メニューページが表示されます
そのため基本的には画面を見ている限りはclass Menu extends StatelessWidget {}の中身を眺めておけばよいことになります
さて、アップバーなどのウィジェットは解説しなくてもなんとなくわかると思います
難しいのはConsumerやprovider.nicknameやElevatedButton(onPressed: () => provider.recommend(), child: Text('あなたにふさわしいお相手を表示します'))の部分だと思います
これらは何をしているかというと、(何となくわかると思いますが)ElevatedButtonを押すとprovider.nicknameの値を画面に表示しています

最初は空文字で定義されてボタンを押すと定義した文字列が表示されるコードはこちらに定義されています

class UserProvider with ChangeNotifier {
  String _nickname = ''; // 初期値

  String get nickname => _nickname; notifyListeners();

  void recommend() { // ボタンが押されたら値がセットされる
    _nickname = 'あなたの最高のお相手Aさんです';
    notifyListeners(); // 値の変更を画面に伝えるメソッドで、プロバイダーにはこれが必要。わからなければおまじない
  }
}

青色のボタンを押して→recommend() が実行され、→nicknameの値が変更されて、変更された値が画面に反映されている流れです
画面に反映する役割は、notifyListeners()とConsumerが担っています

Consumerの基本形

Consumerの基本形はこちらです

Consumer<UserProvider>( // 定義したProviderのクラス名
  builder: (context, provider, _) { // 第二引数のproviderに実際のインスタンスが入る
    return Widget( // WidgetにはColumnなどのお好きなWidgetが利用できる
        // providerを利用する処理がかける
    );
  },
),

アロー関数などが思いっきり使われているので大変理解しがたい形でしょうが、まあ慣れましょう
これでデータの状態管理ができるようになりました

では次にまずはログイン機能を実装してみましょう

ログイン機能の実装

ログインをするためには正しいemailとpasswordを渡しつつログイン認証用APIエンドポイントを呼び出して認証情報を受け取る必要があるので、HttpクライアントとしてはDioを利用して、認証情報はCookieに格納する方針で実装することとします
その他の情報はデータの状態管理モジュールのProviderを利用して実装していきます
まずはmain.dartなどをProviderを利用できる形に書き換えていきます

必要なパッケージインストール

Httpクライアントとして使用するDioCookieを管理するためのパッケージとそのほか必要なパッケージをインストールします

pubspec.yaml

dependencies:
  http: [最新版]
  dio: [最新版]
  cookie_jar: [最新版]
  dio_cookie_manager: [最新版]
  path_provider: [最新版]

main.dartのProviderに対応するための書き換え実装

main.dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

void main() {
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => LoginProvider()),
        ChangeNotifierProvider(create: (_) => ProfileProvider()),
      ],
      child: const MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget  {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: MenuScreen(),
      routes: <String, WidgetBuilder>{
        '/menu': (BuildContext context) => MenuScreen(),
        '/login': (BuildContext context) => LoginScreen(),
        '/signup': (BuildContext context) => SignupScreen(),
        '/my-profile': (BuildContext context) => MyProfileScreen(),
        '/looking': (BuildContext context) => ProfilesScreen(),
      },
    );
  }
}

メニュー画面の実装

ところでアプリのタイトルを決めていませんでしたが、ここでは俺の嫁探しにしています(ギャルゲみたいとか言わない)

screens/menu.dart

import 'package:flutter/material.dart';

class MenuScreen extends StatelessWidget {
  const MenuScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('俺の嫁探し'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            const Text('さあ最高のマッチングアプリを始めましょう!',),
            const SizedBox(height: 16,),
            TextButton(
              child: const Text('ログイン'),
              onPressed: () {
                Navigator.pushNamed(context, '/login');
              },
            ),
            TextButton(
              child: const Text('新規会員登録'),
              onPressed: () {
                Navigator.pushNamed(context, '/signup');
              },
            ),
          ],
        )
      ),
    );
  }
}

ログイン画面の実装

まずは画面のレイアウトを整えます
Consumerを利用してProviderを利用できるようにしています
またemailにはバリデータをつけています
email_validator | Dart Package


screens/login.dart

import 'package:email_validator/email_validator.dart';
import 'package:flutter/material.dart';

class LoginScreen extends StatelessWidget {
  const LoginScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('ログインページ'),
      ),
      body: Consumer2<LoginProvider, ProfileProvider>(
        builder: (context, loginProvider, profileProvider, _) {
          return Center(
            child: Padding(
              padding: const EdgeInsets.all(32.0),
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  TextFormField(
                    onChanged: (value) => loginProvider.email = value,
                    decoration: const InputDecoration(
                      labelText: 'email',
                    ),
                    maxLength: 50,
                  ),
                  Text(loginProvider.message, style: const TextStyle(color: Colors.red),),
                  TextFormField(
                    onChanged: (value) => loginProvider.password = value,
                    decoration: InputDecoration(
                      labelText: 'password',
                      suffixIcon: IconButton(
                          onPressed: () => loginProvider.togglePasswordVisible(),
                          icon: const Icon(Icons.remove_red_eye)
                      ),
                    ),
                    obscureText: loginProvider.hidePassword,
                    maxLength: 50,
                  ),
                  Padding(
                    padding: const EdgeInsets.only(top: 16.0),
                    child: ElevatedButton(
                      child: const Text('ログイン'),
                      style: ElevatedButton.styleFrom(
                        fixedSize: Size(MediaQuery.of(context).size.width * 0.95, 32),
                      ),
                      onPressed: () {
                        loginProvider.setMessage('');
                        if( !EmailValidator.validate(loginProvider.email) ) {
                          loginProvider.setMessage('Email形式で入力してください');
                          return ;
                        }
                        loginProvider.auth()
                          .then( (isSuccess) {
                            if (isSuccess) {
                              profileProvider.fetchMyProfile(loginProvider.getUserId())
                                .then((isSuccess) {
                                  if (isSuccess) {
                                    print('プロフィール作成済みユーザーです');
                                    Navigator.pushAndRemoveUntil(
                                      context,
                                      MaterialPageRoute(builder: (context) => const MyProfileScreen()),
                                          (route) => false,
                                    );
                                  }
                                  else {
                                    print('プロフィール未作成ユーザーです');
                                    Navigator.pushAndRemoveUntil(
                                      context,
                                      MaterialPageRoute(builder: (context) => const MyProfileScreen()),
                                          (route) => false,
                                    );
                                  }
                                });
                            }
                          })
                          .catchError((error) => print(error));
                      },
                    ),
                  )
                ],
              ),
            ),
          );
        }
      ),
    );
  }
}

新規ユーザー作成画面の実装

ログインとほぼ同じです
ボタンを呼び出す際に呼び出しているProviderのメソッドが違います

screens/signup.dart

class SignupScreen extends StatelessWidget {
  const SignupScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('サインアップページ'),
      ),
      body: Consumer<LoginProvider>(
          builder: (context, loginProvider, _) {
            return Center(
              child: Padding(
                padding: const EdgeInsets.all(32.0),
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    TextFormField(
                      onChanged: (value) => loginProvider.email = value,
                      decoration: const InputDecoration(
                        labelText: 'email',
                      ),
                      maxLength: 50,
                    ),
                    Text(loginProvider.message, style: const TextStyle(color: Colors.red),),
                    TextFormField(
                      onChanged: (value) => loginProvider.password = value,
                      decoration: InputDecoration(
                        labelText: 'password',
                        suffixIcon: IconButton(
                            onPressed: () => loginProvider.togglePasswordVisible(),
                            icon: const Icon(Icons.remove_red_eye)
                        ),
                      ),
                      obscureText: loginProvider.hidePassword,
                      maxLength: 50,
                    ),
                    Padding(
                      padding: const EdgeInsets.only(top: 16.0),
                      child: ElevatedButton(
                        child: const Text('新規ユーザー作成'),
                        style: ElevatedButton.styleFrom(
                          fixedSize: Size(MediaQuery.of(context).size.width * 0.95, 32),
                        ),
                        onPressed: () {
                          loginProvider.setMessage('');
                          if( !EmailValidator.validate(loginProvider.email) ) {
                            loginProvider.setMessage('Email形式で入力してください');
                            return ;
                          }
                          loginProvider.signup()
                              .then( (isSuccess) {
                            if (isSuccess) {
                              Navigator.pushAndRemoveUntil(
                                context,
                                MaterialPageRoute(builder: (context) => const LoginScreen()),
                                    (route) => false,
                              );
                            }
                          })
                          .catchError((error) => print(error));
                        },
                      ),
                    )
                  ],
                ),
              ),
            );
          }
      ),
    );
  }
}

LoginProviderの実装

ロジックの部分を実装します
プロバイダーはemailやパスワード情報を保持したり認証処理を行ったりする役割を担っています
togglePasswordVisibleのメソッドは例えば画面上でパスワードの表示非表示を切り替えるためのメソッドとなります
またauthのメソッドはいわゆるログインのメソッドとなっております
ログインの処理の流れとしてはまずはDioを呼び出してDioを使用する準備を整え、ログイン後に取得できるtokenをCookieに格納するための下準備をCookieJarなどを使って準備を行っています
またスマートフォンはストレージのアクセス権などが厳しく明示的にCookieの情報をストレージに書き込めることをpath_provider.dartのパッケージで明示しています(明示しなければストレージは読み取り専用)
そしてsaveFromResponseでクッキーを保存しています

これで認証情報を取得する処理は出来ました
認証情報を利用してAPIを呼び出す場合は直後の処理のようにヘッダーに 'Authorization': を付与します
正しい認証情報を持っていればアクセスができて、そうでなければエラーが返ってきます

また余談ですが、これはFlutter特有なのかわかりませんが、Flutterはエラーハンドリングはあまり細かく分けたりせずに大雑把にエラーをキャッチして一緒くたに処理するみたいですね
ネットに転がっている例とか見ていても細かくエラーハンドリングをしているケースをあまり見かけません
なのでこのアプリでもその慣習を踏襲して、エラーが起きたとしてもユーザーはエラーが起きたくらいのメッセージしか得られません
ユーザーフレンドリーじゃない気がしますが、Flutterの設計思想としてもしかしたらそもそも設計思想としてエラーを起こすやつは何やってもダメとかあるのかもしれないです
(Flutter開発歴が浅いので間違っていたらすみません)

providers/login_pro.dart

import 'dart:io';
import 'package:cookie_jar/cookie_jar.dart';
import 'package:dio/dio.dart';
import 'package:dio_cookie_manager/dio_cookie_manager.dart';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';


class LoginProvider with ChangeNotifier {
  bool _isSuccess = false;
  String message = '';

  String email = '';
  String password = '';
  bool hidePassword = true;
  final UserModel _userModel = UserModel();

  final Uri _uriHost = Uri.parse('http://10.0.2.2:8000'); // Mobile(エミュレータ)ではこちらのホストを利用する
  // final Uri _uriHost = Uri.parse('http://127.0.0.1:8000'); // Webではこちらのホストを利用する

  String getUserId() {
    return _userModel.id;
  }

  void setMessage(String msg) {
    message = msg;
    notifyListeners();
  }

  void togglePasswordVisible() {
    hidePassword = !hidePassword;
    notifyListeners();
  }

  Future<bool> auth() async {
    _isSuccess = false;
    message = '';

    try {
      Dio dio = Dio();
      dio.options.baseUrl = _uriHost.toString();
      dio.options.connectTimeout = 5000;
      dio.options.receiveTimeout = 3000;
      dio.options.contentType = 'application/json';

      List<Cookie> cookieList = [];

      Directory appDocDir = await getApplicationDocumentsDirectory();
      String appDocPath = appDocDir.path;
      PersistCookieJar cookieJar = PersistCookieJar(storage: FileStorage(appDocPath+"/.cookies/"));
      dio.interceptors.add(CookieManager(cookieJar));

      final responseJwt = await dio.post(
          '/authen/jwt/create',
          data: {
            'email': email,
            'password': password,
          }
      );
      cookieList = [ ...cookieList, Cookie('access_token', responseJwt.data['access']) ];
      cookieJar.saveFromResponse(_uriHost, cookieList);

      final responseUser = await dio.get(
        '/authen/users/me',
        options: Options(
          headers: {
            'Authorization': 'JWT ${cookieList.first.value}',
          },
        ),
      );
      _userModel.id = responseUser.data['id'];
      _userModel.email = responseUser.data['email'];

      _isSuccess = true;
    } catch(error) {
      message = '正しいEメールとパスワードを入力してください';
      print(error);
      _isSuccess = false;
    }
    notifyListeners();
    return _isSuccess;
  }

  Future<void> logout() async {
  }

  Future<bool> signup() async {
    _isSuccess = false;
    message = '';

    try {
      Dio dio = Dio();
      dio.options.baseUrl = _uriHost.toString();
      dio.options.contentType = 'application/json';

      final response = await dio.post(
          '/api/users/create/',
          data: {
            'email': email,
            'password': password,
            'username': '',
          }
      );
      _userModel.id = response.data['id'];
      message = '新規ユーザーの仮登録が成功しました。本登録にはユーザーのアクティベーションを行って下さい';
      _isSuccess = true;
    } catch(error) {
      message = '新規ユーザー登録処理が失敗しました。同じEmailは使用できません';
      print(error);
      _isSuccess = false;
    }
    notifyListeners();
    return _isSuccess;
  }

}

http://10.0.2.2:8000http://127.0.0.1:8000/エイリアス となっており今回FlutterエミュレータDjangoのバックエンド双方が127.0.0.1:8000のホストを占有しておりバッティングしているため、10.0.2.2:8000を指定することでエミュレータ側からバックエンドにアクセスすることができるようになります
後で環境変数化の解説があるので、その時にこちらのコードも環境変数化してください
CookieJar の PersistCookieJar を使用することでクッキーの認証情報をストレージに格納して永続的に利用できるようになります


これが実装できるとこんな画面でログインが可能となるはずです
MyProfileを実装してない場合は当該部分を適宜コメントアウトなどして動作確認してくださいな


※パスワードはログイン後に空文字を入れるなどして初期化したほうがいいですね



サインアップの動作確認

サインアップも同様に利用できることを確認しておきましょう
サインアップはバックエンド開発の時に行ったようにユーザーを新規作成するとEmailかバックエンド側コンソールにアクティベーション用のURLが表示されるので、スマートフォンエミュレータのブラウザを開いてそのURLにアクセスしましょう
今回は開発環境のためURLのホストは12.7.0.0.1の部分を10.0.2.2に書き換えてアクセスします
Stripe画面が呼び出されてクレジット決済が完了したらユーザーがアクティベーションされることが確認出来たらOKです

今回はStripeのリダイレクトをDjango Rest Frameworkで作成したViewで返されたデータをフロントエンドで適切に受け取って画面を表示させていないので、Djangoの開発者用の画面が表示されてしまっています
これはDjangoテンプレートを使って画面を作成する方がいいなとフロント実装しながら思いました
今の実装だとアカウント作成の導線がぐちゃっているので、アプリを本気で運用する場合はこの辺は修正したほうがよさそうですね


自己プロフィール

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

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

レイアウトはこちらになります

全体像

まずは全体コードを記載します
とてもコードが長いので細かい単位でスナックバーなどの共通部品は個別にウィジェットに分けています

screens/my_profile_screen.dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:matchingappweb/pickers/graduation_picker_widget.dart';
import 'package:matchingappweb/pickers/passion_picker_widget.dart';
import 'package:matchingappweb/providers/login_provider.dart';
import 'package:matchingappweb/providers/profile_provider.dart';
import 'package:provider/provider.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import '../pickers/location_picker_widget.dart';
import '../pickers/sex_picker.dart';
import '../utils/show_snack_bar.dart';
import '../widgets/bottom_nav_bar_widget.dart';
import '../widgets/drawer_widget.dart';



class MyProfileScreen extends StatelessWidget {
  const MyProfileScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final Uri _uriHost = Uri.parse(dotenv.get('BACKEND_URL_HOST'));

    return Scaffold(
      appBar: AppBar(
        title: const Text('マイプロフィール'),
      ),
      body: Consumer2<ProfileProvider, LoginProvider>(
        builder: (context, profileProvider, loginProvider, _) {
          return Padding(
              padding: const EdgeInsets.all(32.0),
              child: SingleChildScrollView(
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    Stack(
                      children: <Widget>[
                        profileProvider.uploadTopImage != null ? 
                          Image.file(profileProvider.uploadTopImage!)  :
                          profileProvider.myProfile.topImage != null ?
                            Image.network('${profileProvider.myProfile.topImage?.replaceFirst(dotenv.get('STORAGE_URL_HOST'), _uriHost.toString())}',
                              width: 100, fit: BoxFit.fill,
                              errorBuilder: (context, error, stackTrace) {
                                return Image.asset('images/nophotos.png',width: 100, fit: BoxFit.fill,);
                              },
                            ) :
                            Image.asset('images/nophotos.png', width: 100, fit: BoxFit.fill,),
                        profileProvider.myProfile.isKyc ?
                          const Icon(Icons.check_circle, color: Colors.greenAccent, size: 16,) :
                          const SizedBox(),
                      ],
                    ),
                    TextButton(
                      child: const Text('画像変更'),
                      onPressed: () => profileProvider.pickTopImage(),
                    ),
                    TextFormField(
                      onChanged: (value) => profileProvider.myProfile.nickname = value,
                      decoration: const InputDecoration(labelText: 'ニックネーム', hintText: 'このフィールドは必須です'),
                      maxLength: 50,
                      initialValue: profileProvider.myProfile.nickname,
                    ),
                    TextFormField(
                      keyboardType: TextInputType.number,
                      inputFormatters: [FilteringTextInputFormatter.digitsOnly],
                      onChanged: (value) => profileProvider.myProfile.age = int.parse(value),
                      decoration: const InputDecoration(labelText: '年齢', hintText: '18歳未満は登録出来ません',),
                      enabled: profileProvider.myProfile.user == null,
                      maxLength: 2,
                      initialValue: profileProvider.myProfile.age.toString(),
                    ),
                    for (String key in sexPicker.keys) ... {
                      RadioListTile(
                        value: key,
                        groupValue: profileProvider.myProfile.sex,
                        title: Text(sexPicker[key] ?? '性別不詳'),
                        selected: profileProvider.myProfile.sex == key,
                        onChanged: (value) {
                          if (profileProvider.myProfile.user == null) {
                            profileProvider.myProfile.sex = value.toString();
                            profileProvider.notifyListeners();
                          }
                          else {
                            null;
                          }
                        },
                      )
                    },
                    TextFormField(
                      keyboardType: TextInputType.number,
                      inputFormatters: [FilteringTextInputFormatter.digitsOnly],
                      onChanged: (value) => profileProvider.myProfile.height = int.parse(value),
                      decoration: const InputDecoration(labelText: '身長cm', hintText: '140cm以上200cm未満で入力してください'),
                      maxLength: 3,
                      initialValue: profileProvider.myProfile.height != null ? profileProvider.myProfile.height.toString() : '',
                    ),
                    const LocationPickerWidget(),
                    TextFormField(
                      onChanged: (value) => profileProvider.myProfile.work = value,
                      decoration: const InputDecoration(labelText: '仕事',),
                      maxLength: 20,
                      initialValue: profileProvider.myProfile.work,
                    ),
                    TextFormField(
                      keyboardType: TextInputType.number,
                      inputFormatters: [FilteringTextInputFormatter.digitsOnly],
                      onChanged: (value) => profileProvider.myProfile.revenue = int.parse(value),
                      decoration: const InputDecoration(labelText: '収入(万円)',),
                      maxLength: 4,
                      initialValue: profileProvider.myProfile.revenue.toString(),
                    ),
                    const GraduationPickerWidget(),
                    TextFormField(
                      onChanged: (value) => profileProvider.myProfile.hobby = value,
                      decoration: const InputDecoration(labelText: '趣味',),
                      maxLength: 20,
                      initialValue: profileProvider.myProfile.hobby,
                    ),
                    const PassionPickerWidget(),
                    TextFormField(
                      onChanged: (value) => profileProvider.myProfile.tweet = value,
                      decoration: const InputDecoration(labelText: 'つぶやき',),
                      maxLength: 10,
                      initialValue: profileProvider.myProfile.tweet,
                    ),
                    TextFormField(
                      keyboardType: TextInputType.multiline,
                      onChanged: (value) => profileProvider.myProfile.introduction = value,
                      decoration: const InputDecoration(labelText: '自己紹介',),
                      maxLength: 1000,
                      maxLines: null,
                      initialValue: profileProvider.myProfile.introduction,
                    ),
                    Padding(
                      padding: const EdgeInsets.only(top: 16.0),
                      child: ElevatedButton(
                        child: profileProvider.myProfile.user == null ? const Text('プロフィールを作成する') : const Text('プロフィールを更新する'),
                        style: ElevatedButton.styleFrom(
                          fixedSize: Size(MediaQuery.of(context).size.width * 0.95, 32),
                        ),
                        onPressed: () {
                          if (profileProvider.myProfile.user == null) {
                            profileProvider.createMyProfile(loginProvider.getUserId()).then((isSuccess) {
                              if (isSuccess) {
                                Navigator.pushReplacementNamed(context, '/my-profile');
                                showSnackBar(context, 'プロフィールが新規作成されました');
                              } else {
                                showSnackBar(context, 'エラーが発生しました');
                              }
                            });
                          }
                          else {
                            profileProvider.updateMyProfile(loginProvider.getUserId()).then((isSuccess) {
                              if (isSuccess) {
                                Navigator.pushReplacementNamed(context, '/my-profile');
                                showSnackBar(context, 'プロフィール更新が完了しました');
                              } else {
                                showSnackBar(context, 'エラーが発生しました');
                              }
                            });
                          }
                        },
                      ),
                    ),
                  ],
                ),
              ),
          );
        },
      ),
      drawer: const DrawerWidget(),
      bottomNavigationBar: const BottomNavBarWidget(),
    );
  }
}

共通部品のコード実装

スナックバー

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

utils/show_snack_bar.dart

import 'package:flutter/material.dart';

void showSnackBar(BuildContext context, String msg) {
  final snackBar = SnackBar(
    content: Text(msg),
    action: SnackBarAction(label: '閉じる', onPressed: () {}),
  );
  ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
ボトムナブバー

ボトムナビゲーションバーとか言ったり同義語がたくさんありますがアプリの下部にあるナビゲーションバーです
今回はただの飾りつけとしての実装しかしていません

widgets/bottom.dart

class BottomNavBarWidget extends StatelessWidget {
  const BottomNavBarWidget({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return BottomNavigationBar(
      items: [
        BottomNavigationBarItem(
          label: '相手探し',
          icon: IconButton(
            icon: const Icon(Icons.wc),
            onPressed: () {
              // Navigator.pushNamed(context, '/looking');
            },
          )
        ),
        BottomNavigationBarItem(
            label: 'いいねリスト',
            icon: IconButton(
              icon: const Icon(Icons.volunteer_activism),
              onPressed: () {
                // Navigator.pushNamed(context, '/chance');
              },
            )
        ),
        BottomNavigationBarItem(
            label: 'メッセージ',
            icon: IconButton(
              icon: const Icon(Icons.message),
              onPressed: () {
                // Navigator.pushNamed(context, '/message');
              },
            )
        ),
      ],
    );
  }
}
ドロワー

左右から出てくるアレですね
今回はこれで画面移動を行います

widgets/drawer.dart

class DrawerWidget extends StatelessWidget {
  const DrawerWidget({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Drawer(
      child: Consumer4<LoginProvider, ProfileProvider, MatchingProvider, MessageProvider>(
        builder: (context, loginProvider, profileProvider, matchingProvider, messageProvider, _) {
          return ListView(
            children: [
              const DrawerHeader(
                child: Text('ようこそ俺の嫁探しへ', style: TextStyle(color: Colors.white),),
                decoration: BoxDecoration(
                  color: Colors.blue,
                ),
              ),
              ListTile(
                title: const Text("相手を探す"),
                trailing: const Icon(Icons.arrow_forward),
                onTap: () async {
                  bool _isSuccess = await profileProvider.fetchProfileList();
                  if (_isSuccess) Navigator.pushNamed(context, '/looking');
                },
              ),
              ListTile(
                title: const Text("いいねした人リスト"),
                trailing: const Icon(Icons.arrow_forward),
                onTap: () async {
                  bool _isSuccess = await profileProvider.fetchProfileApproachingList();
                  if (_isSuccess) Navigator.pushNamed(context, '/favorite');
                },
              ),
              ListTile(
                title: const Text("いいねをもらった人リスト"),
                trailing: const Icon(Icons.arrow_forward),
                onTap: () async {
                  bool _isSuccess = await profileProvider.fetchProfileApproachedList();
                  if (_isSuccess) Navigator.pushNamed(context, '/chance');
                },
              ),
              ListTile(
                title: const Text("マッチング成立リスト"),
                trailing: const Icon(Icons.arrow_forward),
                onTap: () async {
                  bool _isSuccess = await profileProvider.fetchProfileMatchingList();
                  if (_isSuccess) Navigator.pushNamed(context, '/matching');
                },
              ),
              ListTile(
                title: const Text("メッセージ"),
                trailing: const Icon(Icons.arrow_forward),
                onTap: () {} // Navigator.pushNamed(context, '/message'),
              ),
              ListTile(
                title: const Text("自己プロフィール"),
                trailing: const Icon(Icons.arrow_forward),
                onTap: () {
                  profileProvider.fetchMyProfile(loginProvider.getUserId())
                    .then( (_) {
                       Navigator.pushNamed(context, '/my-profile');
                    });
                }
              ),
            ],
          );
        }
      )
    );
  }
}
環境変数の導入

開発環境や本番環境の分離やシークレット情報の保持などのために使用する環境変数を設定します
以下の環境変数用のパッケージをインストールしてマニュアルに記載の設定を行っていきます
flutter_dotenv | Flutter Package

.env.devと.env.prodをプロジェクト直下に作成してassetsに記載します

pabspec.yaml

dependencies:
  flutter_dotenv: ^5.0.2

flutter:
  assets:
   - .env.dev
   - .env.prod

flutter pub get コマンドを叩いてインストールします

その後、.envファイルに環境変数を記載します
.env.dev

BACKEND_URL_HOST=http://10.0.2.2:8000
STORAGE_URL_HOST=http://127.0.0.1:8000/

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);
      ...
  }
}
pickerの導入

詳しい手順はQiitaにまとめました
Provider利用時のFlutterのpicker(ドラムロール)の実装方法 - Qiita

例:graduation_picker.dart

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

例:graduation_picker_widget.dart

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:matchingappweb/pickers/graduation_picker.dart';
import 'package:provider/provider.dart';
import '../providers/profile_provider.dart';

class GraduationPickerWidget extends StatelessWidget {
  const GraduationPickerWidget({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Consumer<ProfileProvider>(
        builder: (context, profileProvider, _) {
          return Row(
            children: [
              const Text('最終学歴 ',),
              Text(graduationPicker[profileProvider.myProfile.graduation] ?? '', style: const TextStyle(fontWeight: FontWeight.bold)),
              TextButton(
                child: const Text('選択'),
                onPressed: () {
                  showModalBottomSheet(
                      context: context,
                      builder: (BuildContext context) {
                        return Container(
                          height: MediaQuery.of(context).size.height / 2,
                          child: Column(
                            children: [
                              Row(
                                crossAxisAlignment: CrossAxisAlignment.end,
                                children: [
                                  TextButton(
                                    child: const Text('戻る'),
                                    onPressed: () => Navigator.pop(context),
                                  ),
                                  TextButton(
                                    child: const Text('決定'),
                                    onPressed: () {
                                      profileProvider.notifyListeners();
                                      Navigator.pop(context);
                                    },
                                  ),
                                ],
                              ),
                              Container(
                                height: MediaQuery.of(context).size.height / 3,
                                child: CupertinoPicker(
                                  itemExtent: 40,
                                  children: [
                                    for (String key in graduationPicker.keys) ... {
                                      Text(graduationPicker[key] ?? '最終学歴不詳')
                                    },
                                  ],
                                  onSelectedItemChanged: (int index) => profileProvider.myProfile.graduation = graduationPicker.keys.elementAt(index),
                                ),
                              )
                            ],
                          ),
                        );
                      }
                  );
                },
              ),
            ],
          );
        }
    );
  }
}
ラジオボタンの実装

ラジオボタンもPickerと同じような実装になります
Picker_Widgetを作らずmy_profile_screen上でラジオボタンを実装しており、表示させるデータだけをpickerファイルに分離させています

例:sex_picker

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

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

}

CookieDioの使い方はLoginの機能を作成したときと同じです
ここではmy_profileという変数を用意して、自己プロフィールを新規作成するメソッドと取得するメソッドと更新するメソッドを作成しました
Profileモデルが大きいのでコード量が大きく見えていますが、基本的にはバックエンドで作成したAPIのエンドポイントを呼んでいるだけです
ただ、HTTPクライアントで取得したデータは型がDynamicになってしまうため、少し面倒ですがProfile型に戻す処理をつける必要があり、その処理が_inputProfileModelです
また画像のアップロードも行えるロジックを実装しました
画像などのデータを含むフォームデータを作成する場合はFormData型でFormData.fromMapを使用するとよいところがポイントです

画像のアップロード

詳細はQiitaに記述しました
Flutter×Django(DRF)で画像をアップロードする方法 - Qiita

この実装ではpickTopImageのメソッドでproviderにセットし、プロフィール作成時もしくは更新時にアップロードするようになっています
現段階の実装ではほかの画面に遷移したときに画像はクリアされませんが、実運用では画像のアップロードを取りやめたい場合の画像クリア機能をつける必要があります

動作確認

新規アカウントを作成したり、既存のアカウントを使用して、ログインします
そしてプロフィール作成や更新を行って、作成や更新が出来たらスナップバーが出現し作成・更新されたデータができていることをDjangoの管理画面などでも確認してください

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

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

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

まずはProfileProviderにProfileデータを取り扱うための処理を記載していきます
まずは画面共有するための変数を定義します

ProfileProvider.dart

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

そしてバックエンドから全件データを取得する処理をまずは記載します
Provider内のプライベートメソッドにします
バックエンドで実装したAPIをHTTPクライアントのDIOで呼び出しています
またmy_profileの時と同様に、HTTPクライアントで取得したデータは型がDynamicになってしまうためProfile型に戻す処理をつける必要があり、これらはこの後も他のメソッドで頻繁に共通利用されるため_inputMessageModel、_inputMessageModelListのメソッドとして処理を外だししています

ProfileProvider.dart

/// 【プライベート】プロフィール全件取得
  Future _fetchProfileAllList() async {
    _isSuccess = false;
    profileList.clear();
    try {
      Dio dio = Dio();
      List<Cookie> cookieList = await _prepareDio(dio);
      final Response profiles = await dio.get(
        '/api/profiles',
        options: Options(
          headers: {
            'Authorization': 'JWT ${cookieList.first.value}',
          },
        ),
      );
      profileList = _inputProfileModelList(profiles.data!);
      _isSuccess = true;
    } catch(error) {
      print(error);
      _isSuccess = false;
    }
    notifyListeners();
    return _isSuccess;
  }

/// 【プライベート】バックエンドから取得したメッセージデータのProvider化
  MessageModel _inputMessageModel(dynamic message) {
    return MessageModel(
      id: message!['id'],
      sender: message!['sender'],
      receiver: message!['receiver'],
      message: message!['message'],
      createdAt: message!['created_at'],
    );
  }

  /// 【プライベート】バックエンドから取得したメッセージデータ一覧のProvider化
  List<MessageModel> _inputMessageModelList(dynamic messageList) {
    return messageList.map<MessageModel>(
            (message) => _inputMessageModel(message)
    ).toList();
  }

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

次にプロフィール一覧画面ではすでにいいねを送ったユーザーやいいねをもらったユーザーやマッチングしているユーザーは表示させない仕様とするため、いいね機能であるマッチングモデルの処理をここで追加していきます
_matchingList はマッチング中のMatchingModelデータがリストで格納され、_approachingList はいいねを送ったユーザー、_approachedList はいいねをくれたユーザーを格納します
またフィルタリングの処理の都合上、_matchingUserIdList のように相手ユーザーのIDだけをリスト化したものも作成しておきます

ProfileProvider.dart

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

そして、バックエンドから取得したマッチングに関する全件データをローカルでそれぞれのデータに振り分けていく処理を実装して、上記で作成した変数に振り分けたデータをmapやwhereを使ってフィルタリングし、それぞれ格納していきます

ProfileProvider.dart

/// 【プライベート】いいねしているユーザー・いいねされているユーザー・マッチングしているユーザーのそれぞれのマッチングリストを取得する
  Future _fetchMatchingList() async {
    _isSuccess = false;
    try {
      Dio dio = Dio();
      List<Cookie> cookieList = await _prepareDio(dio);
      final Response matchingList = await dio.get(
        '/api/favorite/',
        options: Options(
          headers: {
            'Authorization': 'JWT ${cookieList.first.value}',
          },
        ),
      );
      _matchingList = _inputMatchingModelList(matchingList.data!);
      _approachingList = _matchingList.where((matching) => matching.approaching == myProfile.user).toList();
      _approachingUserIdList = _approachingList.map((matching) => matching.approached).toList();
      _approachedList = _matchingList.where((matching) => matching.approached == myProfile.user).toList();
      _approachedUserIdList = _approachedList.map((matching) => matching.approaching!).toList();
      _matchingList = _matchingList.where((matching) => matching.approved == true).toList();
      _matchingUserIdList = _matchingList.map((matching) => matching.approaching!).toList();
      _isSuccess = true;
    } catch(error) {
      print(error);
      _isSuccess = false;
    }
    notifyListeners();
    return _isSuccess;
  }

フィルタリングの実装

プロフィール一覧画面に表示するユーザーのフィルタリング

上記で格納した _approachingList などの変数を使用していいねを送ったユーザーといいねをくれたユーザーを取り除く処理を記述します
removeWhereを使います
実装はこちらで以上となります
ちなみに後で気づきましたが、_isSuccessの部分の実装は正しくない実装です
余力のある日とはtry catchで囲んで実装の修正をしておきましょう
まあこのままでも動きますが、今のところ

ProfileProvider.dart

/// ユーザーのプロフィール一覧を取得する(いいね・マッチング状態のユーザーは除外)
  Future fetchProfileList() async {
    _isSuccess = false;
    _isSuccess = await _fetchProfileAllList();
    _isSuccess = await _fetchMatchingList();
    profileList.removeWhere((profile) => _approachingUserIdList.contains(profile.user));
    profileList.removeWhere((profile) => _approachedUserIdList.contains(profile.user));
    notifyListeners();
    return _isSuccess;
  }
いいねを送ったユーザーのフィルタリング

上記で格納した _approachingList の変数を使用していいねを送ったユーザーをフィルタリングしていく処理を記述します
whereを使用しています
特段上の実装と変わったところはありません

ProfileProvider.dart

/// いいねしたユーザーのプロフィール一覧を取得する(マッチング状態のユーザーは除外)
  Future fetchProfileApproachingList() async {
    _isSuccess = false;
    _isSuccess = await _fetchProfileAllList();
    _isSuccess = await _fetchMatchingList();
    profileApproachingList = profileList.where((profile) => _approachingUserIdList.contains(profile.user)).toList();
    profileApproachingList.removeWhere((profile) => _matchingUserIdList.contains(profile.user));
    notifyListeners();
    return _isSuccess;
  }
いいねをもらったユーザーのフィルタリング

同様に

ProfileProvider.dart

/// いいねされたユーザーのプロフィール一覧を取得する(マッチング状態のユーザーは除外)
  Future fetchProfileApproachedList() async {
    _isSuccess = false;
    _isSuccess = await _fetchProfileAllList();
    _isSuccess = await _fetchMatchingList();
    profileApproachedList = profileList.where((profile) => _approachedUserIdList.contains(profile.user)).toList();
    profileApproachedList.removeWhere((profile) => _matchingUserIdList.contains(profile.user));
    notifyListeners();
    return _isSuccess;
  }
マッチングしたユーザーのフィルタリング

同様です

ProfileProvider.dart

/// マッチングしているユーザーのプロフィール一覧を取得する
  Future fetchProfileMatchingList() async {
    _isSuccess = false;
    _isSuccess = await _fetchProfileAllList();
    _isSuccess = await _fetchMatchingList();
    profileMatchingList = profileList.where((profile) => _matchingUserIdList.contains(profile.user)).toList();
    notifyListeners();
    return _isSuccess;
  }

ユーザー一覧画面の作成

それではユーザー一覧画面を実装していきます
一覧画面は4画面で共通利用するので共通部分を共通化しておきます
リストビューで実装して画像とニックネームと年齢とつぶやきが表示されるような実装にしました

共通利用画面のWidget

widgets/ProfileListWidget .dart

class ProfileListWidget extends StatelessWidget {
  ProfileListWidget({
    Key? key,
    required List<ProfileModel> profiles,
    String? nextUrl,
    Function? nextAction
  }) : _profiles = profiles, _nextUrl = nextUrl ?? '/profile-detail', super(key: key);

  final List<ProfileModel> _profiles;
  final String _nextUrl;
  // Flutter Web の場合 dotenv.get('BACKEND_URL_HOST_CASE_FLUTTER_WEB')
  final Uri _uriHost = Uri.parse(dotenv.get('BACKEND_URL_HOST'));

  @override
  Widget build(BuildContext context) {
    return Consumer<ProfileProvider>(
      builder: (context, profileProvider, _) {
        return ListView.builder(
          itemCount: _profiles.length,
          itemBuilder: (BuildContext context, int index) {
            return ListTile(
              leading: Stack(
                children: <Widget>[
                  _profiles[index].topImage != null ?
                  Image.network('${_profiles[index].topImage?.replaceFirst(
                      dotenv.get('STORAGE_URL_HOST'), _uriHost.toString())}',
                    errorBuilder: (context, error, stackTrace) {
                      return Image.asset('images/nophotos.png',);
                    },
                  ) :
                  Image.asset('images/nophotos.png',),
                  _profiles[index].isKyc
                      ?
                  const Icon(
                    Icons.check_circle, color: Colors.greenAccent, size: 16,)
                      :
                  const SizedBox(),
                ],
              ),
              title: Text('${_profiles[index].nickname} ${_profiles[index].age}歳'),
              subtitle: Text(_profiles[index].tweet ?? ''),
              trailing: Icon(
                profileProvider.checkSendFavorite(_profiles[index].user ?? '') ? Icons.favorite : Icons.favorite_border,
                color: Colors.pinkAccent,
              ),
              onTap: () async {
                profileProvider.setProfileDetail(_profiles[index]);
                if(_nextUrl == '/message') await profileProvider.getMessageList();
                Navigator.pushNamed(context, _nextUrl);
              },
            );
          },
        );
      }
    );
  }
}
プロフィール一覧画面の実装

screens/ProfilesScreen.dart

class ProfilesScreen extends StatelessWidget {
  const ProfilesScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('プロフィール一覧'),
      ),
      body: Consumer<ProfileProvider>(
        builder: (context, profileProvider, _) {
          return ProfileListWidget(profiles: profileProvider.profileList);
        },
      ),
      drawer: DrawerWidget(),
      bottomNavigationBar: BottomNavBarWidget(),
    );
  }
}
いいねを送ったユーザーのプロフィール一覧画面

上記とほぼ同じ実装です
以下の部分が主に違います

screens/ApproachingScreen.dart

return ProfileListWidget(profiles: profileProvider.profileApproachingList);
いいねをもらったユーザーのプロフィール一覧画面

上記と同様です
引数にprofileProvider.profileApproachedListを与えます

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

上記と同様ですが、引数にprofiles: profileProvider.profileMatchingList と それだけでなく、一覧のユーザーをタップしたときに、マッチング画面ではメッセージ画面に飛ぶようにnextUrl: '/message',の引数を加えておきます

ユーザー情報詳細画面の実装

一覧画面のうち特定のユーザーをタップすると詳細情報を見られるように詳細画面を作成します

ユーザー詳細選択ロジック

タップしたときにどのユーザーをタップしたかわかるようなロジックをProviderに実装します

ProfileProvider.dart

ProfileModel? profileDetail;

/// 選択したユーザーのプロフィールをセットする
  void setProfileDetail(ProfileModel profile) {
    profileDetail = profile;
    notifyListeners();
  }
詳細画面実装

詳細画面では先ほど格納したprofileDetail の情報を元に詳細を表示する画面を作成します
レイアウトは適当ですので、適宜手を加えて改善してください

ProfileDetailScreen.dart

class ProfileDetailScreen extends StatelessWidget {
  const ProfileDetailScreen({Key? key,}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final Uri _uriHost = Uri.parse('http://10.0.2.2:8000');
    return Scaffold(
      appBar: AppBar(
        title: const Text('プロフィール詳細'),
      ),
      body: Consumer<ProfileProvider>(
        builder: (context, profileProvider, _) {
          return Padding(
            padding: const EdgeInsets.all(32.0),
            child: SingleChildScrollView(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Padding(
                    padding: const EdgeInsets.all(8.0),
                    child: Text(profileProvider.profileDetail!.nickname, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),),
                  ),
                  Stack(
                    children: <Widget>[
                      profileProvider.profileDetail!.topImage != null ?
                      Image.network('${profileProvider.profileDetail!.topImage?.replaceFirst('http://127.0.0.1:8000/', _uriHost.toString())}',
                        width: 100, fit: BoxFit.fill,
                        errorBuilder: (context, error, stackTrace) {
                          return Image.asset('images/nophotos.png',width: 100, fit: BoxFit.fill,);
                        },
                      ) :
                      Image.asset('images/nophotos.png', width: 100, fit: BoxFit.fill,),
                      profileProvider.profileDetail!.isKyc ?
                      const Icon(Icons.check_circle, color: Colors.greenAccent, size: 16,) :
                      const SizedBox(),
                    ],
                  ),
                  Row(
                    mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                    children: [
                      Text('${profileProvider.profileDetail!.age.toString()}歳',),
                      Text(sexPicker[profileProvider.profileDetail!.sex] ?? '',),
                      Text(locationPicker[profileProvider.profileDetail!.location] ?? '',),
                    ],
                  ),
                  Padding(
                    padding: const EdgeInsets.all(8.0),
                    child: Text('送ったいいね ${profileProvider.profileDetail!.sendFavorite ?? ''}',),
                  ),
                  Padding(
                    padding: const EdgeInsets.all(8.0),
                    child: Text('もらったいいね ${profileProvider.profileDetail!.receiveFavorite ?? ''}',),
                  ),
                  Padding(
                    padding: const EdgeInsets.all(8.0),
                    child: Text('所持しているいいね ${profileProvider.profileDetail!.stockFavorite ?? ''}',),
                  ),
                  const Padding(
                    padding: EdgeInsets.all(8.0),
                    child: Text('いいねする',),
                  ),
                  Padding(
                    padding: const EdgeInsets.all(8.0),
                    child: IconButton(
                      icon: Icon(
                        profileProvider.checkSendFavorite(profileProvider.profileDetail!.user ?? '') ? Icons.favorite : Icons.favorite_border,
                        color: Colors.pinkAccent
                      ),
                      onPressed: () async {
                        if (!profileProvider.checkSendFavorite(profileProvider.profileDetail!.user ?? '')) {
                          profileProvider.sendFavorite().then((isSuccess) {
                            if (isSuccess) {
                              showSnackBar(context, 'いいねを送りました');
                            } else {
                              showSnackBar(context, 'エラーが発生しました');
                            }
                          });
                        }
                      },
                    ),
                  ),
                  Padding(
                    padding: const EdgeInsets.all(8.0),
                    child: Text(profileProvider.profileDetail!.tweet ?? '', style: const TextStyle(decoration: TextDecoration.underline),),
                  ),
                  Padding(
                    padding: const EdgeInsets.all(8.0),
                    child: Text('${profileProvider.profileDetail!.height ?? ''}cm',),
                  ),
                  Padding(
                    padding: const EdgeInsets.all(8.0),
                    child: Text('お仕事 ${profileProvider.profileDetail!.work ?? ''}',),
                  ),
                  Padding(
                    padding: const EdgeInsets.all(8.0),
                    child: Text('${profileProvider.profileDetail!.revenue ?? ''}万円',),
                  ),
                  Padding(
                    padding: const EdgeInsets.all(8.0),
                    child: Text(graduationPicker[profileProvider.profileDetail!.graduation] ?? '',),
                  ),
                  Padding(
                    padding: const EdgeInsets.all(8.0),
                    child: Text('趣味 ${profileProvider.profileDetail!.hobby ?? ''}',),
                  ),
                  Padding(
                    padding: const EdgeInsets.all(8.0),
                    child: Text('結婚願望 ${passionPicker[profileProvider.profileDetail!.passion ?? '']}' ,),
                  ),
                  const Padding(
                    padding: EdgeInsets.all(8.0),
                    child: Text('自己紹介',),
                  ),
                  Container(
                    padding: const EdgeInsets.all(8.0),
                    decoration: BoxDecoration(border: Border.all()),
                    child: Padding(
                        padding: const EdgeInsets.all(8.0),
                        child: Text(profileProvider.profileDetail!.introduction ?? '',),
                    ),
                  ),
                ],
              ),
            ),
          );
        },
      ),
      drawer: const DrawerWidget(),
      bottomNavigationBar: const BottomNavBarWidget(),
    );
  }
}

いいね送信機能の実装

さて詳細画面ができたので、詳細画面からいいねを押すことができる機能を作成していきましょう
全体としてのコードはこちらです
sendFavoriteがいいねを送るロジックを記載したコードであり、相手とのいいねの状態によって処理が異なります
check〇〇Faoriteはコメントアウト通りの処理になっており条件判定に使います

ProfileProvider.dart

/// いいねをしたユーザーかどうかをチェックする
  bool checkSendFavorite(String userId) {
    return _approachingUserIdList.contains(userId);
  }

  /// いいねをくれたユーザーかどうかをチェックする
  bool checkReceiveFavorite(String userId) {
    return _approachedUserIdList.contains(userId);
  }

  /// いいねを送る・承認する
  Future sendFavorite() async {
    _isSuccess = false;
    try {
      String approachUserId = profileDetail != null ? profileDetail!.user! : '';
      // 既にこちらからいいねを送っている場合は処理を行わない
      if (checkSendFavorite(approachUserId)) {
        // 何も処理を行わない
      }
      // 既に相手からいいねが来ている場合は上記のリクエストデータのapprovedをTrueにしていいねを行い、相手のマッチングモデルデータのapprovedもTrueに更新する
      else if (checkReceiveFavorite(approachUserId)) {
        // いいね新規作成処理
        await _createFavorite(approached: approachUserId, approved: true);
        // いいねをくれたユーザーのマッチングリストの中でapproachUserIdと一致するマッチングデータのIDを探索する
        Iterable<MatchingModel> approachMatching = _approachedList.where((matching) => matching.approaching == approachUserId);
        int approachMatchingId = approachMatching.first.id ?? 0;
        // 相手のいいねデータに対する承認処理
        await _patchApproved(id: approachMatchingId);
        // マッチングデータ再取得
        await fetchProfileMatchingList();
      }
      // どちらもいいねを送っていない場合は上記のリクエストデータを用いてマッチングデータを作成する
      else {
        // いいね新規作成処理
        await _createFavorite(approached: approachUserId, approved: false);
        // いいねしたユーザー一覧データ再取得
        await fetchProfileApproachingList();
      }
      _isSuccess = true;
    } catch(error) {
      print(error);
      _isSuccess = false;
    }
    notifyListeners();
    return _isSuccess;
  }

/// 【プライベート】マッチングデータ新規作成
  Future _createFavorite({required String approached, required bool approved}) async {
    Dio dio = Dio();
    List<Cookie> cookieList = await _prepareDio(dio);
    return await dio.post(
      '/api/favorite/',
      options: Options(
        headers: {
          'Authorization': 'JWT ${cookieList.first.value}',
        },
      ),
      data: {
        "approached": approached,
        "approved": approved,
      },
    );
  }

  /// 【プライベート】マッチングデータ承認フィールド更新
  Future _patchApproved({required int id}) async {
    Dio dio = Dio();
    List<Cookie> cookieList = await _prepareDio(dio);
    return await dio.patch(
      '/api/favorite/$id/',
      options: Options(
        headers: {
          'Authorization': 'JWT ${cookieList.first.value}',
        },
      ),
      data: {
        "approved": true
      },
    );
  }
既にもらっているいいねに対する承認ロジックについて

条件分岐ごとの処理はコメントアウトに記載されている通りですが、すでにいいねをもらっている人にいいねを送る処理というのは言い換えるといいねを承認していることと同じです
相手からすでにいいねが来ているということは、相互でいいねを送り合うので、マッチング成立ととらえます
こちらからは承認済のいいね(マッチングモデルデータ)を送信して、相手からすでにもらっているいいねデータのapprovedを承認済みに変更します
approvedはメッセージ送信を許可するかどうかを判定するためのフィールドであるため必ず両者のマッチングデータのapprovedをTrueにします
相手のデータを更新する必要があるため、_patchApprovedのメソッドを作成しています(※Djangoの記事でバックエンドのPatchを拒否する設定になってたかもしれないので、そうなっていた場合は当該コードをコメントアウトするなり修正してください)

これにて、いいね機能を実装することができました

メッセージ機能の実装

最後にメッセージ機能を作成します

ModelとProviderの実装

Modelの実装

MessageModel .dart

class MessageModel {
  late int? id = 0;
  late String sender = '';
  late String receiver = '';
  late String message = '';
  late String? createdAt;

  MessageModel({
    this.id,
    required this.sender,
    required this.receiver,
    required this.message,
    this.createdAt,
  });
}
全件メッセージを取得するロジック

別ファイルに分けたいのは山々ですが、Provider間でのデータの受け渡しに関しては、理解できておらず状態管理がうまくできるコードを作成できないので、ProfileProviderが長大になるとは思いつつもこちらに追記していきます
上記で実装してきた容量で状態管理したい変数とロジックを作成していきます

ProfileProvider.dart

/// メッセージ機能
  List<MessageModel> messageList = [];
  List<MessageModel> _sendMessageList = [];
  List<MessageModel> _receiveMessageList = [];
  String newMessage = '';

バックエンドからデータを取得してデータを格納する処理

ProfileProvider.dart

/// 【プライベート】送ったメッセージの内容を全件取得する
  Future _fetchSendMessageList() async {
    Dio dio = Dio();
    List<Cookie> cookieList = await _prepareDio(dio);
    final Response<dynamic> message = await dio.get(
      '/api/dm-message/',
      options: Options(
        headers: {
          'Authorization': 'JWT ${cookieList.first.value}',
        },
      ),
    );
    _sendMessageList = _inputMessageModelList(message.data!);
  }

  /// 【プライベート】受け取ったメッセージの内容を全件取得する
  Future _fetchReceiveMessageList() async {
    Dio dio = Dio();
    List<Cookie> cookieList = await _prepareDio(dio);
    final Response<dynamic> message = await dio.get(
      '/api/dm-inbox/',
      options: Options(
        headers: {
          'Authorization': 'JWT ${cookieList.first.value}',
        },
      ),
    );
    _receiveMessageList = _inputMessageModelList(message.data!);
  }

/// 【プライベート】バックエンドから取得したメッセージデータのProvider化
  MessageModel _inputMessageModel(dynamic message) {
    return MessageModel(
      id: message!['id'],
      sender: message!['sender'],
      receiver: message!['receiver'],
      message: message!['message'],
      createdAt: message!['created_at'],
    );
  }

  /// 【プライベート】バックエンドから取得したメッセージデータ一覧のProvider化
  List<MessageModel> _inputMessageModelList(dynamic messageList) {
    return messageList.map<MessageModel>(
            (message) => _inputMessageModel(message)
    ).toList();
  }
特定のユーザーとのメッセージだけにフィルタリングするロジック

マッチング成立画面からメッセージ画面に移るときに呼び出されるAPIを実装したものが以下です

ProfileProvider.dart

/// 指定したユーザーとのメッセージの内容を取得する
  Future getMessageList() async {
    _isSuccess = false;
    messageList.clear();
    try {
      // バックエンドから自身に関連するメッセージ一覧を取得する
      await _fetchSendMessageList();
      await _fetchReceiveMessageList();
      // 指定したユーザーとのメッセージだけをフィルタリングして新規メッセージ順に messageList に格納する
      messageList.addAll(_sendMessageList.where((message) => message.receiver == profileDetail!.user));
      messageList.addAll(_receiveMessageList.where((message) => message.sender == profileDetail!.user));
      messageList.sort((alpha, beta) => alpha.createdAt!.compareTo(beta.createdAt!));
      // 処理成功
      _isSuccess = true;
    } catch(error) {
      print(error);
      _isSuccess = false;
    }
    notifyListeners();
    return _isSuccess;
  }
新規メッセージ作成

新たなメッセージを送る処理です

ProfileProvider.dart

/// 指定したユーザーに新規メッセージを送信する
  Future createMessage() async {
    _isSuccess = false;
    try {
      Dio dio = Dio();
      List<Cookie> cookieList = await _prepareDio(dio);
      await dio.post(
        '/api/dm-message/',
        options: Options(
          headers: {
            'Authorization': 'JWT ${cookieList.first.value}',
          },
        ),
        data: {
          "receiver": profileDetail!.user,
          "message": newMessage,
        },
      );
      newMessage = '';
      await getMessageList();
      _isSuccess = true;
    } catch(error) {
      print(error);
      _isSuccess = false;
    }
    notifyListeners();
    return _isSuccess;
  }

メッセージ画面の実装

まずはメッセージ画面を作成します
コードが長くなるので、MessageListWidgetで一部ウィジェット化しています

MessageScreen.dart

class MessageScreen extends StatelessWidget {
  const MessageScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Consumer<ProfileProvider>(
      builder: (context, profileProvider, _) {
        return Scaffold(
          appBar: AppBar(
            title: Text('${profileProvider.profileDetail!.nickname}とのメッセージ'),
          ),
          body: Column(
            children: [
              Expanded(child: MessageListWidget(messages: profileProvider.messageList,),),
              Padding(
                padding: const EdgeInsets.only(left: 8.0, right: 8.0, bottom: 4.0),
                child: TextFormField(
                  onChanged: (value) => profileProvider.newMessage = value,
                  decoration: InputDecoration(
                    labelText: 'メッセージを送りましょう',
                    suffixIcon: IconButton(
                        onPressed: () {
                          profileProvider.createMessage().then((isSuccess) {
                            if (isSuccess) {
                              showSnackBar(context, 'メッセージが送信されました');
                            } else {
                              showSnackBar(context, 'エラーが発生しました');
                            }
                          });
                        },
                        icon: const Icon(Icons.send)
                    ),
                  ),
                  maxLength: 500,
                ),
              ),
            ],
          ),
          drawer: const DrawerWidget(),
          bottomNavigationBar: const BottomNavBarWidget(),
        );
      },
    );

  }
}
Widget化したメッセージ一覧画面コンポーネントの実装

こちらもレイアウトは適当です
LINEのような言わゆるよく見るチャットのレイアウトを作成する実力はなかったのでひとまずこちらの実装となっています
実運用を目指すのであればブラッシュアップが必要です

widgets/MessageListWidget.dart

class MessageListWidget extends StatelessWidget {
  MessageListWidget({
    Key? key,
    required List<MessageModel> messages,
  }) : _messages = messages, super(key: key);

  final List<MessageModel> _messages;
  // Flutter Web の場合 dotenv.get('BACKEND_URL_HOST_CASE_FLUTTER_WEB')
  final Uri _uriHost = Uri.parse(dotenv.get('BACKEND_URL_HOST'));

  @override
  Widget build(BuildContext context) {
    return Consumer<ProfileProvider>(
      builder: (context, profileProvider, _) {
        return ListView.builder(
          itemCount: _messages.length,
          itemBuilder: (BuildContext context, int index) {
            String imageUrl = profileProvider.myProfile.topImage ?? '';
            String sender = profileProvider.myProfile.nickname;
            String createdAt = _messages[index].createdAt ?? '';
            if(_messages[index].sender == profileProvider.profileDetail!.user) {
              imageUrl = profileProvider.profileDetail!.topImage ?? '';
              sender = profileProvider.profileDetail!.nickname;
            }
            if(imageUrl != '') imageUrl.replaceFirst(dotenv.get('STORAGE_URL_HOST'), _uriHost.toString());
            return ListTile(
              leading: imageUrl != '' ?
                Image.network(imageUrl,
                  errorBuilder: (context, error, stackTrace) {
                    return Image.asset('images/nophotos.png',);
                  },
                ) :
                Image.asset('images/nophotos.png',),
              title: Text('$index ${_messages[index].message}'),
              subtitle: Text('$sender $createdAt'),
            );
          },
        );
      }
    );
  }
}

これでマッチングアプリに必要な機能をすべて実装することができました
お疲れ様です
動作確認を行って想定どおりに動くか確認しておきましょう

終わりに

今回作成したコードは記述の重複が多いため実運用を考える際はリファクタリングが必要かと思います
またログアウト機能や認証情報の期限切れに対するログイン画面へのリダイレクト機能も作っていません
またテストも実装していません
その他改善すべき点はまだまだありますが、マッチングアプリに絶対必要な機能を一通り作成することができたので、これにてひとまず完成ということにします
お疲れさまでした

マッチングアプリを個人開発する~Djangoバックエンドその5 決済機能実装~

この記事はマッチングアプリを自作してみようと試みている記事です
バックエンドを実装しています

前回までの記事はこちらです
shinseidaiki.hatenablog.com


前回はバックエンドのコア機能の動作確認を終えました
今回は積み残しのタスクとしての決済機能の実装を行っていきます
決済機能は不要だという方はこの章は飛ばしてもらってよいです

また、以下で紹介する実装は実運用には到底耐えないと思いますので本番運用を目指す方は更なるブラッシュアップを行ってください

目次

決済機能のStripeについての概要

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

ちなみに一昔前はJCBに非対応でなかなか大きな穴がありましたが今現在は対応されており、Stripeの使用にあたって突っ込みが入るところはほぼなくなっていると思います

なお初めのうちは決済機能の自作を考えるかもしれないですが、決済に関しては各種法令や業務要件・仕様が非常にシビアなため個人開発で決済機能を自作することは全く現実的ではないと思われます
素直に代行機能を利用する方が賢明だと思います

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

アーキテクチャ設計

ではシステム構成とREST APIURIのエンドポイントとその機能をざっくり整理しておきます

システム構成図

やはり図を書いた方が何をこれからやるかがはっきりとするのでまずはお絵描きをします
図のような実装を行っていきます


画面のユーザー新規作成の段階では仮登録としてis_active=Falseであり、Emailに記載するURLからクレジット決済機能提供サービスのStripe経由でクレジット決済が完了したユーザーのみをアクティベーションします

ちなみにこれらの実装の目的としては不正なユーザーが簡単にユーザー登録をできないようにすることにあります
ただし残念ながらこの構成で目的が適うのかは私も知見がないのでわかりません
ここから先は多分大丈夫だろうという前提で実装を行っていきます

REST API設計

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


まとまりました
では実装をしていきましょう
ブログの説明の構成的には、アクティベーション機能とEmail機能とクレジット決済機能単位で分けた方が分かりやすいとは思いますが、コードがごちゃごちゃするので全機能をまとめてsettings.pyやmodel.pyから説明していきます
それぞれの機能の実装方法は今後切り分けたほうが分かりやすければブログにまとめたいとおもいます


各種機能を使用するための設定と環境変数を実装する

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

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

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

メール認証も済ませてアカウントをアクティベーションしてください
アカウントを作成するとダッシュボードが表示されていると思います
デフォルトでテスト環境になっていることを確認します
なお本番利用する場合はアプリサービスごとに都度申請が必要であるのでテスト環境だと思ってたら実は本番だった的なうっかり課金が起こってしまうことがないので初心者やちょっとAPIを試してみたいユーザーには心強い存在ですね

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

pip install stripe

settings.pyの実装

次にDjangoでEmailとStripeを利用するための設定を行います

Email機能の設定

DjangoでEmailを利用したい場合はsettings.pyにEMAIL_BACKENDを設定すればよいことになっています
本番環境ではdjango.core.mail.backends.smtp.EmailBackendを使用しますが、テスト環境でメールの確認をしたい場合にはdjango.core.mail.backends.console.EmailBackendを用いることがコンソールに結果が出力されるのでお手軽に試したい場合はこちらを使うのが便利です
メールを実際に送信する場合はメールサーバーを用意したり、gmailを利用したりしなければならないので少し手間がかかるので基本的にはコンソール出力でテスト環境は対処します

settings.py

EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' # コンソールにメール内容を表示する
# EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' # メールを送信する

またメールには送り手の情報などが必要になるので必要項目をsettings.pyに記述します
settings.py

EMAIL_HOST = env.str('EMAIL_HOST')
EMAIL_PORT = env.int('EMAIL_PORT')
DEFAULT_FROM_EMAIL = env.str('DEFAULT_FROM_EMAIL')
EMAIL_HOST_USER = env.str('EMAIL_HOST_USER')
EMAIL_HOST_PASSWORD = env.str('EMAIL_HOST_PASSWORD')
EMAIL_USE_TLS = env.bool('EMAIL_USE_TLS')

PASSWORDなどおよそコード上に残したくないものを設定するのでこれらの値は.envファイルに記述して環境変数化します
以下はgmailを使用する場合の設定です
EMAIL_HOST_PASSWORDはgmailの生パスワードか二段階認証を有効化した時に使えるアプリパスワードを設定します
私は横着して生パスワードで一度試しましたが、googleアカウントのセキュリティで弾かれたので、弾かれた場合はGoogleアカウントの設定(Chromeブラウザの設定ではないことに注意)>セキュリティ>安全性の低いアプリのアクセスを「オン」にすれば使用できるようなります
しかしGoogleサービスはスパム判定とかどうなってるのかよくわからず怖いのでこういったものを試すなら吹っ飛んでも大丈夫なGoogleアカウントを用意しておく方が無難な気がします

.env.dev or .env.prod

EMAIL_HOST=smtp.gmail.com
EMAIL_PORT=587
DEFAULT_FROM_EMAIL=[YOUR.EMAIL]
EMAIL_HOST_USER=[YOUR.EMAIL]
EMAIL_HOST_PASSWORD=[YOUR.EMAIL.HOST.PASSWORD.BUT.APP.PASSWORD.FOR.GMAIL]
EMAIL_USE_TLS=True

決済機能の設定

次にStripeの設定を行います
こちらは特にSTRIPE_API_SECRET_KEYが大事です
MY_URLはStripeの成功・キャンセル時のリダイレクトに使うために使用します
STRIPE_ITEM_PRICEはマッチングアプリの開始利用料を設定します
これらも環境変数化します

settings.py

STRIPE_API_SECRET_KEY = env.str('STRIPE_API_SECRET_KEY')
MY_URL = env.str('MY_URL')
STRIPE_ITEM_PRICE = env.str('STRIPE_ITEM_PRICE')

.env.dev or .env.prod

STRIPE_API_SECRET_KEY=[STRIPE API シークレットキー]
MY_URL=http://127.0.0.1:8000
STRIPE_ITEM_PRICE=price_[STRIPE自動生成コード]

STRIPE_API_SECRET_KEYはダッシュボードの開発者向けのところで目隠しされているシークレットキーをコピーします
STRIPE_ITEM_PRICEに関してはドキュメントの販売商品の定義のところで自作した商品のコードを入力します
stripe.com

以下のようにテスト商品を作成することができるのでPRICE_IDに入った文字列をSTRIPE_ITEM_PRICEに格納する

Stripe例

line_items=[
  {
       # Provide the exact Price ID (for example, pr_1234) of the product you want to sell
        'price': '{{PRICE_ID}}', # ドキュメントで商品の値段を設定すると ------> 'price_[PRODUCT_KEY]'になるはず  
        'quantity': 1,
   },
],

設定はこれでおしまいです
次はモデルを作ります

Modelを実装する

次はモデルを実装します
仮登録ユーザーを本登録するアクティベーションの機能とメール送信機能を実装していきます
アクティベーションにはトークンを利用するのでトークンを格納するためのモデル作成していきます

アクティベーション機能の実装

現実世界で実際に運用されているWebサービスは通常、不正ユーザーの登録を防ぐために仮登録を行ってEmailの認証情報を入力させた後に本登録を完了させる実装が一般的かと思います
実装の具体的な方法はいろいろあるかと思いますが、今回は有効期限付きのトークンを発行して有効なトークンを持っているユーザーが期限内に決済を完了させた場合にアクティベーションをする実装とします

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

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

models.py

class UserActivateTokens(models.Model):

    token_id = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False)
    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
    activate_token = models.UUIDField(default=uuid.uuid4)
    expired_at = models.DateTimeField()

    objects = UserActivateTokensManager()

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

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

マネージャークラスは公式ドキュメントでは「Django のモデルに対するデータベースクエリの操作を提供するインターフェイスです」と説明されていますが、分かりにくいのでモデルの特殊なメソッド置き場みたいなイメージでいいと思います
さて、マネージャーの中のactivate_user_by_tokenの処理がまさしくユーザーのアクティベーションを行っている処理です
user.is_active = Trueをしている箇所からわかると思います
また前処理としてアクティブにするユーザーは 正しいactivate_tokenと、かつ、有効期限が切れていないデータを持っているユーザーだけにフィルタリングしています
つまりactivate_token(※実際に渡してるのはtoken_id)をユーザーを新規作成した本人にしか分からない渡し方、つまりemailで渡すと、トークンはその人しか持っていないはずなので、ここでアクティベーションされるユーザーは、ユーザーを新規作成した本人のアカウントであるという認証を行うことができる仕掛けとなっています

models.py

class UserActivateTokensManager(models.Manager):

    def activate_user_by_token(self, activate_token):
        user_activate_token = self.filter(
            activate_token=activate_token,
            expired_at__gte=datetime.now() # __gte = greater than equal
        ).first()
        if hasattr(user_activate_token, 'user'):
            user = user_activate_token.user
            user.is_active = True
            user.save()
            return user
トークンを発行する処理

さて、ユーザーを作成した時に同時にトークンを発行してそのトークンをemailに乗せて送信出来たら便利ですが、Djangoにはちゃんとそのためのシグナルズ django.db.models.signals という機能が備わっています
シグナルズを利用すると何らかの処理がプログラム上で起こったときにタイミングをプログラム上で明示しなくても条件に合致した場合に自動的にあらかじめ定義したメソッドを実行してくれる機能です
例えば今回利用するようなユーザーが作成された場合に自動的にトークンを発行してメール送信するといったような実
装です
シグナルズの使い方ですが、アノテーションを利用することで使えます
アノテーションとはメソッドの手前に@がつくものです
例えば、今回使う@receiver(post_save, )のようなものです
ちなみにシグナルズと混乱するかもですが、アノテーションをつけていると今回のようなメソッドの実行タイミングの制御やパーミッションの制御などに用いることができます
コード量を減らす工夫です
そして、@receiver(post_save, sender)ですがこれをつけるとsenderに定義したモデルが保存の処理を行うたびに直下のメソッドが自動的に呼び出されます
ここではユーザーが新規作成されたりフィールドが更新されて保存されたタイミングで呼び出されています
シグナリングを使用しなくてもviews.pyに処理をゴリゴリ書いてもいいですが、シグナリングを使用する方が見通しが良くなる場合はこちらで記述するのが得策です

そして、以下がトークンを発行する処理になります
userにはシグナルズでuser.save()メソッドを呼んだつまり新規作成されたユーザーのインスタンスが入ってきます
expired_atの有効期限は現在時刻にsettings.ACTIVATION_EXPIRED_DAYSで定義した3日を足した時刻を格納しています
アクティベーションのメソッドを呼び出すのはこの日時までだよという意味にあたるのがexpired_atになります

models.py

from django.db.models.signals import post_save
from django.dispatch import receiver

@receiver(post_save, sender=settings.AUTH_USER_MODEL)
def publish_activate_token(sender, instance, **kwargs):
    if not instance.is_active:
        user_activate_token = UserActivateTokens.objects.create(
            user=instance,
            expired_at=datetime.now()+timedelta(days=settings.ACTIVATION_EXPIRED_DAYS),
        )
    # .........Emailを送信する処理   

またexpired_atの日付の格納に関してもAwareやNaiveなどのややこしい話があってこの実装ではDjangoのコンソールに警告文が出てしまうのですが、今回は気にしないことにします
この点に関しては記事を少しまとめたものがあるので暇があれば読んでください
qiita.com

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

Emailで送信する処理ですが、Djangoはsend_mail()メソッドを呼ぶだけでメールを送ることができます
引数の意味はぱっと見でわかると思いますので以下実装で示します
上のpublish_activate_token()メソッドの続きにEmail処理を記述して以下のようにします
メール本文に{settings.MY_URL}/api/users/{user_activate_token.token_id}/payment/を記述しており、これをクリックするとStripeの決済画面に遷移する動きになっています
またpost_saveはマネージャークラスでis_activeをtrueにするときにも反応するのでアクティベーション完了のメールも送信する設定にしました

models.py

from django.core.mail import send_mail

@receiver(post_save, sender=settings.AUTH_USER_MODEL)
def publish_activate_token(sender, instance, **kwargs):
    if not instance.is_active:
        user_activate_token = UserActivateTokens.objects.create(
            user=instance,
            expired_at=datetime.now()+timedelta(days=settings.ACTIVATION_EXPIRED_DAYS),
        )
        subject = 'Please Activate Your Account'
        message = f'URLにアクセスして決済を完了してください。\n {settings.MY_URL}/api/users/{user_activate_token.token_id}/payment/'
    if instance.is_active:
        subject = 'Activated! Your Account!'
        message = 'ユーザーが使用できるようになりました'
    from_email = settings.DEFAULT_FROM_EMAIL
    recipient_list = [
        instance.email,
    ]
    send_mail(subject, message, from_email, recipient_list)  

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

Viewの前にルーティング設定であるurls.pyの実装を行っておきます
アーキテクチャ設計のところで記載したものと同じです

urls.py

from .views import activate_user
from .views import pay_stripe
from .views import pay_stripe_cancel 

urlpatterns = [
    path('users/<uuid:token_id>/payment/', pay_stripe, name='pay-stripe'),  # Stripe決済画面にリダイレクト
    path('users/payment/cancel/', pay_stripe_cancel, name='pay-stripe-cancel'),  # 決済失敗時に実行
    path('users/<uuid:activate_token>/activation/', activate_user, name='users-activation'),  # 決済成功時にユーザーアクティベーションを実行
]

Viewを実装する

Viewの実装では決済機能とユーザーアクティベーションの処理を実装します

決済機能の実装

Stripeを利用するための実装

今回のAPI機能はDjangoで作成するモデルをメインで使うよりはStripeの決済機能を使うロジックの方が多いので、メソッドベースのAPIViewを採用します
そして以下のアノテーションをつけてアクセス制御を行っています
@api_view(['GET'])
@permission_classes([AllowAny])

また、処理に関してはほぼStripeのドキュメントをそのまま利用しています
success_urlとcancel_urlはぱっと見でわかると思いますが成功時とキャンセル時のリダイレクト先を記載するところです
こちらのアプリで想定するURLに飛ばすように設定します
success_urlにはアクティベーション用のトークンをくっつけておきます

views.py

from rest_framework.decorators import api_view, permission_classes
from django.shortcuts import redirect
from django.conf import settings
import stripe

stripe.api_key = settings.STRIPE_API_SECRET_KEY


@api_view(['GET'])
@permission_classes([AllowAny])
def pay_stripe(request, token_id):
    try:
        # ......tokensの前処理
        checkout_session = stripe.checkout.Session.create(
            line_items=[
                {
                    'price': settings.STRIPE_ITEM_PRICE,
                    'quantity': 1,
                },
            ],
            mode='payment',
            success_url=f'{settings.MY_URL}/api/users/{tokens.activate_token}/activation/',
            cancel_url=f'{settings.MY_URL}/api/users/payment/cancel/',
        )
    except Exception as e:
        return str(e)
    return redirect(checkout_session.url, code=303)
token_idを受け取ってアクティベーションさせるメソッドにはactivate_tokenを渡す処理の実装

今回token_idとactivate_tokenでトークンを分けていましたが、これはtoken_idをもしusers/{str:token_id}/activation/のようにアクティベーションURIに使ったとしたらユーザーがURLのエンドポイントの/activation/を推測できると決済を通さずにアクティベーションできてしまうというリスクがあるからでした
なので、token_idを決済画面のユーザー識別に使ってactivate_tokenをアクティベーショントークンとして使い分ける実装まで行ったStripe決済画面呼び出しメソッド pay_stripe(request, token_id) は以下のようになります
といってもマネージャークラスで実装した処理とほとんど一緒です
これでトークンが流出するかUUIDが推測でもされない限り決済をスキップされる可能性がなくなりました

views.py

from .models import UserActivateTokens

@api_view(['GET'])
@permission_classes([AllowAny])
def pay_stripe(request, token_id):
    try:
        tokens = UserActivateTokens.objects.all().filter(
            token_id=token_id,
            expired_at__gte=datetime.now()
        ).first()
        if tokens is None:
            return Response({'message': 'トークン間違いもしくはトークンの有効期限切れです'})
        checkout_session = stripe.checkout.Session.create(
            line_items=[
                {
                    'price': settings.STRIPE_ITEM_PRICE,
                    'quantity': 1,
                },
            ],
            mode='payment',
            success_url=f'{settings.MY_URL}/api/users/{tokens.activate_token}/activation/',
            cancel_url=f'{settings.MY_URL}/api/users/payment/cancel/',
        )
    except Exception as e:
        return str(e)
    return redirect(checkout_session.url, code=303)
Stripeで決済をキャンセルしたときの処理の実装

キャンセルされたときの処理です
メッセージを返すだけです
Djangoで画面も作る実装の場合はHttpResponseを返しますが、今回のようにバックエンド特化のREST APIの場合はResponseを返すという違いがあります

views.py

from rest_framework.decorators import api_view, permission_classes
from rest_framework.response import Response

@api_view(['GET'])
@permission_classes([AllowAny])
def pay_stripe_cancel(request):
    return Response({'message': '決済がキャンセルされました'})
ユーザーアクティベーションの処理の実装

決済が成功した場合はユーザーのアクティベーションを行うメソッドがリダイレクトされて呼び出されます
UserActivateTokens.objects.activate_user_by_token(activate_token)がアクティベーションの処理を呼び出しています
大まかには決済が成功して想定通りユーザーがアクティベーションされればactivated_userが返ってきて、何らかの障害やエラーが発生した場合には失敗のメッセージを送る想定としています

ただしこのメソッドは決済が絡んでくるためサービスの品質として絶対に落としてはいけないものであるので、本番環境ではこの処理の失敗時に特別なログを出力させて管理者に通知する実装をとった方が良いと思われます
余力があればそちらの実装も行っていきたいと思います

views.py

from .models import UserActivateTokens
from rest_framework.decorators import api_view, permission_classes


@api_view(['GET'])
@permission_classes([AllowAny])
def activate_user(request, activate_token):
    activated_user = UserActivateTokens.objects.activate_user_by_token(activate_token)
    if hasattr(activated_user, 'is_active'):
        if activated_user.is_active:
            message = {'message': 'ユーザーのアクティベーションが完了しました'}
        if not activated_user.is_active:
            message = {'message': 'アクティベーションが失敗しています。管理者に問い合わせてください'}
    if not hasattr(activated_user, 'is_active'):
        message = {'message': 'エラーが発生しました'}
    return Response(message)

このメソッドではGETメソッドを使っていますが、Userモデルの変更を伴っているのにGETで呼び出していいのかなと思いつついい方法が思い浮かばなかったのでこの実装にしています

動作確認

さて簡単に動作を確認していきます
まずはDBの変更を伴っているのでマイグレーションを行ってから起動しましょう

py manage.py makemigrations
py manage.py migrate
py manage.py runserver

ユーザー作成時のトークン生成とEmail通知の確認

POSTMANでhttp://127.0.0.1:8000/api/users/create/にアクセスして、アカウントを作成しましょう
するとコンソールに次のようなEmailが返ってくることが確認できると思います

Content-Type: text/plain; charset="utf-8"
MIME-Version: 1.0
Content-Transfer-Encoding: 8bit
Subject: Please Activate Your Account
From: super@user.com
To: user5@user.com
Date: Sat, 19 Feb 2022 11:27:48 -0000
Message-ID: 
 <***********************************************>

URLにアクセスして決済を完了してください。
 http://127.0.0.1:8000/api/users/[token-id-uuid]/payment/
-------------------------------------------------------------------------------
[19/Feb/2022 20:27:48] "POST /api/users/create/ HTTP/1.1" 201 84

また管理画面でもユーザーが作成できていて、is_activeがFalseになっていることを確認します
またトークンが発行されていることも見ておきましょう
トークンテーブルを見るのはadmin.pyをいじって管理画面にテーブルを増やすか、CB Browswe for SQLiteのようなUIで操作が出来るフリーのツールをインストールしてきて、プロジェクトフォルダのdb.sqlite3を開けば確認することができます
トークンも同時に生成されていることが分かりました

Stripe決済機能とアクティベーション機能の確認

さて、emailの本文にあるhttp://127.0.0.1:8000/api/users/[token-id-uuid]/payment/にアクセスしましょう
Stripeの画面にリダイレクトしていることを確認します

さてテスト用の番号は以下のようになっているので試してみましょう

支払いが成功しました 4242 4242 4242 4242
支払いには認証が必要です 4000 0025 0000 3155
支払いが拒否されました 4000 0000 0000 9995

stripe.com


4242 4242 4242 4242を入力してその他の項目も適当に埋めてOKを押します

チェックがかかりいつものDjangoのブラウザのRest API実行環境にリダイレクトされます
アクティベーションが成功した旨のメッセージが出ていれば成功です

{
    "message": "ユーザーのアクティベーションが完了しました"
}

逆にOKボタンを押さずに戻るボタンを押すなどするとキャンセルが起こります
キャンセルのリダイレクトも確認しておきましょう

Stripeを使えない場合のトラブルシュート

以下のようなエラーが発生した場合は、Stripeのアカウント名を変更する必要がある

InvalidRequestError at /pay/checkout/
In order to use Checkout, you must set an account or business name at https://dashboard.stripe.com/account.

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

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

存在しないもしくは適当に自身で生成したuuidをactivate-token-uuidの部分に代入してアクティベーションを試みます
http://127.0.0.1:8000/api/users/[発行されていないactivate-token-uuid]/activation/

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

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


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


次回予告

これで実装したいバックエンドの機能は完成しました
次は変更に備えてテストと行きたいところですが、まずはフロントエンドの画面を先に作ろうと思います
テストは少し後回しにしたいと思います


次回記事

shinseidaiki.hatenablog.com

マッチングアプリを個人開発する~Djangoバックエンドその4 動作確認~

この記事はマッチングアプリを自作してみようと試みている記事です
バックエンドを実装しています

前回までの記事はこちらです
shinseidaiki.hatenablog.com


前回はREST API作成まで終わりました
今回は実装したREST API機能がちゃんと動くかを動作確認していきます

目次

動作確認

それではAPIが想定通り正しく動作するか確認を行っていきましょう
DjangoRest APIのテスト実行環境も用意されておりサポートが手厚いのでここでもDjangoにおんぶに抱っこされながら動作確認をしていきましょう

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

さてそれではPyCharmの△ボタンを押してサーバーを起動しましょう

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

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

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


/api/authen/jwt/create/と/api/users/create/以外はJWTトークンなしではアクセスを拒否されることも確認しておく


ユーザー新規作成とJWT認証(/api/authen/jwt/createと/api/users/create/)

サーバ起動後まずはhttp://127.0.0.1:8000/api/にアクセスしましょう
以下のような画面が表示されているはずです
f:id:shinseidaiki:20220212174953p:plain

認証情報が含まれていないといわれて接続を拒否されているのはデフォルトでログインユーザーのみしかAPIにアクセスできないとしているため正しい挙動をしていることが分かります

それではまずはユーザーを作成してそのユーザーのJWTの認証情報を手に入れましょう
手順は以下です

ユーザー新規作成

http://127.0.0.1:8000/api/users/create/
にアクセスします
このエンドポイントはあらゆるアクセスを許可しているのでアクセスできるはずです
以下のように任意のユーザーを作成しましょう

f:id:shinseidaiki:20220212175550p:plain

POSTの送信ボタンを押すとレスポンスが返ってきます

{
    "email": "user1@user.com",
    "username": "user1",
    "id": "uuid-665e........................................."
}

さて管理画面で作成できていることを確認しましょう
http://127.0.0.1:8000/adminのusersを確認しましょう
作成したユーザーがあればよいです

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

f:id:shinseidaiki:20220212180106p:plain

ログイン認証処理とJWT発行

ではユーザーが作成できたので、先ほど作ったemailとpasswordを使用してJWTトークンを発行しましょう
Djoserのモジュールを今回利用しているので/authen/jwt/create/のエンドポイントに正しいemailとpasswordをリクエストボディに詰めて送信すると認証が成功しJWTトークンがレスポンスのaccessパラメータに格納されて返ってきます
返ってきたaccessのパラメータの値をトークンといい今回はJWTトークンという名前がついています
JWTの認識をつけるためにトークンに接頭辞としてJWT をつけるようにsettings.pyで設定しているのですが、取得した[JWT Token] をその後のリクエストに「JWT [JWT Token]」とした値でくっつけて送信すると認証済みユーザーとしてアクセスを許可する仕組みとなっています
この流れがいわゆる世間でいうログインです

それでは動作確認していきますが、ここで新しいToolを使用します
POSTMANというAPIのテスト実行環境を用意してくれるソフトウェアを利用します
まずはインストールして起動してください
www.postman.com

これはDjangoの用意してくれているREST API実行環境と同じようなものなのですがPostmanの方がより機能が多く代表的な機能としてはリクエストボディにパラメータを詰めてリクエストを投げることができます
それではPostmanの以下のような画面を開きます
f:id:shinseidaiki:20220212181531p:plain

必要な項目を埋めていきます
メソッド: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という拡張機能をインストールしてください
インストールして有効化すると右上に表示されます
これを利用していきます

f:id:shinseidaiki:20220212182355p:plain

RequestHeaderに先ほど控えたJWTトークンを貼り付けてトークンを使用するチェックを付けます
f:id:shinseidaiki:20220212182638p:plain
f:id:shinseidaiki:20220307132017p:plain

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

f:id:shinseidaiki:20220212182747p:plain

それではこの認証情報を使いながら他のエンドポイントの動作確認を行っていきましょう

ログイン中のユーザー情報の取得と更新(/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を入力してアクセスしましょう
アクセスできていたこのような画面になっているはずです
f:id:shinseidaiki:20220212194831p:plain

下の入力フォームを使っても変更できますが、ユーザー情報はemailもユーザー名もパスワードも別々の画面を経由して編集することが多いと思いますので、差分更新のPATCHを利用して更新できるかを確認してみようと思います

なお差分更新のPATCHはDjango REST FRAMEWORKの画面では起動できないようなので、PATCHに関してはポストマンで動作確認しましょう
ちなみにPATCHの実行はPUTにpatial=trueを引数に持たせて実装しているようなので、Djangoの実行環境でもがんばったらもしかしたらできるかもしれないですね

emailとユーザー名のPATCH変更

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

f:id:shinseidaiki:20220212195637p:plain

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

emailとユーザー名のPATCH変更

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

f:id:shinseidaiki:20220212200243p:plain

他のユーザーのidをpkに入れても見れないことを確認する

APIからでも管理画面からでもどちらでもいいですが、別のユーザーを作成しましょう
ちなみにこういったデータ動作確認作業に管理画面(/admin)は大変重宝します
自分でいろいろいじってみてください

さて実行しましょう

GET /api/users/[別ユーザーuuid]

以下のように404が確認できれば確認OKです

HTTP 404 Not Found
Allow: GET, PUT, PATCH, HEAD, OPTIONS
Content-Type: application/json
Vary: Accept

{
    "detail": "見つかりませんでした。"
}

ログイン中のユーザー情報の取得と更新(/api/users/profile/{pk}/)

それでは次にユーザーの情報を取得・更新できるエンドポイントが想定通り動作するか確認していきます
このエンドポイントを実行するにはプロフィールが存在しないといけないので、管理画面で先ほど作ったユーザーのどちらでもいいので、Profileクラスのデータを作成してください
なお、今回管理画面ではUserにProfileクラスをインクライン(挿入)しているためUserの中でProfile項目を編集できるようにしています
Profileクラスの詳細画面でProfileデータを追加してもいいですが、外部キーを入れるなど誰のデータをいじっているかわからなくなったりするのでadminにインクラインの設定を加えて作業しやすくしておくことは結構大事だったりします

ユーザー情報取得の確認(GET)

さてプロフィールを作成したらユーザーのUUIDをpkに入力して/api/users/profile/{pk}/にアクセスしましょう
なお、ログインしていない方のユーザーを使っている場合は404が出るはずなのでJWT認証の作業をもう一度行ってください

さてアクセスが成功して以下のような結果が返ってくればデータ取得はうまくいっていることが確認できました

HTTP 200 OK
Allow: GET, PUT, PATCH, HEAD, OPTIONS
Content-Type: application/json
Vary: Accept

{
    "user": "uuid-.............................",
    "is_special": false,
    "is_kyc": false,
    "top_image": "http://127.0.0.1:8000/media/images/top_image/[path]",
    "nickname": "ユーザー1_男性",
    "created_at": "2022-02-12 02:26:00",
    "updated_at": "2022-02-12 03:24:18",
    "age": 20,
    "sex": "male",
    "height": null,
    "location": null,
    "work": null,
    "revenue": null,
    "graduation": null,
    "hobby": null,
    "passion": "slowly",
    "tweet": null,
    "introduction": "",
    "send_favorite": 0,
    "receive_favorite": 0,
    "stock_favorite": 1000
}
ユーザー情報更新の確認(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)にアクセスして、決済が成功したユーザーをアクティブとする処理を加えていきましょう

なお対面などで年齢確認を行ってユーザーをアクティブにする運用の場合はクレジットカード決済を使用せずにその都度管理者が管理画面でユーザーのアクティベーションを手動で行うという方法もあるかもしれない


続きはこちらです

shinseidaiki.hatenablog.com

マッチングアプリを個人開発する~Djangoバックエンドその3 REST API作成~

この記事はマッチングアプリを自作してみようと試みている記事です
バックエンドを実装しています

前回までの記事はこちらです
shinseidaiki.hatenablog.com


前回はモデル作成まで終わりました
今回はバックエンドの機能としてREST APIの機能を実装していきます

目次

Serializersの実装

SerializerとはModelとViewをつなぐインターフェースです
Webアプリケーションは究極的にはデータの作成・参照・更新・削除(通称CRUD)をするためにコードをごちゃごちゃといじっていくものなのですが、SerializerはModelを基にCRUDを自動的に実装してくれるものになっています
一昔前はModelを作成した後にまずはデータ作成機能を作り、参照機能を作り、更新機能を作り、削除機能を作り、といったように手作業で一つ一つ実装していました
しかしながらこのようなCRUD機能に関してはModelが定まれば半機械的に実装できるもののためDjangoでは実際に自動化してくれる機能があり、それがSerializerとなっています

ちなみにDjangoアーキテクチャーの役割はまだいまだに自分も理解しきれていないのですが、Webアプリの設計思想であるMVCモデル的に考えるとDjangoのViewがControllerに対応していて、SerializerがServiceに対応しているイメージでしょうか?
ちなみにDjangoはMVTモデルと言っているのでそう単純には比較できないでしょうがこのくらいの浅い知識でもアプリは実装できるのでいい時代ですね
それではアプリの方のフォルダ(basicapi/)にserializers.pyファイルを作成します

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

まずはユーザーのCRUDをサポートするシリアライザーを作成します
そのためにModelSerializerを継承してシリアライザーを作成します
ModelSerializerを継承すると思考停止でCRUDをサポートしてくれるので、serializers.pyの実装は基本的にはModelSerializerにおんぶに抱っこされる形になろうかと思います

serializers.py

from rest_framework import serializers
from django.contrib.auth import get_user_model

class UserSerializer(serializers.ModelSerializer):

    class Meta:
        model = get_user_model()
        fields = ('email', 'password', 'username', 'id')
        extra_kwargs = {'password': {'write_only': True, 'min_length': 8}}

    def create(self, validated_data):
        user = get_user_model().objects.create_user(**validated_data)
        return user

    def update(self, use_instance, validated_data):
        for attr, value in validated_data.items():
            if attr == 'password':
                use_instance.set_password(value)
            else:
                setattr(use_instance, attr, value)
        use_instance.save()
        return use_instance

def create(self, validated_data) はModelSerializerにもともと備わっているCreateメソッドのオーバーライドをしたものになっています
基本的にはModelSerializerは思考停止でいいのですが、ユーザーに関しては思考停止ではいけません
というのもパスワードを取り扱うためで、ModelSerializerをそのまま使ってしまうと生のパスワードを保存してしまうので、パスワードをハッシュ化する必要があります
そこでユーザーモデルを作成したときに実装したUserManagerのcreate_user()を利用します
create_user()メソッド内にはset_password()のメソッドがあり、これがパスワードの値をハッシュ化しています
validated_dataはユーザー作成に必要なemailやpassword、usernameなどのデータが入っている想定です

fieldsはCRUD機能を自動適用させたいフィールドを指定します
extra_kwargsは指定したフィールドに制約条件を課すことができて上記ではpasswordの文字数は8文字以上という制約条件をつけています

update()は更新処理メソッドのオーバーライドでこれもユーザー作成時同様パスワード変更時にハッシュ化を行う必要があるためにこのような実装を行っています
extra_kwargsでパスワードを書き込み専用にしているのでREST APIを使用しているユーザーがパスワードを取得することはないので二重ハッシュ化によるパスワード破壊は起こらないはずなので大丈夫だとは思うが、ユーザー情報の更新処理は差分更新(PATCHメソッド)で呼び出すこととします
ユーザー情報でパスワード更新とメールアドレスユーザー名の更新画面は画面をそれぞれ分けて利用する設計にします

ProfileSerializerの作成

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

serializers.py

class ProfileSerializer(serializers.ModelSerializer):

    created_at = serializers.DateTimeField(format='%Y-%m-%d %H:%M:%S', read_only=True)
    updated_at = serializers.DateTimeField(format='%Y-%m-%d %H:%M:%S', read_only=True)

    class Meta:
        model = Profile
        fields = (
            'user', 'is_special', 'is_kyc', 'top_image', 'nickname', 'created_at', 'updated_at',
            'age', 'sex', 'height', 'location', 'work', 'revenue', 'graduation',
            'hobby', 'passion', 'tweet', 'introduction',
            'send_favorite', 'receive_favorite', 'stock_favorite'
        )
        extra_kwargs = {'user': {'read_only': True}}

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

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

serializers.py

class MatchingSerializer(serializers.ModelSerializer):
    
    created_at = serializers.DateTimeField(format='%Y-%m-%d %H:%M:%S', read_only=True)

    class Meta:
        model = Matching
        fields = ('id', 'approaching', 'approached', 'approved', 'created_at')
        extra_kwargs = {'approaching': {'read_only': True}}

ダイレクトメッセージシリアライザーの作成

ダイレクトメッセージはマッチング中のユーザーのみがやりとりできるという制約があるので受け手のユーザーをあらかじめマッチングしたユーザーのみにフィルタリングしておきます
フィルタリングしておくことで送り手はマッチングしたユーザーのみを受け手に指定できることになるのでマッチングしたユーザーにのみダイレクトメッセージを送れる機能要件を満たします

フィルタリングの機能の実装はややこしいのでコードを眺めているだけでは解読不能かと思いますので図を作成しました
適当な図ですが理解の助けになれば幸いです

マッチング中のユーザーのみをフィルタリングする

DMを送ることができるのはマッチングしているユーザーのみなので、マッチングしているユーザーのリストを手元に持っておきたいのでマッチング中のユーザーをフィルタリングして取得します
DMの送り手のユーザーのデータはログインユーザーであるので送り手のユーザーはrequest.userで取得ができます
ここでマッチングしているユーザーとは視点を変えれば送り手にいいねを送り返しているユーザーでもあるので、受け手視点の受け手側に送り手が含まれているマッチングモデルのデータをまずは取得してloversに格納します
loversには受け手が送り手(approaching)となっている視点のマッチングモデルのデータが含まれているので、そのapproachingのユーザーIDを使用してユーザーモデルをフィルタリングすると、マッチングしているユーザーのデータだけを取得することができます
上記の内容を実装した結果は以下の通りになります

serializers.py

from django.db.models import Q


class MatchingFilter(serializers.PrimaryKeyRelatedField):

    def get_queryset(self):
        request = self.context['request']
        lovers = Matching.objects.filter(Q(approached=request.user) & Q(approved=True))

        list_lover = []
        for lover in lovers:
            list_lover.append(lover.approaching.id)

        queryset = get_user_model().objects.filter(id__in=list_lover)
        return queryset

ちなみにMatchingFilterはPrimaryKeyRelatedFieldを継承していますがこれは、ModelSerializer を使用する場合に扱うフィールドが外部キーで別テーブルを参照している場合があるので、紐付いたレコードをどのように表示するかを指定するためのものです
基本的には今回のようにフィルタリングをするために使用されることが多そうです
ModelSerializer を使っていて外部キーのフィールドを扱っていてもフィルタリングなどの処理をする必要がなければPrimaryKeyRelatedFieldを特に意識せずともDjangoがよしなに全件取得を行ってくれるようです

DMシリアライザー実装

ダイレクトメッセージシリアライザーでは受け手receiverに格納されているデータは上記で作成したフィルタリングを使用してマッチングされているユーザーのみを扱うようにする
他は同じです

serializers.py

from .models import DirectMessage


class DirectMessageSerializer(serializers.ModelSerializer):

    created_at = serializers.DateTimeField(format='%Y-%m-%d %H:%M:%S', read_only=True)
    receiver = MatchingFilter()

    class Meta:
        model = DirectMessage
        fields = ('id', 'sender', 'receiver', 'message', 'created_at')
        extra_kwargs = {'sender': {'read_only': True}}


これでシリアライザーの実装は一通り完了しました
次はViewを実装していきましょう

Viewsの実装

リアライザーの次はViewを実装していきましょう
Viewの大きな役割としてはURIのエンドポイントごとにどういった処理を実装させるかを記述します
リアライザーで処理のCRUDのインターフェースは作成されているので、Viewで記述する処理内容としてはCRUDのうち何を使用するのかとパーミッションを制御するのが主な実装内容となります
Djangoではビューの実装方法として関数ベースビューとクラスベースビューの二種類がありますが、本アプリではクラスベースビューを利用します
Djangoを利用する際はクラスベースビューを使用したほうが楽で見通しが良いです
処理はブラックボックス化しますが、非本質な処理を徹底的に排除しようという設計思想のDjangoフレームワークを利用する限りはブラックボックスは正義です
関数ベースビューは何をやっているかの処理が分かりやすくなりますがコード量が増えたり冗長になりがちなため特段理由がない限りはクラスベースビューを使用すればいいと思います
ブラックボックスを避けたい場合はSpringBootなどの別のフレームワークを採用するなど技術スタックの選定から検討をした方がいいです

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

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

クラスベースビューで使用するdjango-rest-frameworkのモジュールの機能を予習しておく
分からない場合はいったん流して後で眺めてもらったら良いです

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

他のクラスもあるが例えば以下のように上記のクラスの組み合わせとなる
ListCreateAPIView (POST、GET) = ListAPIView + CreateAPIView

ドキュメント:Generic views - Django REST framework

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

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

ドキュメント:Viewsets - Django REST framework

TIPS データ操作に関する"安全"や"危険"という用語の使い方について

先ほどから出しているCRUDという単語はデータ操作の4種類、登録・取得・更新・削除の操作のことを言っているのですが、このうち元々のデータを破壊する可能性があるデータ操作を"危険"と表現します
データ操作に対して元々のデータは変化しないことが保証されている場合は"安全"と表現されます
この中で元々のデータを破壊する可能性がある"危険"なデータ操作は「更新」と「削除」であり、"安全"なデータ操作は「登録」と「取得」です
REST APIパーミッション切り分けの場合にこの用語が使われたりするので知っておくとよいでしょう

RESTAPI ユーザー作成機能の実装

クラスベースビューを使用するためにはベースビュークラスを継承して利用します
基本的には思考停止でrest_framework.viewsets.ModelViewSetを継承したクラスを作成すればよいですが、ModelViewSetはCRUDすべてがサポートされているためユーザーのようにREST API経由で変更削除されたくないような場合にはCRUDの一部に特化したクラスを継承する方がいい場合があります
今回はREST APIではユーザー作成ができれば十分であるので汎用APIビューのCreateのみをサポートするgenerics.CreateAPIViewを継承したクラスを作成します
また今回settings.pyのREST_FRAMEWORK設定でREST APIはログインユーザー以外はデフォルトで使用禁止にしているので、ユーザー作成の処理だけは例外としてログイン不要で使用可能にする設定を付与します

クラスベースビューで実装したユーザー作成機能は以下のようなコードになります

views.py

from rest_framework.generics import CreateAPIView
from .serializers import UserSerializer
from rest_framework.permissions import AllowAny


class CreateUserView(CreateAPIView):
    serializer_class = UserSerializer
    permission_classes = (AllowAny,)

処理を2行という圧倒的な短さで記述できるのがDjangoの驚異的なところです

余談 Djangoテンプレートの話

ちなみに余談ですが、今回はフロントエンドをNext.jsで実装する予定のためREST API用のベースビューのクラスを rest_framework モジュールから継承していますが、実はNext.jsを使用せずにDjangoだけで画面も作成できるのですが、Djangoだけで画面まで作成する場合はdjango.views.genericのモジュールから各種ベースクラスのビューを同様に継承することで同様に実装することも可能です
この場合はserialiserも不要となったりしてものすごく簡単にアプリを実装することができます
この辺の実装手順はDjangoチュートリアルに載っています
Djangoだけでもアプリ開発は十分に作成できるんだよという話でした
下手なローコードツールを使うよりよっぽど便利で楽だと思います

RESTAPI ユーザー取得更新機能の実装

こちらも取得と更新だけに特化したいのでgenerics.RetrieveUpdateAPIViewを継承します
RetrieveUpdateAPIViewはリクエストにpkのURLのリクエストパラメータがあることが要求されることには注意が必要です
pkと言っているのは/users/{pk}/の{pk}の部分です
ユーザーの場合はUUIDがここに入ってきます

views.py

class UserView(RetrieveUpdateAPIView):
    queryset = User.objects.all()
    serializer_class = UserSerializer

    def get_queryset(self):
        return self.queryset.filter(id=self.request.user.id)

RESTAPI プロフィールCRUD機能の実装

プロフィールは登録・一覧取得・取得・更新・削除のすべての操作を行いたいためModelViewSetを継承します

使用ケースは以下を想定します

登録 ユーザー作成時
一覧取得 異性のプロフィール一覧を取得
取得 特定の異性のプロフィール詳細を取得
更新 自身のプロフィール情報を変更する
削除 拒否する

perform_create メソッドはデータ作成時に登録するデータを指定できるメソッドのことでProfileViewSetではログイン中のユーザーをProfileモデルの外部キーであるuserに登録します
get_querysetでは異性のユーザーをフィルタリングしています
ログインユーザーが男性の場合は女性、ログインユーザーが女性の場合は男性のプロフィール一覧をフィルタリングして取得します

views.py

from rest_framework.viewsets import ModelViewSet
from .models import Profile
from .serializers import ProfileSerializer
from rest_framework import status
from rest_framework.response import Response

class ProfileViewSet(ModelViewSet):
    queryset = Profile.objects.all()
    serializer_class = ProfileSerializer

    def get_queryset(self):
        if hasattr(self.request.user, 'profile'):
            sex = self.request.user.profile.sex
            # Profile.SEX[0][0] = 'male', Profile.SEX[1][0] = 'female'
            if sex == Profile.SEX[0][0]:
                reversed_sex = Profile.SEX[1][0]
            if sex == Profile.SEX[1][0]:
                reversed_sex = Profile.SEX[0][0]
            return self.queryset.filter(sex=reversed_sex)
        return self.queryset.filter(user=self.request.user)

    def perform_create(self, serializer):
        serializer.save(user=self.request.user)

    def destroy(self, request, *args, **kwargs):
        response = {'message': 'Delete is not allowed !'}
        return Response(response, status=status.HTTP_400_BAD_REQUEST)

    def update(self, request, *args, **kwargs):
        response = {'message': 'Update DM is not allowed'}
        return Response(response, status=status.HTTP_400_BAD_REQUEST)

    def partial_update(self, request, *args, **kwargs):
        response = {'message': 'Patch DM is not allowed'}
        return Response(response, status=status.HTTP_400_BAD_REQUEST)

プロフィール削除に関してはAPIを経由して削除できない設計にします
なお、本マッチングアプリでのユーザーの削除はユーザーからなんらかの連絡手段を介して削除要請があった場合に該当するユーザーのis_activeを管理画面からfalseに変更する運用とします

RESTAPI 自己プロフィール取得更新機能の実装

次に自分のプロフィールを参照したり更新したりするためのviewの機能を作成します
クラスベースビューで単独データをとる場合もまずは queryset に [Model].objects.all() すなわち全件取得のクエリを置いておいてその後 get_queryset(self) のメソッドでデータをフィルタリングする実装が一般的なようです

views.py

from rest_framework.generics import RetrieveUpdateAPIView


class MyProfileListView(RetrieveUpdateAPIView):
    queryset = Profile.objects.all()
    serializer_class = ProfileSerializer

    def get_queryset(self):
        return self.queryset.filter(user=self.request.user)

RESTAPI マッチング機能の実装

さてマッチングアプリで最も重要な機能であるマッチング機能を実装していきます
マッチングモデルのデータはログインユーザーがいいねを送ったデータとログインユーザーがいいねを受け取ったデータを取得します
またマッチングデータ作成時、すなわち、いいねを送る際はperform_createで送り手にログインユーザーを指定しておきます
またマッチングモデルには組み合わせ制約の条件を設定しているのでマッチングデータ作成時にバリデーションエラーが発生した場合に備えてtry exceptでエラーハンドリングを行います
また削除(DELETE)と差分更新(PATCH)は使用できないようにしておきます

views.py

from rest_framework.exceptions import ValidationError
from .models import Matching
from .serializers import MatchingSerializer
from django.db.models import Q


class MatchingViewSet(ModelViewSet):
    queryset = Matching.objects.all()
    serializer_class = MatchingSerializer

    def get_queryset(self):
        return self.queryset.filter(Q(approaching=self.request.user) | Q(approached=self.request.user))

    def perform_create(self, serializer):
        try:
            serializer.save(approaching=self.request.user)
        except ValidationError:
            raise ValidationError("User cannot approach unique user a number of times")

    def destroy(self, request, *args, **kwargs):
        response = {'message': 'Delete is not allowed !'}
        return Response(response, status=status.HTTP_400_BAD_REQUEST)

    # partial_update(patch)は使用できるようにする
    # def partial_update(self, request, *args, **kwargs):
    #    response = {'message': 'Patch is not allowed !'}
    #    return Response(response, status=status.HTTP_400_BAD_REQUEST)

RESTAPI メッセージ送信機能の実装

メッセージのREST APIは送信を受信で機能が違うのでクラスビューを分けて実装します
まずはダイレクトメッセージの送信機能を実装します

データ取得はログインユーザーでフィルタリングします
データ作成時はログインユーザーを送り手に格納します
データ削除は出来ないようにします
メッセージ修正ができるようにし、メッセージ変更は基本的に差分変更(PATCH)で行う想定とします

views.py

from .models import DirectMessage
from .serializers import DirectMessageSerializer


class DirectMessageViewSet(ModelViewSet):
    queryset = DirectMessage.objects.all()
    serializer_class = DirectMessageSerializer

    def get_queryset(self):
        return self.queryset.filter(sender=self.request.user)

    def perform_create(self, serializer):
        serializer.save(sender=self.request.user)

    def destroy(self, request, *args, **kwargs):
        response = {'message': 'Delete DM is not allowed'}
        return Response(response, status=status.HTTP_400_BAD_REQUEST)

RESTAPI メッセージ受信機能の実装

受信したダイレクトメッセージは編集が出来てしまっては困るため読み取り専用とするのでReadOnlyModelViewSetを使用します
取得するデータは受信側がログインユーザーとなっているデータをフィルタリングします

views.py

from rest_framework.viewsets import ReadOnlyModelViewSet


class InboxListView(ReadOnlyModelViewSet):
    queryset = DirectMessage.objects.all()
    serializer_class = DirectMessageSerializer

    def get_queryset(self):
        return self.queryset.filter(receiver=self.request.user)


以上で一通りのViewの実装は完了です

URL ルーティングを設定する

ではフロントが操作するためのURLのエンドポイントとどのViewの処理を実行するかを関連づけるルーティングの設定を行っていきましょう
では、アプリ(basicapi)フォルダのurls.pyを編集して実装していきましょう

ここで注意点として、Viewの実装で rest_framework.generics のモジュールのクラスを継承したクラスベースビュー(今回はCreateAPIView, RetrieveUpdateAPIViewを継承しているもの)はルーティングではurlpatternsに直接記載することができるが、rest_framework.viewsets モジュールクラスを継承したクラスベースビュー(ModelViewSet, ReadOnlyModelViewSetを継承しているもの)はrest_framework.routersを使用してルーティングしなければいけないという違いがある
routers.registerの第一引数はURLのパス名となる
urls.pyのコードは全量を記載しておく

urls.py

from rest_framework.routers import DefaultRouter
from django.urls import path
from django.conf.urls import include
from .views import CreateUserView
from .views import ProfileViewSet
from .views import MyProfileListView
from .views import MatchingViewSet
from .views import DirectMessageViewSet
from .views import InboxListView

app_name = 'basicapi'

router = DefaultRouter()
router.register('profiles', ProfileViewSet)
router.register('favorite', MatchingViewSet)
router.register('dm-message', DirectMessageViewSet)
router.register('dm-inbox', InboxListView)

urlpatterns = [
    path('users/create/', CreateUserView.as_view(), name='users-create'),
    path('users/<pk>', UserView.as_view(), name='users'),
    path('users/profile/', MyProfileListView.as_view(), name='users-profile'),
    path('', include(router.urls)),
]

Djasorを利用したときのJWTでのユーザー認証のエンドポイントについて

なお、ユーザー認証すなわちログインログアウトのための機能は djoser のモジュールを最初にインストールしたので、すでに/authen/のエンドポイントで利用することができるようになっている
今回はJWTを利用するので/authen/jwt/create/を利用する
JWTでのユーザーログイン認証の仕組みとしては、リクエストbodyにemail: [email]、password: [password]を正しく入力して、/authen/jwt/create/のエンドポイントを呼ぶと、JWTが生成されログイン成功となります
ログイン後は生成されたトークンを利用してユーザーを認証する仕組みとなっています


以上でREST APIの一通りの実装は完了です
次は動作確認を行っていきましょう

次回予告

長くなったのでここで一度区切ります
次回の記事はこちらです

shinseidaiki.hatenablog.com

マッチングアプリを個人開発する~Djangoバックエンドその2 モデル作成~

この記事はマッチングアプリを自作してみようと試みている記事です
バックエンドを実装しています


前回の記事はこちらです
shinseidaiki.hatenablog.com


前回はDjangoの導入まで終わりました
次にモデルを作成していきましょう

目次

アーキテクチャ設計

さて、続きのAPIを作成する前に前回のガバガバなシステム構成図ではバックエンドの中身をどう作るのかについて全く未検討だったのでここでバックエンド側のアーキテクチャ設計を行います
具体的には、画面遷移図、DB設計、Model設計、View設計、RestAPI URI設計です
なお、Djangoにはコードを元にER図などのドキュメンテーションを作成してくれる機能があるため、ここでは簡単な絵図だけを描くことにします

画面遷移図

画面遷移図はバックエンドのアーキテクチャの設計のイメージがつきやすくなるためここで簡略図を作成する
f:id:shinseidaiki:20220206233859p:plain

ER図

ざっくりとテーブルのリレーションがわかるだけの簡略図を作ります
f:id:shinseidaiki:20220207101401p:plain


ユーザーの実装はDjangoにUserモデルが標準装備されているので、そのUserモデルをカスタマイズすることで実装するのが一般的となっている
その際、基本情報はUserモデルのフィールドとして持ち、nicknameなど新たに持たせたいフィールドは1対1のリレーションで新たに作成したProfileモデルに設定するのが一般的である
またマッチングクラスではいいねの送信者と受信者が双方向にいいねを送り合ったときに双方のapprovedをtrueに変更しマッチングとする設計にする
メッセージモデルに関しては単にUserモデルと1対多の関係性をとるのみとし、マッチングしているユーザー同士のみがメッセージのやり取りができる機能に関してはView側で実装していく
各モデルの他のフィールド(others......)に関しては実装時に具体的にしていく

RestAPI 設計

次にバックエンドで作成するRestAPIの機能とそのURIエンドポイントを設計します
原則としてREST APIはすべてログイン中のユーザーのみが操作できるようにして、それ以外のアクセスは拒否します

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

Djangoの初期設定~設定項目の編集~

Djangoを本格的に開発していくためにはsettings.pyの設定を編集していくことが欠かせませんので、これから開発にあたって必要となってくる設定をここで初めにしておきます

プロジェクト構成に合わせて空フォルダを作成する

以下のようなフォルダ構成のフォルダとファイルを作成する
matchingappapi(プロジェクトルート)
┗ secrets - オープンソースなどでソースコードを外部公開する際に秘匿にしておく情報を扱う
 ┗ .env.dev
 ┗ .env.prod
┗ media - 開発環境のみでクライアントとの画像処理に使用する
┗ .gitignore - githubにアップロードしないファイルを定義する

シークレット情報の環境変数

上記で作成した.envファイルに環境変数を記載する

.env.dev(開発環境)

SECRET_KEY=django-xxxxxxxxxxxxxx
DEBUG=True
ALLOWED_HOSTS=*
CORS_ORIGIN_WHITELIST=http://localhost:3000
DATABASE_URL=sqlite:///db.sqlite3

.env.prod(本番環境 - デプロイする場合)

SECRET_KEY=django-xxxxxxxxxxxxxx
DEBUG=False
ALLOWED_HOSTS=[特定のホスト]
CORS_ORIGIN_WHITELIST=[特定のホスト]
DATABASE_URL=postgres://USER:PASSWORD@HOST:PORT/NAME

なおこの時環境変数に格納する値はクオテーションなしで登録する

DjangoはデフォルトでSQLiteを使用しているため、.env.devではSQLiteを使用することにする
また本番環境ではPostgresを使用するため.env.prodではPostgressを使用する設定とする
大文字になっている箇所は任意の名前を入力する
ちなみに余談ですがHerokuではデプロイした先でデフォルトで環境変数にDATABASE_URL=Postgresのパスが設定されているみたいです

setting.pyの編集

アプリケーション適用

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

INSTALLED_APPS = [
    'basicapi.apps.BasicapiConfig',
]
環境変数適用

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

import environ

env = environ.Env()
root = environ.Path(os.path.join(BASE_DIR, 'secrets'))

# 本番環境
# env.read_env(root('.env.prod'))

# 開発環境
env.read_env(root('.env.dev'))


SECRET_KEY = env.str('SECRET_KEY')

DEBUG = env.bool('DEBUG')

ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=[])

rootに関してはsecretsフォルダを作成したことで変数化しており、プロジェクト直下に配置する構成の場合は記載する必要はなくなる
なお次のような書き方もある
root = environ.Path(BASE_DIR / 'secrets')

パッケージ適用

次に最初にインストールしたpythonパッケージをこれから作るアプリに適用していきます
setting.py

import os
from datetime import timedelta

INSTALLED_APPS = [
    'rest_framework',
    'corsheaders',
    'djoser',
]

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

CORS_ORIGIN_WHITELIST = env.list('CORS_ORIGIN_WHITELIST', default=[])

CORS_ORIGIN_WHITELIST はフロントエンドからのアクセスを許可するためのホワイトリスト設定で、SIMPLE_JWT は認証トークンに使用するJWTの設定です

アクセス制限の適用

次に、バックエンドをREST API化したときのセキュリティの設定としてAPIの利用はデフォルトで認証された(ログイン中)ユーザーのみに制限します
また認証にはJWTを利用することにしており、Tokenの有効期限を1日に設定しています
settings.py

REST_FRAMEWORK = {
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.IsAuthenticated',
    ],
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework_simplejwt.authentication.JWTAuthentication',
    ],
}

SIMPLE_JWT = {
    'AUTH_HEADER_TYPES': ('JWT',),
    'ACCESS_TOKEN_LIFETIME': timedelta(minutes=1440)
}

Djangoの設計自体は認証に関する権限に関してはViewに各画面毎に定義することになっているが、ベストプラクティスとしては基本となる認証ロジックに関してはsetting.pyに記述して差分をViewで適用する方法の方が良い実装だとされている模様で、本アプリではベストプラクティスに沿った実装を行おうと思います

DB設定

次に、Databaseの設定を行います
デフォルト(ローカル)ではsqliteを使用して、本番環境ではPOSTGRESを使用する構成にします
django-environのパッケージを使用すると環境変数でDBも取り扱えるようになる
環境変数にDATABASE_URLが記載されている場合そちらを優先して使用され、DATABASE_URLが無記載の場合はenv.db()がDjangoデフォルトのSQLiteを設定してくれます
settings.pyを以下のように書き換えます
settings.py

# DATABASES = {
#     'default': {
#         'ENGINE': 'django.db.backends.sqlite3',
#         'NAME': BASE_DIR / 'db.sqlite3',
#     }
# }

DATABASES = {
    'default': env.db(),
}
カスタムユーザー適用準備

次に、今後Djangoで扱うユーザーはカスタムユーザーを使用していくので、ユーザーの設定を記載します
settings.py

AUTH_USER_MODEL = 'basicapi.User'
ロケーション変更

次に、ロケーションを日本に変更します
settings.py

LANGUAGE_CODE = 'ja'

TIME_ZONE = 'Asia/Tokyo'
StaticRootの設定

次に、静的ファイルの配信最適化をする設定を行います
本番環境(Heroku)にデプロイする際にばらばらに配置されている静的ファイルを指定した一つのフォルダにまとめてくれます
settings.py

STATIC_ROOT = str(BASE_DIR / 'staticfiles')
Mediaファイルの取り扱い設定

次に、静的ファイルを取り扱えるようにmediaの設定をします
こちらも環境変数化し、本番環境と開発環境を切り分ける必要があるが後々行うこととします
settings.py

# 開発環境
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
MEDIA_URL = '/media/'

またmediaの設定はurls.pyも編集が必要となるので、忘れないうちに以下のコードを追加しておきます
urls.py

from django.conf.urls.static import static
from django.conf import settings

if settings.DEBUG:
    urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

余談として、デフォルトで記述されているTEMPLATES は本アプリでは使用しないためsetting.pyから削除してもよいはずです

URLの設定

プロジェクト側urls.pyの設定

後でもいいですが、ここでルーティングのガワを決めておきます
matchingappapi/urls.py

from django.conf.urls import include

urlpatterns = [
    path('api/', include('basicapi.urls')),
    path('authen/', include('djoser.urls.jwt')),
]
アプリ側urls.pyの設定

basicapi直下にurls.pyを作成してアプリのルーティングのガワを記載しておきます

basicapi/urls.py

from rest_framework.routers import DefaultRouter
from django.urls import path
from django.conf.urls import include

router = DefaultRouter()

app_name = 'basicapi'
urlpatterns = [
    path('', include(router.urls)),
]

.gitignoreの編集

さて上記の設定作業では外部に流出したくないファイルを作成したので、そのファイルたちはGitHubにアップロードしないように設定したいので、.gitignoreを編集していきます
現時点では以下のあたりを記述しておく

.gitignore

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


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

Modelの実装

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

カスタムユーザーの実装

まずはカスタムユーザーを実装していきます
Djangoのドキュメントによると最初のMigrationでカスタムユーザーを使用しないとデフォルトユーザーを使用することになってしまい、さらにドキュメントにはカスタムユーザーの使用を推奨すると記載されているので、実質Djangoの初期作業となっています

カスタムユーザーの作り方としてはAbstractUserを継承する方法と、AbstractBaseUserを継承する方法の2種類がある

  • AbstractUser: すでに存在するフィールドをそのまま流用して、フィールドの増減を行うことができる

ただし、メールアドレスをログインIDにするなどのことができないため、限定的な利用にとどまる

  • AbstractBaseUser: Userモデルをほとんどゼロベースから作成することが可能

自由度が高くこちらをカスタムユーザーとするのが一般的で本アプリでもAbstractBaseUserでカスタム実装していきます

それではアプリのモデルを編集します
basicapi/models.pyを編集します

ユーザーマネージャーの実装

AbstractBaseUserを使用する場合はデータ挿入取得更新処理をするマネージャークラスを作成する必要があるのでマネージャークラスをまずは作ります
Djangoのデフォルトではユーザー名がログインに使用されますが、本アプリのカスタムユーザーはemailをユーザー名として利用するようにします

models.py

from django.contrib.auth.models import BaseUserManager

class UserManager(BaseUserManager):

    def create_user(self, email, password=None, **extra_fields):
        if not email:
            raise ValueError('Users must have an email address')

        user = self.model(email=self.normalize_email(email), **extra_fields)
        user.set_password(password)
        user.save(using=self._db)

        return user

    def create_superuser(self, email, password):
        user = self.create_user(email, password)
        user.is_staff = True
        user.is_superuser = True
        user.save(using=self._db)

        return user
ユーザークラスの実装

ユーザーマネージャーが作成できれば次はユーザークラスを作成します
models.pyにユーザークラスを追記します

models.py

from django.contrib.auth.models import AbstractBaseUser,  PermissionsMixin
import uuid

class User(AbstractBaseUser, PermissionsMixin):

    id = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False)
    email = models.EmailField(max_length=255, unique=True)
    username = models.CharField(max_length=255, blank=True)
    is_active = models.BooleanField(default=False)
    is_staff = models.BooleanField(default=False)

    objects = UserManager()

    USERNAME_FIELD = 'email'

    def __str__(self):
        return self.email

idにはuuidを使用しています
is_staffは管理者画面にアクセスできる権限を付与してしまうので、基本デフォルトでFalseにします
またマッチングアプリの利用者は年齢確認をしなければならないという制約があるので、アカウントの有効化はクレジットカード決済が有効だったユーザーのみに限定したいのでデフォルトでis_activeをFalseにします
アカウントの有効化機能はREST APIを作成する中で作成していきます

Profileクラスの実装

カスタムユーザーを作成する場合は通常セットで作成されるクラスです
nicknameなどの新たなフィールドをユーザークラスに追加したいが、ユーザークラスを煩雑にしたくないケースに頻繁に利用されます
カスタムユーザーを作成するときにはProfileクラスをセットで作成するのがデファクトスタンダードになっているといっても遜色ないかと思います
Userと1対1の関係をとるのでOneToOneFieldを利用する

マッチングアプリは性別や年齢が大切になってくるので必要な最低限の項目を追加したものをまずは作ります

models.py

from django.conf import settings
from django.core.validators import MinValueValidator, MaxValueValidator


class Profile(models.Model):

    user = models.OneToOneField(
        settings.AUTH_USER_MODEL, primary_key=True, on_delete=models.CASCADE, related_name='profile')
    is_kyc = models.BooleanField("本人確認", default=False)
    nickname = models.CharField("ニックネーム", max_length=20)
    created_at = models.DateTimeField("登録日時", auto_now_add=True)
    updated_at = models.DateTimeField("更新日時", auto_now=True, blank=True, null=True)
    age = models.PositiveSmallIntegerField(
        "年齢", validators=[MinValueValidator(18, '18歳未満は登録できません'),
                          MaxValueValidator(100, '100歳を超えて登録はできません')])
    SEX = [
        ('male', '男性'),
        ('female', '女性'),
    ]
    sex = models.CharField("性別", max_length=16, choices=SEX)
    introduction = models.TextField("自己紹介", max_length=1000)

    def __str__(self):
        return self.nickname

次に既存のアプリでは他の項目もたくさんあるので、主にPairsを参考にその他のフィールドも追加していきます
プロフィールクラスにさらに項目を追加した結果が以下です
一気にコードが増えました

models.py

from datetime import datetime, timedelta


def top_image_upload_path(instance, filename):
    ext = filename.split('.')[-1]
    return '/'.join(['images', 'top_image', f'{instance.user.id}{instance.nickname}.{ext}'])


class Profile(models.Model):

    user = models.OneToOneField(
        settings.AUTH_USER_MODEL, primary_key=True, on_delete=models.CASCADE)

    """ Profile Fields """
    is_special = models.BooleanField(verbose_name="優良会員", default=False)
    is_kyc = models.BooleanField(verbose_name="本人確認", default=False)
    top_image = models.ImageField(
        verbose_name="トップ画像", upload_to=top_image_upload_path, blank=True, null=True)
    nickname = models.CharField(verbose_name="ニックネーム", max_length=20)
    created_at = models.DateTimeField(verbose_name="登録日時", auto_now_add=True)
    updated_at = models.DateTimeField(verbose_name="更新日時", auto_now=True, blank=True, null=True)

    """ Physical """
    age = models.PositiveSmallIntegerField(
        verbose_name="年齢", validators=[MinValueValidator(18, '18歳未満は登録できません'),
                          MaxValueValidator(100, '100歳を超えて登録はできません')])
    SEX = [
        ('male', '男性'),
        ('female', '女性'),
    ]
    sex = models.CharField("性別", max_length=16, choices=SEX)
    height = models.PositiveSmallIntegerField(
        verbose_name="身長", blank=True, null=True,
        validators=[MinValueValidator(140, '140cm以上で入力してください'),
                    MaxValueValidator(200, '200cm以下で入力してください')])

    """ Environment """
    LOCATION = [
        ('hokkaido', '北海道'),
        ('tohoku', '東北'),
        ('kanto', '関東'),
        ('hokuriku', '北陸'),
        ('chubu', '中部'),
        ('kansai', '関西'),
        ('chugoku', '中国'),
        ('shikoku', '四国'),
        ('kyushu', '九州'),
    ]
    location = models.CharField(verbose_name="居住エリア", max_length=32, choices=LOCATION, blank=True, null=True)
    work = models.CharField(verbose_name="仕事", max_length=20, blank=True, null=True)
    revenue = models.PositiveSmallIntegerField(verbose_name="収入", blank=True, null=True)
    GRADUATION = [
        ('junior_high_school', '中卒'),
        ('high_school', '高卒'),
        ('trade_school', '短大・専門学校卒'),
        ('university', '大卒'),
        ('grad_school', '大学院卒'),
    ]
    graduation = models.CharField(
        verbose_name="学歴", max_length=32, choices=GRADUATION, blank=True, null=True)

    """ Appealing Point """
    hobby = models.CharField(
        verbose_name="趣味", max_length=32, blank=True, null=True)
    PASSION = [
        ('hurry', '今すぐにでも'),
        ('speedy', '1年以内に'),
        ('slowly', 'ゆっくり考えたい'),
        ('no_marriage', '結婚する気はない'),
    ]
    passion = models.CharField(
        verbose_name="結婚に対する熱意", max_length=32, choices=PASSION, blank=True, null=True, default='slowly')
    tweet = models.CharField(verbose_name="つぶやき", max_length=8, blank=True, null=True)
    introduction = models.TextField(verbose_name="自己紹介", max_length=1000, blank=True, null=True)

    """ Assesment Fields """
    send_favorite = models.PositiveIntegerField(
        verbose_name="送ったいいね数", blank=True, null=True, default=0)
    receive_favorite = models.PositiveIntegerField(
        verbose_name="もらったいいね数", blank=True, null=True, default=0)
    stock_favorite = models.PositiveIntegerField(
        verbose_name="いいね残数", blank=True, null=True, default=1000)

    class Meta:
        ordering = ['-created_at']

    def from_last_login(self):
        now_aware = datetime.now().astimezone()
        if self.user.last_login is None:
            return "ログイン歴なし"
        login_time: datetime = self.user.last_login
        if now_aware <= login_time + timedelta(days=1):
            return "24時間以内"
        elif now_aware <= login_time + timedelta(days=2):
            return "2日以内"
        elif now_aware <= login_time + timedelta(days=3):
            return "3日以内"
        elif now_aware <= login_time + timedelta(days=7):
            return "1週間以内"
        else:
            return "1週間以上"

    def __str__(self):
        return self.nickname

verbose_nameはフィールドの詳細名です
validatorsはバリデータを設定できる項目で最小値や最大値などの制約を加えることができます
choicesは選択式のフィールドに対応する項目です
選択肢に対応する値はタプルの配列で格納します
DB上ではKeyが格納されコード上では辞書型として提供されます
最終ログイン日を教えてくれるサービスがあるので本アプリもそれにならいます
Userクラスのコードの見通しはよくしておきたいのでProfileクラスにメソッドを定義します
もしかしたらアンチパターンかもしれませんが......

またDjangoのモデルのフィールドはキャメルケースと迷いましたがDjangoに書いたフィールド名がDBのフィールド名にそのまま記載されたはずなので本アプリではスネークケースで書いています
デファクトスタンダードなのはどっちなのか正直わかっていません

Matchingクラスの実装

次にマッチングクラスを実装します
いいねを送った人ともらった人を格納するテーブルです
マッチングの実装については、いいねをもらった側のユーザーが承認(approvedをTrue)すると同時に相手側にいいねを送り返して双方向のいいねが成立した時点でマッチングとします
マッチング後、すなわちapprovedがTrueになっている場合にメッセージをやり取りできるような実装とします
またちなみにDjangoでは複合主キーはサポートされていないですが、フィールド同士の組み合わせ制約をunique_togetherで付与することができ、マッチングの重複を避けることができます

models.py

class Matching(models.Model):
    approaching = models.ForeignKey(
        settings.AUTH_USER_MODEL, related_name='approaching',
        on_delete=models.CASCADE
    )
    approached = models.ForeignKey(
        settings.AUTH_USER_MODEL, related_name='approached',
        on_delete=models.CASCADE
    )
    approved = models.BooleanField(verbose_name="マッチング許可", default=False)
    created_at = models.DateTimeField(verbose_name="登録日時", auto_now_add=True)

    class Meta:
        unique_together = (('approaching', 'approached'),)

    def __str__(self):
        return str(self.approaching) + ' like to ' + str(self.approached)

最初に設計したER図とずれがあるのは気にしないで下さい
実装が正です

DM(ダイレクトメッセージ)クラスの実装

最後にDirectMessageクラスの実装を行います
マッチングが成立している(MatchingクラスのapprovedがTrueになっている)ユーザー同士のメッセージが格納されます
余力がある人は画像データなどのマルチメディアのデータを送れるような実装にしてもよいかもしれませんが、このアプリではテキストデータのみを扱えることとします

models.py

class DirectMessage(models.Model):

    sender = models.ForeignKey(
        settings.AUTH_USER_MODEL, related_name='sender',
        on_delete=models.CASCADE
    )
    receiver = models.ForeignKey(
        settings.AUTH_USER_MODEL, related_name='receiver',
        on_delete=models.CASCADE
    )
    message = models.CharField(verbose_name="メッセージ", max_length=200)
    created_at = models.DateTimeField(verbose_name="登録日時", auto_now_add=True)

    def __str__(self):
        return str(self.sender) + ' --- send to ---> ' + str(self.receiver)

他にも作成したほうが良いモデルクラスは多分にありますが、今回はここまでの実装とします
参考にしているPairsにはコミュニティ機能があるので余力があればコミュニティクラスを作ったりタグクラスを作ったりしてもよいかもしれません

Migrationの実行

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

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

なお、makemigrations を行った際にbasicapi/migrationsにスキーマファイル(ex. 0001_initial.py)ができていることを確認することをお勧めします
これで先ほど作ったモデルがDBに構築されました

管理者Adminサイトの整備

管理者画面を扱いたい場合はアプリフォルダ(basicapi)にあるadmin.pyを編集していく必要があります

カスタムユーザーのモデルクラスを管理画面で扱えるようにする

管理者画面にカスタムユーザーを追加

basicapi/admin.pyを以下のように編集してきます
admin.py

from django.contrib import admin
from .models import User
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin


class UserAdmin(BaseUserAdmin):
    ordering = ('id',)
    list_display = ('email', 'password')
    fieldsets = (
        (None, {'fields': ('email', 'password')}),
        ('Personal Information', {'fields': ('username',)}),
        (
            'Permissions',
            {
                'fields': (
                    'is_active',
                    'is_staff',
                    'is_superuser',
                )
            }
        ),
        ('Important dates', {'fields': ('last_login',)}),
    )
    add_fieldsets = (
        (None, {
           'classes': ('wide',),
           'fields': ('email', 'password1', 'password2'),
        }),
    )


admin.site.register(User, UserAdmin)
Profileクラスをカスタムユーザーの詳細画面に挿入する

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

admin.py

from .models import User, Profile


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

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

同様にadmin.pyを編集してユーザー以外のデータも見られるようにします

Prolileを管理者画面に追加

admin.py

from .models import Profile


class ProfileAdmin(admin.ModelAdmin):
    ordering = ('-created_at',)
    list_display = ('__str__', 'user', 'age', 'sex', 'tweet', 'created_at', 'from_last_login')


admin.site.register(Profile, ProfileAdmin)
Matchingを管理者画面に追加

admin.py

from .models import Matching

class MatchingAdmin(admin.ModelAdmin):
    ordering = ('-created_at',)
    list_display = ('__str__', 'approved', 'created_at')


admin.site.register(Matching, MatchingAdmin)
DirectMessageを管理者画面に追加

admin.py

from .models import DirectMessage

class DirectMessageAdmin(admin.ModelAdmin):
    ordering = ('-created_at',)
    list_display = ('__str__', 'message', 'created_at')


admin.site.register(DirectMessage, DirectMessageAdmin)

管理者ユーザー(Superuser)の作成

管理者画面にアクセスできるユーザーを作成しておきましょう
Djangoのデフォルトユーザーではusernameが作成時に必要ですがカスタムユーザーが適用されているとemailの設定が必要になっていることを確認できるはずです

py manage.py createsuperuser

管理画面の動作確認

管理者ユーザーを作成したらサーバーを起動して管理者画面にアクセスしてみましょう
http://127.0.0.1:8000/admin

emailとパスワードでログインできることを確認します
また実装したモデルが管理画面に表示されて追加などの各種CRUD操作ができることを確認します
f:id:shinseidaiki:20220210000747p:plain
またユーザーのパスワードがハッシュ化されていることやプロフィールがインラインで表示されていることも確認しておきます
これらが一通り確認できれば動作確認としては完了です


次回予告

さて、次はSerializersとViewsの実装を行っていよいよRestAPIを作成していきますが、長くなりましたのでここでいったん区切らせてもらいます

次の記事

shinseidaiki.hatenablog.com


今後の課題

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

マッチングアプリを個人開発する~Djangoバックエンドその1 環境構築~

はじめに

マッチングアプリはWebアプリの基本を学べるよい教材だと感じるので、バックエンドにDjango・フロントエンドにFlutterを利用して個人開発で自作してみようと思います
なお実運用したい場合は有償無償を問わず「インターネット異性紹介業」の開業を警視庁に申請しなければならないのと、免許証もしくはクレジットカードを必ず扱うため個人情報の扱いを厳重に行う必要があります
実装とは別に運用面でハードルがありますが、マッチングアプリの自作に挑戦してみたい方の参考になれば幸いです
また、手前味噌で恐縮ですが、この記事はかなり詳しめに書いていまして、初学者にとってはかゆいところに手が届く内容にもなっているので、バックエンド・フロントエンド・デプロイにかけてのフルスタックなのWEBアプリ開発を一通り学んでみたい方にも特におすすめです
想定読者としては、プログラミングを一通り学んでおり、WEBアプリの仕組みについてもなんとなくわかるが、具体的な開発についてはどこから手を付けていいかわからないという人が最適かと思います

マッチングアプリはおそらく実運用が最も肝心なので開発は一つの通過点に過ぎないです
パパっと作ってスモールスタートする方がよさそうな気はしますね

では始めましょう


目次

要件定義

まずは作る目的、業務要件、機能要件、システム構成をざっくり整理しましょう
開発プロジェクトの最も基本であり、目的が重要であればあるほど作るモチベーションにも影響します

目的

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

達成条件

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

業務要件

Must

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

Try

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

機能要件

Must

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

Try

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

システム構成

簡略図(draw.ioで作成)
f:id:shinseidaiki:20220206095532p:plain

※Next.jsではなくFlutterを使いました

さて一通り要件定義を済ませたので早速開発していきましょう

開発環境

バックエンド開発 Django

まずはバックエンド側の開発です
Django(ジャンゴと読みます)を用いてRestAPIを作成していきます
ちなみにDjangoPythonを元にしたWebアプリ開発フレームワークの一つで、YoutubeInstagramなど有名なサービスにも使われている実績のあるフレームワークです

バックエンド側Djangoの開発環境

  • Django バージョン:4.0.2
  • Python バージョン:3.8
  • 仮想環境:Anaconda Navigator Individual Edition
  • エディタ:PyCharm

インストール~環境構築

以下ではpythonとAnaconda NavigatorとPyCharmを利用します
インストールしていない人はインストールしてください

最新版をインストール
Download Python | Python.org

  • Anaconda Navigator

なお、個人向け無料のエディションはAnaconda Individual Edition
Anaconda | Individual Edition

  • PyCharm

無料で使用したいためCommunityの方を選択
ダウンロード PyCharm:JetBrainsによるプロの開発者向けのPython IDE

1. 仮想環境作成

まずは仮想環境を作成しましょう
ここで言っている仮想環境とはDjangoでこれからいろいろなパッケージをインストールして使っていくので、そのバージョン管理を行うための環境です
これを行わないとPC上に直接パッケージがインストールされて他のアプリ開発をするときに面倒なことが起きる可能性があります
特段理由がなければ少し手間はあるがこちらの手順を行っておくのが吉

Anaconda Navigatorを起動する
その後、Environments>Createで新たな仮想環境作成を選択
後は画面に従って、Nameに「Matching_App_API」などを入力していきcreateを押下

2. ターミナルを開き、必要なパッケージをインストールする

Anaconda Navigatorで先ほど作った環境の再生ボタン(△ボタン)をクリックしてターミナルを開き
コマンドラインにpip installを使うことで以下のパッケージをまずはインストールする

pip install ○○
  • django - これから開発に使用するバックエンドのフレームワーク
  • djangorestframework - Rest APIが使用できるようになる
  • djangorestframework-simplejwt - JWTと呼ばれる最近使用されるセキュリティレベルの高いTokenを使用した認証機構(simpleがついていない方も存在したが更新日時がもはや5年前だった)
  • djoser - JWTを便利に使用するためのパッケージ
  • django-cors-headers - バックエンドとフロントエンドをクロスエンジンでつなぐ
  • pillow - 画像を扱うためのパッケージ
  • django-environ - 環境変数を扱うパッケージ

他のパッケージは必要に応じてインストールしていく
インストールが終わったらターミナルを閉じる

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

任意の場所にプロジェクトルートとなるフォルダを作成する
例:C:\Users\[YOURNAME]\projects\matching-app

Pycharmを起動する
今作成したそのフォルダを開く
File>Settingを選択
Project>Python Interpreterを選択して、画面の歯車マークからAddを選択する
Existing environmentを選択して、...マークを選択する
%USERPROFILEフォルダ以下で envs\[仮想環境の名前]\python.exe を選択する
例: envs\Matching_App_API\python.exe
すると、Python Interpreterに先ほど作成した仮想環境Matching_App_APIが適用され、Djangoのコマンドなどが利用できるようになる
OKを押して、PyCharmを再起動するとこの仮想環境が適用される

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

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

django-admin startproject matchingappapi

そしてプロジェクトを作成したら、cdで今作ったプロジェクトフォルダ直下に下るか、PyCharmで今作成したプロジェクトフォルダ直下にフォルダを開きなおすかします
今回は開きなおす方で行います
なお、同じフォルダが二つ作成されているが、プロジェクトフォルダ直下と言っているのは matching-app下のmatchingappapiフォルダのことです
matching-app
┗matchingappapi
 ┗ matchingappapi
なお、フォルダを開きなおすと先ほどの3の手順で行ったPyCharm仮想環境適用の設定をもう一度行う必要があるので、3の手順をもう一度行う

次にアプリを作成する
アプリ名はなんでもよいがここではbasicapiとして作成する

django-admin startapp basicapi

ここまで終わるとフォルダ構成がこのようになっているはずです
matching-app
┗ matchingappapi
 ┗ matchingappapi
 ┗ basicapi
 ┗ manage.py

5. サーバーの起動

ここまででWebアプリの初期導入は完了しています
なので、一度サーバーを起動してアプリが立ち上がるかを動作確認してみましょう

プロジェクト直下の manage.py を右クリックしてrun 'manage'をクリック
そのあと、PyCharm画面の右上にmanageボタンが表示されるので、クリックしてEdit Configurationsを選択する
parametersの箇所に runserver を追記する
この設定を行うことでPyCharmの右上の再生ボタンを押すことでサーバーを起動させることができるようになる

なお他の方法としては、ターミナルで

py manage.py runserver

と入力することでもサーバーを起動することができる

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

次回予告

これからDjangoの本格的な開発に入っていこうと思いますが、長くなるのでいったん記事を区切ろうと思います
続きは、Djangoの設定をいじってモデルを作成し、ユーザー一覧を表示させられるまでのAPIの機能を作成しようと思います


次の記事

shinseidaiki.hatenablog.com

Powershell上でyarnの実行ができない場合の対処法

Powershell上でyarnの実行ができない場合の対処法

PowerShellのセキュリティまわりの設定が原因である場合、以下の記事に対処法が書いてある
https://qiita.com/ponsuke0531/items/4629626a3e84bcd9398f
hoge.psはyarn.psに読み替える
結論としてはこのコマンドを実行する

Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Process

powershellでyarnを実行させたいときは毎回このコマンドをたたくこと
根本的な解決方法ではなく、毎回コマンドを叩かないといけなくてめんどくさいが他の方法が分からなかったため暫定

※npmを実行できない場合も同様のコマンドで対処が可能となる

REST API設計のTIPS


URI設計

単語は複数形にする


api/user/1
✖︎ api/users/1

 

単語はハイフンでつなげる※ハイフンでつなげるくらいなら区切る

api/users-matching
api/users/matching

 

レスポンスデータフォーマット

エラーにHTMLを含めない
サービス閉塞時は503を返して、クライアント側がいつ再開できるか分かるようにしてあげる

 

セマンティックバージョニング

1.2.3のような表記のバージョン記法のこと

メジャー: 1にあたるところ: 後方互換しない大きな機能変更
マイナー: 2にあたるところ: 後方互換する機能追加変更
パッチ: 3にあたるところ: 後方互換するバグ修正

 

 

 

 

 

【開発ナレッジ】Django実践アプリ機能実装ナレッジ~環境変数設定・決済機能・カスタムユーザー・ログイン処理・非同期処理・キャッシュ制御・ログ・OAuth認証・バッチ処理~

Udemyの学習で学習した Django の実践的な機能の実装方法のナレッジをまとめます

2種類の講座の学習まとめ

講座1: 【中級者向け】Django3でショッピングアプリ開発 - ECサイト構築で行うPython・WEBアプリケーション開発
https://github.com/TakumaFujimoto/vegeket_project

講座2: 最新版:Python + Django3 Djangoを基礎から応用まで、アプリケーション開発マスターpython付き


できること(抜粋)

講座1

講座2

  • OAuth認証
  • バッチ処理
  • ログ出力
  • ログイン処理
  • AJAX非同期処理とキャッシュ制御
  • Python基本文法
  • 関数ベースビュー
  • クラスベースビュー
  • Form
  • エラーハンドリング


目次

自環境

  • OS:Windows 10
  • シェル:powershell
  • Python:3.8系
  • エディター:PyCharm
  • 仮想環境:Anaconda Navigator

ECサイトの構築

仮想環境の準備は以下参照
https://shinseidaiki.hatenablog.com/entry/2021/11/07/232602


使うパッケージ requirements.txt

asgiref==3.4.1
certifi==2021.5.30
charset-normalizer==2.0.3
Django==3.2.5
django-environ==0.4.5
idna==3.2
Pillow==8.3.2
pytz==2021.1
requests==2.26.0
sqlparse==0.4.2
stripe==2.60.0
urllib3==1.26.6

requirements.txtをインストールする場合は上記ファイルを任意のフォルダに配置して、
仮想環境になっていることを確認して
以下のコマンドを仮想環境で実行する

pip install -r requirements.txt

なお記事投稿者が記事記入時はDjangoが4にアップグレードされていたため、上記の方法で環境を合わせた

初期準備

django-admin startproject udemyec
django-admin startapp base

フォルダの作成
templates, secrets, static

Tips!

setting.pyのBASE_DIRはpathlibの組み込み変数でプロジェクトのルートパスを差す
BASE_DIR / 'templates' の書き方でもOSに応じて臨機応変にパスを作ってくれる

Secretsフォルダの利用

GitHubにおおよそのことは書いていた
https://github.com/TakumaFujimoto/vegeket_project/blob/main/sec03/README.md

環境変数を扱うためにインストールする
django-environ

環境変数を作成する

secretsフォルダ直下に以下を作成

.env.dev
.env.prod

環境に応じた環境変数を用意しておくことで開発を円滑に行うことが出来るようにする
.env.devは開発環境用
.env.prodは本番環境用(productの略)

環境変数に格納する値はクオテーションなしで登録

.env.dev(開発環境)

SECRET_KEY=django-xxxxxxxxxxxxxx
DEBUG=True
ALLOWED_HOSTS=*

.env.prod(本番環境 - デプロイする場合)

SECRET_KEY=django-xxxxxxxxxxxxxx
DEBUG=false
ALLOWED_HOSTS=[特定のホスト]

setting.pyに環境変数を適用

import environ

env = environ.Env()
root = environ.Path(BASE_DIR / 'secrets')

# 本番環境
# env.read_env(root('.env.prod'))

# 開発環境
env.read_env(root('.env.dev'))


SECRET_KEY = env.str('SECRET_KEY')

DEBUG = env.bool('DEBUG')

ALLOWED_HOSTS = env.list('ALLOWED_HOSTS')

rootはプロジェクト直下に.envファイルを置く場合は記述する必要はない
本番環境と開発環境で自動で分ける場合はif文を使って条件分岐をする必要がある模様
具体的な例はわからなかったが、以下が参考になるかもしれない
https://ikura-lab.hatenablog.com/entry/2019/06/16/142339

.gitignoreに関してはこちらの記事などを参照にする
どこかでまとめる必要がある
https://leading-tech.jp/wiseloan/gitignore/

このあたりを記述

*.log
*.pyc
__pycache__/
secrets/
db.sqlite3

モデルとかテンプレートの作成

省略

この人のフォルダ構成はtemplatesフォルダ内に以下のフォルダとファイルがある
base.html
pagesフォルダ
snippetsフォルダ

modelsフォルダを作成してその直下に__init__.pyファイルを配置すればフォルダがmodelsとして認識される
modelsが増えてきた場合に有効
フォルダ化にはmodelごとに自由なファイル名のモデルを定義することができる
なお、initiは初回に読み込まれるので、以下のような記述を行っておくとモデルが読み込まれる
__init__.py

from .item_models import *

画像を別のストレージで使用する場合については別途調査が必要

Itemの外部キーのcategoryのondeleteについてはon_delete=models.SET_NULLにした方がよい
なぜならカテゴリ削除と同時にItemを削除したくはないから
カテゴリが削除された場合はNullを詰める

ManyToManyでリレーションをとる場合はtagsのような複数形の名前にする方が慣例の模様
ManyToManyの場合は中間テーブルが内部的に作成されて直接つながっていないためondeleteは設定する必要がない

ここではカスタムユーザーモデルは先に作ってない
https://github.com/TakumaFujimoto/vegeket_project/blob/main/sec06/README.md

admin画面はunregisterを使用すると非表示にすることもできる

クラスベースビューと関数ベースビューの違い

クラスベース

class IndexListView(ListView):
    model = Item
    template_name = 'pages/index.html'


関数ベースで書いた場合

def index(request):
  object_list = Item.objects.all()
  context = { 'object_list': object_list, }
  return render(request, 'index.thml', context )
Tips!

for文の中で使える便利なものが存在する
{{ forloop }}
for文が何回回ったかなどを出力できる

Tips! get post

def postやdef getをクラスベースビューで記述するとPOSTの時やGETメソッドの時に実行される関数を記述することができる

class AddCartView(View):
    def post(self, request):
Tips! OrderdDict

OrderedDict()はPython標準ライブラリで搭載されている
辞書の場合、追加するときにListと違って順番が保持されないために順番を保持するために使用できるメソッド
順序月の辞書が作れる

Tips! プチ環境変数

消費税率などの見られてもよい環境変数にしたい情報はsetting.pyに直接記入できる

TAX_RATE = 0.1
Tips! get_queryset, get_context_data

get_querysetについて

データをDBなどから取得するメソッド
ListViewがget_querysetをもともと持っており、デフォルトではモデルのすべてのアイテムを返すようになっている
カスタマイズする場合はget_querysetをオーバーライドする
また、obj.quantityやobj.subtotal のようにmodelsに定義されていないquantityのような値はviewにおいて新たに定義することのできるインスタンス変数となり、DBには保存されないがプログラムで自由に使用することができるようになる
あらかじめモデルで定義していなくても自由にインスタンス変数を追加することができる
self.queryset = []で定義してフォー分で回して、self.queryset.append(obj)したりしてquerysetを作成するようにして作る

get_context_data

テンプレートに値を渡すメソッド
context["total"] = self.total のような記述をすることでテンプレート上でtotalというキーで値にアクセスすることができるようになる
また以下のようにjsonから辞書型に変換して画面に値を渡すことも可能
context["items"] = json.loads(obj.items)

Tips!

管理画面者の編集
以下のadminの中のファイルローカルにコピーして上書きすることで編集することが可能(元ファイルは当然ながら編集しない)
https://github.com/django/django/tree/main/django/contrib/admin/templates

例えば、base_site.htmlをtemplateルートフォルダ下(setting.pyのDIRSのところで設定している場所、基本はプロジェクト直下にテンプレートを指定することが一般的)にadminフォルダを作成し、その下にgithub上のdjangoテンプレートと同じフォルダ構成でファイルを配置する
base_site.htmlの内容を上書きするとadminサイトに変更が反映される

CSSを書き換えたい場合は 管理画面はstatic: admin/css/base.cssを継承しているためこのファイルを作成して継承すればいい(CSSなのでstaticフォルダ以下であるところに注意)
Github上の場所は以下
https://github.com/django/django/tree/main/django/contrib/admin/static/admin/css


決済機能

決済機能についての簡単な説明がある
https://github.com/TakumaFujimoto/vegeket_project/blob/main/sec10/README.md

ここではStripeを利用する
Stripeで開発者向けの開発用の決済機能を無料で試すことができる
Stripe: 世界で最もシェアを獲得するクレジットカード決済代行サービス
各種セキュリティ・法規制に対応しているため決済代行サービスを使わないで課金が必要な自作サービスを実装する選択肢はほぼ皆無だと思われる(特に個人)
使用時の手数料3%以外の費用がないため個人でマッチングアプリを実装したい場合(年齢確認用)などで利用できる

Stripeのドキュメント
Stripe のドキュメント

Stripe使用準備

アカウントの作成
上記ストライプの公式サイトからアカウントを作成する(メール認証があるので認証を済ませる)
アカウントを作成するとダッシュボードに移動する
最初から開発環境を提供してくれている模様
本番環境を利用する場合は別途申請が必要となる
ここではすべて開発環境だけを使用する

Stripeパッケージのインストール

pip install stripe
Stripeに使用するテンプレート画面の作成

Stripeドキュメントに記載されるテンプレートを使用する
https://stripe.com/docs/payments/checkout
こちらにサンプルプロジェクトが記載されている
https://stripe.com/docs/checkout/quickstart
講座の時とは内容が更新されている模様
フィルタリングがあるので、HTML PYTHONを選択して今回はPythonのテンプレートを表示して模倣する

テンプレートとしては以下がある

  • server.py
  • checkout.html チェックアウト、ECサイトにおいてはショッピングカートページを差す画面の見本
  • success.html 決済が成功した後に表示される画面
  • cancel.html 決済が失敗した場合や戻るボタンを押された場合に表示する画面

テンプレートはあくまで三本であり、部分的に使用できる場合は、必要な箇所の機能だけを模倣すればよい

success.htmlを作成する

templates/pages/success.html

{% extends 'base.html' %}
 
{% block main %}
<div class="container my-5">
  <div class="row">
    <div class="col-12">
      <h1>Thank you!</h1>
      <!-- 注文履歴確認ページ -->
      <p>注文履歴は<a href="/orders/">こちら</a></p>
    </div>
  </div>
</div>
{% endblock %}
cancel.htmlを作成する

templates/pages/cancel.html

{% extends 'base.html' %}
 
{% block main %}
<div class="container my-5">
  <div class="row">
    <div class="col-12">
      <h1>Cancel</h1>
      <p>うまく処理されませんでした。カートは<a href="/cart/">こちら</a></p>
    </div>
  </div>
</div>
{% endblock %}
.envを編集する

環境変数にSecrets情報を格納しておく
今回は開発環境のみなので、.env.devのみを編集する

secrets/.env.dev

STRIPE_API_KEY=[STRIPEのAPIキー]
MY_URL=http://127.0.0.1:8000

[STRIPEのAPIキー]は公式のダッシュボードからコピーして貼り付ける
https://dashboard.stripe.com/

ホームの真ん中の方に開発者向けという項目があるので、シークレットキーをコピペする
なお、公開可能キーは自分でカスタマイズした決済ページを実装する場合に必要になってくるものになる
Stripeの決済ページにリダイレクトする今回のような方法の場合ではシークレットキーのみでよい
今使用しているシークレットキーは開発環境用のテスト用シークレットキーとなっており、本番環境で使用する場合は、別途申請を行って本番環境用のシークレットキーを入手して、 .env.prod に本番環境用のシークレットキーをコピペして使用する

setting.pyの編集

設定ファイルにシークレットキーの環境変数を読み込ませる

setting.pyに以下を追記

# Stripe API Key
STRIPE_API_SECRET_KEY = env.str('STRIPE_API_SECRET_KEY')

# スキーマ&ドメイン
MY_URL = env.str('MY_URL')
ビューの作成

base/views/pay_views.py

from django.shortcuts import redirect
from django.views.generic import View, TemplateView
from django.conf import settings
from stripe.api_resources import tax_rate
from base.models import Item
import stripe

# Stripeのシークレットキーを読み込む 
stripe.api_key = settings.STRIPE_API_SECRET_KEY
 
 
class PaySuccessView(TemplateView):
    template_name = 'pages/success.html'
 
    def get(self, request, *args, **kwargs):
        # 最新のOrderオブジェクトを取得し、注文確定に変更
 
        # カート情報削除
        del request.session['cart']
 
        return super().get(request, *args, **kwargs)
 
 
class PayCancelView(TemplateView):
    template_name = 'pages/cancel.html'
 
    def get(self, request, *args, **kwargs):
        # 最新のOrderオブジェクトを取得
 
        # 在庫数と販売数を元の状態に戻す
 
        # is_confirmedがFalseであれば削除(仮オーダー削除)
 
        return super().get(request, *args, **kwargs)
 
 
tax_rate = stripe.TaxRate.create(
    display_name='消費税',
    description='消費税',
    country='JP',
    jurisdiction='JP',
    percentage=settings.TAX_RATE * 100, # 100分率整数で渡す必要がある
    inclusive=False,  # 外税(税別)を指定(内税(税込)の場合はTrue)
)
 
 
def create_line_item(unit_amount, name, quantity):
    return {
        'price_data': {
            'currency': 'JPY',
            'unit_amount': unit_amount,
            'product_data': {'name': name, }
        },
        'quantity': quantity,
        'tax_rates': [tax_rate.id]
    }
 
 
class PayWithStripe(View):
 
    def post(self, request, *args, **kwargs):
        cart = request.session.get('cart', None)
        if cart is None or len(cart) == 0:
            return redirect('/')
 
        line_items = []
        for item_pk, quantity in cart['items'].items():
            item = Item.objects.get(pk=item_pk)
            line_item = create_line_item(
                item.price, item.name, quantity)
            line_items.append(line_item)
 
        checkout_session = stripe.checkout.Session.create(
            # customer_email=request.user.email,
            payment_method_types=['card'],
            line_items=line_items,
            mode='payment',
            success_url=f'{settings.MY_URL}/pay/success/',
            cancel_url=f'{settings.MY_URL}/pay/cancel/',
        )
        return redirect(checkout_session.url)

PaySuccessViewは決済が成功した場合のリダイレクトのためのView
PayCancelViewは決済が失敗した場合のリダイレクトのためのViewであり、
実際の決済を行う処理はPayWithStripeとなる
PayWithStripeはpostのみでアクセス可能とする
公式ドキュメントのテンプレートのserver.pyを参考に作成していく

line_itemsはStripeの決済ページに表示させるデータを準備している
載せられるデータは詳しくはドキュメント参考だが、今回載せている'price_data'は価格であり、ドキュメントの'price'よりもより詳しく記載できている
'quantity': quantity, は購入数量
'tax_rates': [tax_rate.id] は任意で載せることができるが、これを載せていると自動で税金計算をしてくれるなど便利になる

checkout_sessionにline_itemsといったデータを追加送信すると決済ページにその情報を表示させることが可能となる
Stripeとの通信に必要な情報はcheckout_sessionを使用するので、これを必ず定義する
checkout_sessionの要素はドキュメント(https://stripe.com/docs/checkout/quickstart)を参照
なお、success_url=f'{settings.MY_URL}/pay/success/',のように{settings.MY_URL}を付与しておかないとStripeの決済画面のURLからの相対パスにリダイレクトしてしまうため、URLにドメインを追加しておく

最後は return redirect(checkout_session.url) でStripe公式がデフォルトで用意している決済画面にリダイレクトする

views.pyではなくviewsフォルダを作成してviewsを実装している場合はviews/__init__.pyのpay_viewsのインポートを忘れないように

from .pay_views import *
ルーティングの設定

'pay/checkout/'のURLエンドポイントはStripeの決済画面にリダイレクトするページとなっている

urls.py

    # Pay
    path('pay/checkout/', views.PayWithStripe.as_view()),
    path('pay/success/', views.PaySuccessView.as_view()),
    path('pay/cancel/', views.PayCancelView.as_view()),
動作確認

サーバーを起動させる
商品を選択してカートからcheckoutボタンを押す

成功させたい場合は4242 4242 4242 4242
支払いに確認を必要とする場合は4000 0025 0000 3155
支払いが拒否される場合は4000 0000 0000 9995
を入力する
カード番号以外は適当な値を入力してかまわない

それぞれのパターンの挙動が正しければ動作確認はOK
これで基本的な決済機能を実装できるようになった

トラブルシュート

以下のようなエラーが発生した場合は、Stripeのアカウント名を変更する必要がある模様(参考https://note.com/daikinishimatsu/n/n029a7ff01f62

InvalidRequestError at /pay/checkout/
In order to use Checkout, you must set an account or business name at https://dashboard.stripe.com/account.

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

エラーハンドリング

公式ドキュメント
https://docs.djangoproject.com/en/4.0/ref/views/#error-views

事前準備
setting.pyのDEBUGをFalseにする
DEBUGでない場合はホストALLOWED_HOSTの追加必要 '127.0.0.1'
プロジェクトのurls.pyにステータスコードを設定したハンドラーを追加する

handler404 = views.関数名()

なお、デフォルトではNot Foundエラーやサーバーエラー画面の味気ない画面が出力される
間違ったURLを正しいURLにリダイレクトなどカスタムしたい場合にエラーハンドリングをカスタマズする

例:間違ったURLの場合はリダイレクトする例

プロジェクト側 urls.py

handler404 = show_error_page

views.py

def show_error_page(request, exception):
    # return render(request, ' 404.html')
    return redirect('store:home')

カスタムユーザモデルを作成する

Djangoは通常models.pyにカスタムユーザーを使用することを明示せずにマイグレーションをすると、初めのマイグレーションでデフォルトユーザーを作成されてしまう
Djangoの公式ドキュメントに記述されている通りデフォルトユーザーを使用してしまうと後からの変更が困難となるので、基本的には最初からカスタムユーザーを作成しておくことが推奨されている

今回は一度マイグレーションを上記でしてしまっているが、この状態からカスタムユーザーを作成することも一応できる
後からカスタムユーザーを作成する大まかな流れとしては、カスタムユーザーをmodels.pyで作成して、DBを削除して、migrationsフォルダのinit以外のファイルを削除して、cacheも削除して、マイグレーションの初期化を行って、その後もう一度マイグレーションを行う(マイグレーションを一からやり直すことでカスタムユーザーを適用させることができる)

カスタムユーザーの作り方としてはAbstractUserを継承する方法と、AbstractBaseUserを継承する方法の2種類がある

  • AbstractUser: すでに存在するフィールドをそのまま流用して、フィールドの増減を行うことができる

ただし、メールアドレスをログインIDにするなどのことができないため、限定的な利用にとどまる

  • AbstractBaseUser: Userモデルをほとんどゼロベースから作成することが可能

自由度が高くこちらをカスタムユーザーとするのが一般的

以下はAbstractBaseUserのカスタム実装例

モデルの作成

models.pyにドキュメントを参考にしたモデルを作成する
https://docs.djangoproject.com/ja/3.2/topics/auth/customizing/#a-full-example
もしくは下の章で紹介しているカスタムモデルの例を参考にする

AbstractBaseUserを使用する場合は、データ挿入取得更新処理をするマネージャークラスを作成する必要がある
以下のコード例ではclass UserManager(BaseUserManager): の部分である
ほぼお決まりの書き方があり、通常ユーザーcreate_userと管理者ユーザーcreate_superuserの2種類のユーザー新規作成メソッドをオーバーライドする
自力で書こうとするとcreate_superuserを忘れがちなため注意が必要

カスタムモデルのログインIDを変更する場合はUSERNAME_FIELDに使用したいフィールドを記載する
ここでは'username'をログインIDにしているが、'email'を指定することもできる
'email'を指定した場合は、ログインIDにEmailを使用することが可能となる
REQUIRED_FIELDSはただの必須項目の設定である

また、ユーザーの生年月日やニックネームなどの情報はUserモデルに直接フィールドを持たせるよりも分離したProfileモデルに持たせるような実装で作成するのが慣習となっている
Userモデルと1to1のリレーションでテーブル分離したProfileモデルを作成する
models.OneToOneFields(User, ....) を使用すると1対1のリレーションを張ることができる

account_models.py

from django.dispatch import receiver
from django.db.models.signals import post_save
from django.db import models
from django.contrib.auth.models import BaseUserManager, AbstractBaseUser
 
 
class UserManager(BaseUserManager):
 
    def create_user(self, username, email, password=None):
        if not email:
            raise ValueError('Users must have an email address')
        user = self.model(
            username=username,
            email=self.normalize_email(email),
        )
        user.set_password(password)
        user.save(using=self._db)
        return user
 
    def create_superuser(self, username, email, password=None):
        user = self.create_user(
            username,
            email,
            password=password,
        )
        user.is_admin = True
        user.save(using=self._db)
        return user
 
 
class User(AbstractBaseUser):
    id = models.CharField(default=get_random_string(22), primary_key=True, max_length=22)
    username = models.CharField(
        max_length=50, unique=True, blank=True, default='匿名')
    email = models.EmailField(max_length=255, unique=True)
    is_active = models.BooleanField(default=True)
    is_admin = models.BooleanField(default=False)
    objects = UserManager()
    USERNAME_FIELD = 'username'
    EMAIL_FIELD = 'email'
    REQUIRED_FIELDS = ['email', ]
 
    def __str__(self):
        return self.email
 
    def has_perm(self, perm, obj=None):
        "Does the user have a specific permission?"
        # Simplest possible answer: Yes, always
        return True
 
    def has_module_perms(self, app_label):
        "Does the user have permissions to view the app `app_label`?"
        # Simplest possible answer: Yes, always
        return True
 
    @property
    def is_staff(self):
        "Is the user a member of staff?"
        # Simplest possible answer: All admins are staff
        return self.is_admin
 
 
class Profile(models.Model):
    user = models.OneToOneField(
        User, primary_key=True, on_delete=models.CASCADE)
    name = models.CharField(default='', blank=True, max_length=50)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    # 住所や年齢などアプリに必要なユーザー情報を設定する
 
    def __str__(self):
        return self.name
 
 
# OneToOneFieldを同時に作成
@receiver(post_save, sender=User)
def create_onetoone(sender, **kwargs):
    if kwargs['created']:
        Profile.objects.create(user=kwargs['instance'])

@receiverの処理はUserが作成されると同時にProfileモデルのフィールドにもデータを作成する処理となっているが、プロフィールはユーザーがアクティブなってから項目を入力されて新規作成されるものだとも考えられるので、この処理はあってもなくてもよい
ただこの処理があるとUserが作成された際にProfileが作成されるためProfileの新規作成処理を別途作成する必要がなくなる

カスタムモデルを使用するという宣言をするためのsetting.pyの編集

カスタムユーザーモデルを使用する宣言を行うために以下のコードを追加する
setting.py

AUTH_USER_MODEL = 'base.User'

※baseの部分はアプリ名
この設定を忘れるとDjangoのデフォルトユーザーモデルが作成されてしまう


またログインのURL情報や、ログイン成功時のリダイレクトURLもsetting.pyに記載することができる
ログアウトも同様
setting.py

LOGIN_URL = '/login/'
 
LOGIN_REDIRECT_URL = '/'
 
LOGOUT_URL = '/logout/'
 
LOGOUT_REDIRECT_URL = '/login/'
ユーザー作成用のフォームを作成する

base/forms.pyを作成する

from django import forms
from django.contrib.auth import get_user_model
 
 
class UserCreationForm(forms.ModelForm):
    password = forms.CharField()
 
    class Meta:
        model = get_user_model()
        fields = ('username', 'email', 'password', )
 
    def clean_password(self):
        password = self.cleaned_data.get("password")
        return password
 
    def save(self, commit=True):
        user = super().save(commit=False)
        user.set_password(self.cleaned_data["password"])
        if commit:
            user.save()
        return user

clean_passwordはバリデーション
不正なパスワードはここで弾かれる

管理者画面にカスタムユーザーを表示させられるように admin.py を編集する

admin.py

from base.forms import UserCreationForm
from django.contrib.auth.admin import UserAdmin

class ProfileInline(admin.StackedInline):
    model = Profile
    can_delete = False


class CustomUserAdmin(UserAdmin):
    fieldsets = (
        (None, {'fields': ('username', 'email', 'password',)}),
        (None, {'fields': ('is_active', 'is_admin',)}),
    )

    list_display = ('username', 'email', 'is_active',)
    list_filter = ()
    ordering = ()
    filter_horizontal = ()

    add_fieldsets = (
        (None, {'fields': ('username', 'email', 'is_active',)}),
    )

    add_form = UserCreationForm

    inlines = (ProfileInline,)

admin.site.register(User, CustomUserAdmin)

ちなみに行を分けると管理画面上の表示が改行されて見やすくなる
fieldsの中身は列挙すると順に横に表示されていく
fieldsets = (
(None.......), # 1行目表示
(None.........), # 2行目表示
)
またこの例ではInlineを利用して、ProfileをUserの管理画面に表示させている
管理者画面でのユーザー追加にはforms.pyで作成したUserCreationFormを指定することで管理者画面でユーザーを作成することができる
またこの例ではadd_fieldsetsにpasswordのフィールド項目が抜けているように思われる

マイグレーション初期化(一度マイグレーションをしてしまった後に後からカスタムユーザーを適用する場合)

アプリ(base)下のmigrationsフォルダ下の__init__.py以外の0001_initial.pyなどのファイルをすべて削除する
同様に、migrationsフォルダ下の__pycache__フォルダ下も同様にinitだけ残して削除する
また、データベースも削除するので、DjangoのデフォルトのSQLiteを使用している場合はdb.sqlite3を削除するだけでよい
別のデータベースを使用している場合はそのデータベースの中身を削除するか、setting.pyのデータベースの接続設定などを変えるなどしてDBを新規作成する

マイグレーションをしてカスタムユーザーを適用する

初めてのマイグレーションかもしくはマイグレーション初期化が終わったら、マイグレーションを行って、先ほど作成したカスタムユーザーを適用する

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

管理者を作成できるか確認し、管理者画面にログインできるか確認する

py .\manage.py createsuperuser
ログイン・サインアップ画面を作成する

ログイン・新規登録兼用画面
login_signup.html

{% extends 'base.html' %}
 
{% block main %}
<div class="container my-5">
  <div class="row ">
    <div class="col-12">
      <h1>
        {% if 'login' in request.path %}
        Login
        {% elif 'signup' in request.path %}
        Signup
        {% endif %}
      </h1>
      <form method="POST">
        {% csrf_token %}
        <div class="form-row">
          <div class="form-group col-md-4">
            <input type="text" class="form-control" name="username" placeholder="Username">
          </div>
        </div>
        <div class="form-row">
          <div class="form-group col-md-4">
            <input type="email" class="form-control" name="email" placeholder="Email" required>
          </div>
        </div>
        <div class="form-row">
          <div class="form-group col-md-4">
            <input type="password" class="form-control" name="password" placeholder="Password" required>
          </div>
        </div>
        <button class="btn btn-info btm-sm" type="submit">
          {% if 'login' in request.path %}
          Login
          {% elif 'signup' in request.path %}
          Signup
          {% endif %}
        </button>
      </form>
    </div>
  </div>
</div>
 
{% endblock %}
ユーザー情報変更用画面を作成する

ユーザーが自身のアカウント情報を変更することができる画面を作成する
account.html

{% extends 'base.html' %}
 
{% block main %}
 
<div class="container my-5">
  <div class="row">
    <div class="col-12">
      <h1>Account</h1>
      <form method="POST">
        {% csrf_token %}
        <div class="form-row">
          <div class="form-group col-md-4">
            <label>Username</label>
            <input class="form-control" type="text" name="username" placeholder="name" value="{{user.username}}">
          </div>
        </div>
        <div class="form-row">
          <div class="form-group col-md-4">
            <label>Email</label>
            <input class="form-control" type="email" name="email" placeholder="Email" value="{{user.email}}" required>
          </div>
        </div>
        <button type="submit" class="btn btn-primary">Save</button>
      </form>
    </div>
  </div>
</div>
 
{% endblock %}
プロフィール変更用画面を作成する

プロフィールを変更することのできる画面を作成する
profile.html

{% extends 'base.html' %}
 
{% block main %}
 
<div class="container my-5">
  <div class="row">
    <div class="col-12">
      <h1>Profile</h1>
      <form method="POST">
        {% csrf_token %}
        <div class="form-group ">
          <label>Name</label>
          <input class="form-control" type="text" name="name" placeholder="name" value="{{user.profile.name}}">
        </div>
        <div class="form-row">
          <div class="form-group col-md-2">
            <label>フォーム入力値1</label>
            <input class="form-control" type="text" name="zipcode" placeholder="zipcode"
              value="{{user.profile.zipcode}}">
          </div>
        <button type="submit" class="btn btn-primary">Save</button>
      </form>
    </div>
  </div>
</div>
 
{% endblock %}

form-rowで入力項目を増やす場合はform-groupで囲われている箇所を繰り返し記述する

Viewを作成してログイン・サインアップ・アカウント情報更新機能を作成する

アカウント情報を画面に表示するためのViewを作成する
account_views.py

from django.views.generic import CreateView, UpdateView
from django.contrib.auth.views import LoginView
from django.contrib.auth import get_user_model
from base.models import Profile
from base.forms import UserCreationForm
 
 
class SignUpView(CreateView):
    form_class = UserCreationForm
    success_url = '/login/'
    template_name = 'pages/login_signup.html'
 
    def form_valid(self, form):
        return super().form_valid(form)
 
 
class Login(LoginView):
    template_name = 'pages/login_signup.html'
 
    def form_valid(self, form):
        return super().form_valid(form)
 
    def form_invalid(self, form):
        return super().form_invalid(form)
 
 
class AccountUpdateView(UpdateView):
    model = get_user_model()
    template_name = 'pages/account.html'
    fields = ('username', 'email',)
    success_url = '/account/'
 
    def get_object(self):
        # URL変数ではなく、現在のユーザーから直接pkを取得
        self.kwargs['pk'] = self.request.user.pk
        return super().get_object()
 
 
class ProfileUpdateView(UpdateView):
    model = Profile
    template_name = 'pages/profile.html'
    fields = ('name', '..', ......)
    success_url = '/profile/'
 
    def get_object(self):
        # URL変数ではなく、現在のユーザーから直接pkを取得
        self.kwargs['pk'] = self.request.user.pk
        return super().get_object()

SignUpViewは新規アカウント作成のためのView
LoginViewはログインのためのView
Viewはメソッドを二つに分ける模様
AccountUpdateViewはアカウント情報の更新用View
またユーザーアカウント情報の更新の際の注意点は、URLからIDを取得するのではなく、現在のユーザーから直接PKを取得する(self.request.user.pk)
またフィールドはリストでもよいが、タプルを使用するのが一般的らしい
LoginRequiredMixinを使用すると、ログイン制約をViewにつけることができる(ログインしていなければアクセスさせないということが実現できる)
LoginRequiredMixinはViewクラスの第一引数に記載すると自動的に適用される

ルーティングurls.pyを作成する

urls.py

from django.contrib.auth.views import LogoutView

    # Account
    path('login/', views.Login.as_view()),
    path('logout/', LogoutView.as_view()),
    path('signup/', views.SignUpView.as_view()),
    path('account/', views.AccountUpdateView.as_view()),
    path('profile/', views.ProfileUpdateView.as_view()),
ログアウト機能の実装

ログアウト機能は組み込みのViewが存在するため、それを利用する
上記で設定しているこの部分で実装が完了する
urls.py

from django.contrib.auth.views import LogoutView

path('logout/', LogoutView.as_view()),
Tips! Djangoテンプレートエンジン使用時のHTMLでのログイン判定

{% if user.is_authenticated %} でログイン判定することが可能

カスタムユーザーを使用したログイン処理(関数ベースView)

Tips! Modelマネージャー

Modelクラスはテーブル定義を記述するだけでなくデータ挿入取得更新処理をするマネージャーに分ける場合がある
以下のユーザークラスはマネージャクラスを用いている
※ UserManager(BaseUserManager)の処理

ユーザー登録

以下のようなコードを書けばよい

models.py カスタムユーザーを使用する場合

from django.db import models
from django.contrib.auth.models import (
    BaseUserManager, AbstractBaseUser, PermissionsMixin
)

class UserManager(BaseUserManager):

    def create_user(self, username, email, password=None):
        if not email:
            raise ValueError('Enter Email!')
        user = self.model(
            username=username,
            email=email
        )
        user.set_password(password)
        user.save(using=self._db)
        return user
    
    def create_superuser(self, username, email, password=None):
        user = self.model(
            username=username,
            email=email,
        )
        user.set_password(password)
        user.is_staff = True
        user.is_active = True
        user.is_superuser = True
        user.save(using=self._db)
        return user

class Users(AbstractBaseUser, PermissionsMixin):
    username = models.CharField(max_length=255)
    age = models.PositiveIntegerField()
    email = models.EmailField(max_length=255, unique=True)
    is_active = models.BooleanField(default=False)
    is_staff = models.BooleanField(default=False)
    picture = models.FileField(null=True, upload_to='picture/')

    USERNAME_FIELD = 'email'
    REQUIRED_FIELDS = ['username']

    class Meta:
        db_table = 'users'

views.py

def regist(request):
    regist_form = forms.RegistForm(request.POST or None)
    if regist_form.is_valid():
        try:
            regist_form.save()
            return redirect('accounts:home')
        except ValidationError as e:
            regist_form.add_error('password', e)
    return render(
        request, 'accounts/regist.html', context={
            'regist_form': regist_form,
        }
    )

form.py

from django.contrib.auth.password_validation import validate_password


class RegistForm(forms.ModelForm):
    username = forms.CharField(label='名前')
    age = forms.IntegerField(label='年齢', min_value=0)
    email = forms.EmailField(label='メールアドレス')
    password = forms.CharField(label='パスワード', widget=forms.PasswordInput())
    confirm_password = forms.CharField(label='パスワード再入力', widget=forms.PasswordInput())
    
    class Meta():
        model = Users
        fields = ('username', 'age', 'email', 'password')
    
    def clean(self):
        cleaned_data = super().clean()
        password = cleaned_data['password']
        confirm_password = cleaned_data['confirm_password']
        if password != confirm_password:
            raise forms.ValidationError('パスワードが異なります')

    def save(self, commit=False):
        user = super().save(commit=False)
        validate_password(self.cleaned_data['password'], user)
        user.set_password(self.cleaned_data['password'])
        user.save()
        return user
ユーザーを仮登録してその後本登録する処理の実装

方針
Djangoのユーザーの場合はユーザークラスにもともと登録されているis_activateを使用して制御する

Djangoのシグナル機能を利用してユーザー作成時作成後などの特定のイベントに起因するイベント処理を簡単に実行することができる
この機能をDjangoではシグナルと言う
@receiverアノテーションを関数の先頭に付与して使うことができる
@receiver(post_save, sender=User)
def_save_profile(sender, instance, **kwargs):
instance.profile.save()
のような使い方となる
この場合はユーザーモデルが保存されたタイミングで処理が実行される関数となる
他にもpre_save, pre_delete, post_deleteなどがある

このシグナルを利用して、Djangoでユーザーが作成されたタイミングでトークンを発行するような処理を作る
コンソールにトークンを表示する処理を記述する(本番サービスではメール認証にするべき)
models.py

from django.db.models.signals import post_save
from django.dispatch import receiver

class UserActivateTokens(models.Model):

    token = models.UUIDField(db_index=True)
    expired_at = models.DateTimeField()
    user = models.ForeignKey(
        'Users', on_delete=models.CASCADE
    )

    objects = UserActivateTokensManager()

    class Meta:
        db_table = 'user_activate_tokens'

@receiver(post_save, sender=Users)
def publish_token(sender, instance, **kwargs):
    user_activate_token = UserActivateTokens.objects.create(
        user=instance, token=str(uuid4()), expired_at=datetime.now() + timedelta(days=1)
    )
    # 本番サービスではメールでURLを送る実装とする
    print(f'http://127.0.0.1:8000/accounts/activate_user/{user_activate_token.token}')

accounts/activate_user/{user_activate_token.token}
のエンドポイントでアカウントを有効化する処理をviews.pyの中に追記する

models.pyに有効化処理をまず記述してそのあとviews.pyにこの処理を呼び出す処理を追加
models.py

class UserActivateTokensManager(models.Manager):

    def activate_user_by_token(self, token):
        user_activate_token = self.filter(
            token=token,
            expired_at__gte=datetime.now()
        ).first()
        user = user_activate_token.user
        user.is_active = True
        user.save()


エンドポイントから呼び出される処理 (urls.pyのルーティング処理などは基本なので省略)
views.py

def activate_user(request, token):
    user_activate_token = UserActivateTokens.objects.activate_user_by_token(token)
    return render(
        request, 'accounts/activate_user.html'
    )
ログインの処理

models.py
特になし

views.py

def user_login(request):
    login_form = forms.LoginForm(request.POST or None)
    if login_form.is_valid():
        email = login_form.cleaned_data.get('email')
        password = login_form.cleaned_data.get('password')
        user = authenticate(email=email, password=password)
        if user:
            if user.is_active:
                login(request, user)
                messages.success(request, 'ログイン完了しました。')
                return redirect('accounts:home')
            else:
                messages.warning(request, 'ユーザがアクティブでありません')
        else:
            messages.warning(request, 'ユーザかパスワードが間違っています')
    return render(
        request, 'accounts/user_login.html', context={
            'login_form': login_form,
        }
    )

forms.py

class LoginForm(forms.Form):
    email = forms.CharField(label="メールアドレス")
    password = forms.CharField(label="パスワード", widget=forms.PasswordInput())
ログアウト処理 ( & ログアウト完了時のメッセージについてmessageフレームワークを使用する )

django.contrib.messagesのパッケージに各種エラーやデバッグ・ログイン時ログアウト時などのタイミングでメッセージを簡単に表示させることのできるmessagesというパッケージがある

以下のような書き方となる

messages.debug(request, 'メッセージ')
messages.info(request, 'メッセージ')
messages.success(request, 'ログイン完了しました')
messages.warning(request, '注意')
messages.error(request, 'エラー発生')

例:ログアウトの処理などで以下のように書くとよい
views.py

from django.contrib.auth.decorators import login_required

@login_required
def user_logout(request):
    logout(request)
    messages.success(request, 'ログアウトしました')
    return redirect('accounts:home')


テンプレートでは使用するテンプレートごとに (user_login.htmlなど) にmessagesを書いておく
user_login.html

{% if messages %}
  {% for message in messages %}
    <div>{{ message.message }}</div>
  {% endfor %}
{% endif %}
パスワード変更処理

パスワードは変更後にsession情報を更新する必要がある
またパスワード確認フィールドもあることもポイント

フォームの情報でDBの (上書き) 更新は以下のような形になる
第二引数のinstanceに上書きしたい情報が格納される
forms.PasswordChangeForm(request.POST or None, instance=request.user)
この情報のままで保存するのは危険であるので、viewsのtryの処理のようなvalidationチェックを行う
update_session_auth_hash(request, request.user)でセッション情報を更新する

models.py
特になし

views.py

@login_required
def change_password(request):
    password_change_form = forms.PasswordChangeForm(request.POST or None, instance=request.user)
    if password_change_form.is_valid():
        try:
            password_change_form.save()
            messages.success(request, 'パスワード更新完了しました。')
            update_session_auth_hash(request, request.user)
        except ValidationError as e:
            password_change_form.add_error('password', e)
    return render(
        request, 'accounts/change_password.html', context={
            'password_change_form': password_change_form,
        }
    )


forms.py

class PasswordChangeForm(forms.ModelForm):

    password = forms.CharField(label='パスワード', widget=forms.PasswordInput())
    confirm_password = forms.CharField(
        label='パスワード再入力', widget=forms.PasswordInput())

    class Meta():
        model = Users
        fields = ('password', )

    def clean(self):
        cleaned_data = super().clean()
        password = cleaned_data['password']
        confirm_password = cleaned_data['confirm_password']
        if password != confirm_password:
            raise forms.ValidationError('パスワードが異なります')

    def save(self, commit=False):
        user = super().save(commit=False)
        validate_password(self.cleaned_data['password'], user)
        user.set_password(self.cleaned_data['password'])
        user.save()
        return user
ユーザー情報変更処理

models.py
特になし


views.py

@login_required
def user_edit(request):
    user_edit_form = forms.UserEditForm(request.POST or None, request.FILES or None, instance=request.user)
    if user_edit_form.is_valid():
        messages.success(request, '更新完了しました。')
        user_edit_form.save()
    return render(request, 'accounts/user_edit.html', context={
        'user_edit_form': user_edit_form,
    })


forms.py

class UserEditForm(forms.ModelForm):
    username = forms.CharField(label='名前')
    age = forms.IntegerField(label='年齢', min_value=0)
    email = forms.EmailField(label='メールアドレス')
    picture = forms.FileField(label='写真', required=False)

    class Meta:
        model = Users
        fields = ('username', 'age', 'email', 'picture')

AJAX非同期処理とキャッシュ制御(関数ベースView)

Ajax

画面遷移せずにクライアント側からjQueryを用いてサーバサイドにリクエストを投げてデータをやり取りする技術

クライアント側

$.ajax({
   url : “create_post/”, // 実行するURLを指定
   type : “POST”, // HTTPメソッド
   data : { the_post : $(‘#post-text’).val() }, // 送信するデータ
   success : function(json) { // 実行が成功した場合の処理
   },
   error : function(xhr,errmsg,err) { // 実行が失敗した場合の処理
}
});

サーバーサイド側はJSONを返す処理を記述する

from django.http import JsonResponse
from django.core import serializers


if request.is_ajax:
   # 処理
   json_instance = serializers.serialize(‘json’, [ instance, ]) # jsonに変換する
   return JsonResponse({“instance”: json_instance}, status=200) # レスポンスを返す
   または
   return HttpResponse(
      json.dumps(response_data),
      content_type="application/json"
   )
CACHEの種類

1. Memcached: メモリー上キャッシュ、処理が高速、最も一般的に使われ複数サーバで共有も可能
2. Database: データベースに保存するキャッシュ、取得速度遅いが大容量
3. File system: ファイル上に分割して保存するキャッシュ、速度は遅いが管理が楽
4. Local memory: ローカルPCメモリー上に保存するキャッシュ、デフォルトキャッシュ
5. Dummy: 開発環境用ダミーキャッシュ

Djangoのドキュメント Cache
Django’s cache framework | Django documentation | Django


キャッシュの設定例
setting.py

CACHES = {
  'default': {
    'BACKEND': 'django.core.cache.backends.memcached.MemcachedCache',
    'LOCATION': [
      '172.19.26.240:11211',
      '172.19.26.242:11212‘,
    ]
  }
}

キャッシュ操作

from django.core.cache.cache import *

cache.set(‘KEY’, ‘VALUE’)
cache.get(‘KEY’, ‘DEFAULT’) # 存在しない場合はデフォルト値が返ってくる
cache.clear()
cache.delete('KEY')
Ajax実装例

JQuery CDNのインポート
jQuery CDN
uncompressedをクリック
スクリプト部分をコピーしてbase.pyのようなHTMLファイルのheadタグ部分にペーストする


Ajaxの処理を記述

base.pyのbodyタグ内にブロックを記述し、使いたいテンプレートで処理を記述する

<body>

{% block javascript %}{% endblock %}

</body>


例: フォームの一時保存機能を追加する場合

テンプレートのフォームコンポーネントにidを付与してscriptブロックにscriptタグで処理を記述する

<form method="POST">
  {% csrf_token %}
  {{ post_comment_form.as_p }}
  <input type="button" value="一時保存" id="save_comment">
  <input type="submit" value="コメント送信">
</form>


{% block javascript %}
<script>
$("#save_comment").click(function(){
    var comment = $("#id_comment").val();
    $.ajax({
        url: "{% url 'boards:save_comment' %}",
        type: "GET",
        data: {comment: comment, theme_id: "{{ theme.id }}"},
        dataType: "json",
        success: function(json){
            if(json.message){
                alert(json.message);
            }
        }
    });
});
</script>
{% endblock %}

urlでセーブ用のViewの処理のエンドポイントを呼ぶ処理を実装しているので、viewの処理も実装する
urls.pyの実装例は省略

キャッシュを設定する処理
views.py

from django.core.cache import cache
from django.http import JsonResponse

def save_comment(request):
    if request.is_ajax:
        comment = request.GET.get('comment')
        theme_id = request.GET.get('theme_id')
        if comment and theme_id:
            cache.set(f'saved_comment-theme_id={theme_id}-user_id={request.user.id}', comment)
            return JsonResponse({'message': '一時保存しました!'})

saved_comment-theme_id={theme_id}-user_id={request.user.id} は長いがただのKEYである

例: キャッシュを取出す処理
views.py

def post_comments(request, theme_id): # フォームにレンダリングするためのデータを渡す処理
    saved_comment = cache.get(f'saved_comment-theme_id={theme_id}-user_id={request.user.id}', '')
    post_comment_form = forms.PostCommentForm(request.POST or None, initial={'comment': saved_comment})

画面読み込み時にレンダリングするためのデータを渡す処理の先頭にキャッシュされたデータを取得する処理を記述しておく
フォームの第二引数 initial にキャッシュの初期値を設定する
また、フォーム画面でデータをサーバー側に保存した場合はcache.deleteを使用してキャッシュデータを削除しておくことも忘れないようにしておく

クラスベースビューを使用した主にログイン処理

Djangoはクラスベースビューを用いる方が一般的で実践的
ただし別のフレームワークでフロントエンド開発をする場合はDjangoREST APIとして使用するため関数ベースビューで記述する方が一般的かもしれない
Djangoで画面まで作成する場合にはクラスベースビューで作成する方が便利だと思われる

以下のテンプレートがある

View 全てのViewの元になるView
TemplateView テンプレートを表示する。ホーム画面など
CreateVIew データベースにデータを挿入するView。データ作成画面
UpdateView データを更新するView
DeleteView データを削除するView
ListView 特定のテーブルのデータ一覧を表示するView
DetailView テーブルのレコードの詳細を表示するView
FormView Formを表示してデータを送信するView
RedirectView リダイレクトを行うView
View

上記のテンプレートでうまく実装できなさそうな処理を記述するときに継承するViewクラス

リクエストに応じた処理を記述する

class MyView(View):
  def get(self, request, **kwargs):
    # GETの場合の処理
  def post(self, request, **kwargs):
    # POSTの場合の処理
リダイレクトビュー(RedirectView)

urls.pyに直接記述

from django.views.generic.base import RedirectView

urlpatterns = [
  path('/search/<term>/', RedirectView.as_view(url='https://google.co.jp/?q=%(term)s')),
]
SuccessMessageMixin

データ更新削除時等のメッセージ表示に便利なクラス
Pythonは複数クラスの継承ができるため作成するViewの2つ目のクラスとしてSuccessMessageMixinを継承する
例 views.py

class BookUpdateView(UpdateView, SuccessMessageMixin):
  model = Books
  success_message # 成功時メッセージ(静的)
  def get_success_message(self, cleaned_data): # 成功時メッセージ(動的


そのほかのテンプレートビューは使用する際に配布資料参照のこと

ログイン処理(クラスベースView)

ログインログアウト処理

クラスベースのログインビューが存在するためそれを利用する

from django.views.generic import View

class LoginView(View):
  def post(self, request, *args, **kwargs):
    username = request.POST[‘username’] # usernameを取得
    password = request.POST['password’] # passwordを取得
    user = authenticate(username=username, password=password) # userが存在するか確認
    if user is not None:
      if user.is_active:
         login(request, user) # ログイン処理
    return render(request, "index.html")


class LogoutView(View):
  def get(self, request, *args, **kwargs):
    logout(request) # ログアウト処理
    return HttpResponseRedirect(settings.LOGIN_URL)
ログイン制約

LoginRequiredMixin
ログインユーザーのみViewにアクセスできるように制限するクラス
使用するviewに継承する

class MyView(LoginRequiredMixin, View): # ログインが必要なViewに継承させる
  login_url = '/login/'
  redirect_field_name = 'redirect_to’

@method_decorator(login_required)
アノテーションでもログイン制約を付与することができる
Viewの中の一部のメソッドのみログイン制約をつけたい場合に使用できる

class ProtectedView(TemplateView):
  @method_decorator(login_required)
  def dispatch(self, request, *args, **kwargs):
ログインセッション時間について

セッション時間はデフォルトで2週間となっている
変更する場合はsettingにセッション情報を追記する
プログラム中で個別にセッション時間を指定したい場合には request.session.set_expiry(value) を使用する

settings.py

SESSION_COOKIE_AGE: セッションの保存時間(秒)

https://docs.djangoproject.com/ja/3.1/ref/settings/#std:setting-SESSION_COOKIE_AGE

request.session.set_expiry(value): セッションの保存時間を引数の時間(秒)に変更する

※なお、引数が0の場合、ブラウザを閉じるとセッションが破棄される
value に datetime または timedelta オブジェクトを指定すると指定された日時に破棄される
https://docs.djangoproject.com/ja/3.1/topics/http/sessions/#django.contrib.sessions.backends.base.SessionBase.set_expiry

ログイン実装例(クラスベース)
例: ユーザー登録
class RegistUserView(CreateView):
    template_name = 'regist.html'
    form_class = RegistForm
例: ログイン
class UserLoginView(LoginView):
    template_name = 'user_login.html'
    authentication_form = UserLoginForm

    def form_valid(self, form):
        remember = form.cleaned_data['remember']
        if remember:
            self.request.session.set_expiry(1200000)
        return super().form_valid(form)

def form_validはセッション情報保持ボタンを押すとログイン情報を保持することができるような処理にしている
※講座の中でデフォルトのセッション時間を5秒にしているため
実際の実装の場面ではform_validの部分は省いて実装するケースがほとんどだと思う


FormViewで定義する例

class UserLoginView(FormView):
    template_name = 'user_login.html'
    form_class = UserLoginForm

    def post(self, request, *args, **kwargs):
        email = request.POST['email']
        password = request.POST['password']
        user = authenticate(email=email, password=password)
        next_url = request.POST['next']
        if user is not None and user.is_active:
            login(request, user)
        if next_url:
            return redirect(next_url)
        return redirect('accounts:home')
例: ログアウト
class UserLogoutView(LogoutView):
    pass

Viewで定義する例

class UserLogoutView(View):
    
    def get(self, request, *args, **kwargs):
        logout(request)
        return redirect('accounts:user_login')
例: ログイン制約
# @method_decorator(login_required, name='dispatch')
class UserView(LoginRequiredMixin, TemplateView):
    template_name = 'user.html'

    # @method_decorator(login_required)
    def dispatch(self, *args, **kwargs):
        return super().dispatch(*args, **kwargs)

MiddleWareを利用したログ出力の実装(Pythonの基本ログ出力方法)

ログ出力をする意義

以下の理由にて実務で必須

  • エラー解析
  • パフォーマンス解析(レスポンスタイム)
ログレベル

上から順位重大

  • critical 重大エラー
  • error エラー
  • warning 警告
  • info 情報
  • debug デバッグ
出力方法

loggingモジュール(デフォルトでログレベルはwarning)

import logging

# ログの出力
logging.critical(‘エラー内容')
.. rtc
ファイルへの出力
logging.basicConfig(filename=‘app.log’, filemode=‘w’) 
よく使うテクニック
ogging.error(‘%s raised error’, name) # 変数をログに出力
logging.error(f‘{name} raised error’) # 変数をログに出力(3.6以降)
logging.error(f‘{name=} raised error’) # 変数をログに出力(3.8以降)
logging.error(“”, exc_info=True) #スタックトレースの出力

logging.basicConfig(format=‘%(asctime)s-%(process)s-%(levelname)s-%(message)s’) # 出力するログのフォーマットの設定%(asctime): 出力時間、%(process): プロセスID、%(levelname): ログレベル、%(message):メッセージ
datefmt: 時刻のフォーマットを指定

スタックトレースは特によく使う模様
原因特定するために何行目のログ情報かの情報がとても有益となる

ログの一般的な使い方 tryとかで使う

try:
  method()
except Exception as e
  logggin.error(e, exc_info=True)

logging.error(“”, exc_info=True) #スタックトレースの出力
ロガーのインスタンス
logger = logging.getLogger(__name__)

__name__はファイル名を差し、ロガーの識別子となる
使用を推奨されている

handlerを設定する

logger.setLevel(logging.DEBUG) # ログレベルのデフォルトはwarning
ロガーの情報を設定ファイルに記述して使用する方法

confフォルダを作成して、設定ファイルを logger.conf 作る

[loggers] # loggerの一覧を設定
[handlers] # Handlerの一覧を設定
[formatters] # Formatterの一覧を設定
[logger_${logger名}] # 各loggerの設定
[handler_${handler名}] # handlerの設定
[formatter_${formatter名}] # 各formatterの設定

logger.conf 例

[loggers]
keys=root

[handlers]
keys=consolehandler, filehandler

[formatters]
keys=sampleformatter

[logger_root]
level=DEBUG
handlers=consolehandler, filehandler

[handler_consolehandler]
class_StreamHandler
level=INFO
formatter=samleformatter
args=(sys.stdout,)

[handler_filehandler]
class=FileHandler
level=Error
formatter=samleformatter
args=['logs/app.log', 'a', 'utf-8']

[formatter_sampleformatter]
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'

ログのインスタンスを使用してログを出力させる
logging.py

import logging
import logging.config

logging.config.fileConfig(fname='conf/logger.conf')

logger = logging.getLogger(__name__)

logger.debug('')
logger.info('')
logger.warning('')
logger.error('')
logger.critical('')

logsフォルダを作ってログをファイルとして出力させる
今回はinfo以上のログが出力される

複数のloggerを設定して別々のログを出力させることも可能
confファイル

[loggers]
keys=root, samplelogger

[handlers]
keys=consolehandler, filehandler, sampleconsolehandler

[formatters]
keys=sampleformatter

[logger_root]
level=DEBUG
handlers=consolehandler, filehandler

[logger_samplelogger]
level=DEBUG
handlers=filehandler, sampleconsolehandler
qualname=samplelogger
propagate=0

[handler_consolehandler]
class=StreamHandler
level=INFO
formatter=sampleformatter
args=(sys.stdout,)

[handler_filehandler]
class=FileHandler
level=ERROR
formatter=sampleformatter
args=['logs/app.log', 'a', 'utf-8']

[handler_sampleconsolehandler]
class=StreamHandler
level=DEBUG
formatter=sampleformatter
args=(sys.stdout,)

[formatter_sampleformatter]
format=%(asctime)s-%(name)s-%(levelname)s-%(message)s

logging.pyファイル

import logging
import logging.config

logging.config.fileConfig(fname='conf/logger.conf')

logger = logging.getLogger(__name__)

logger.debug('デバッグログ')
logger.info('インフォログ')
logger.warning('ワーニングログ')
logger.error('エラーログ')
logger.critical('クリティカルログ')


logger = logging.getLogger('samplelogger')
logger.debug('デバッグログ')
logger.info('インフォログ')
logger.warning('ワーニングログ')
logger.error('エラーログ')
logger.critical('クリティカルログ')
ローテーティングファイルハンドラーを利用する

一定の条件を設定して、別ファイルにログを新たに切り出す設定
管理上見やすくしたり1ファイルのファイルサイズの肥大化を防ぐことができる

例: logger.confファイルに以下を追記

[handler_fileHandler]
class=handlers.RotatingFileHandler
level=INFO
formatter=sampleFormatter
args=('./logs/log.out', when='S', interval=10, backupCount=5, encoding=‘utf-8’, 
maxBytes=1000)

時間によるローテーションを設定したい場合は以下

[handler_fileHandler]
class=handlers.TimedRotatingFileHandler
level=INFO
formatter=simpleFormatter
args=(‘logger.log’,when=’D’,interval=1,backupCount=3,encoding=‘utf-8’)

MiddleWareを利用したログ出力の実装(Djangoの基本ログ出力方法)

上記はpythonの基本のログ出力方法
以下ではDjangoに設定する方法

Djangoログ出力 setting.py

Djangoのログ出力は setting.py に記述する
ロギング | Django ドキュメント | Django

setting.py

LOGGING = {
  ‘version’: 1, # ロガーバージョン通常は1
  ‘disable_existing_loggers’: False, # デフォルトログの無効化通常はFalse
  'formatters': {
    : # フォーマッターの一覧を記載
  },
  'handlers': {
    : # ハンドラーの一覧を記載
  },
  'loggers': {
    : # ロガーの一覧を記載
  }
}

setting.py 例

LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    'formatters': {
        'simple': {
            'format': '%(asctime)s %(levelname)s [%(pathname)s:%(lineno)s] %(message)s',
        }
    },
    'handlers': {
        'console_handler': {
            'level': 'DEBUG',
            'class': 'logging.StreamHandler',
            'formatter': 'simple',
        },
        'timed_file_handler': {
            'level': 'INFO',
            'class': 'logging.handlers.TimedRotatingFileHandler',
            'filename': os.path.join('logs', 'application.log'),
            'when': 'S',
            'interval': 10,
            'backupCount': 10,
            'formatter': 'simple',
            'encoding': 'utf-8',
            'delay': True,
        },
        'timed_error_handler': {
            'level': 'ERROR',
            'class': 'logging.handlers.TimedRotatingFileHandler',
            'filename': os.path.join('logs', 'application_error.log'),
            'when': 'S',
            'interval': 10,
            'backupCount': 10,
            'formatter': 'simple',
            'encoding': 'utf-8',
            'delay': True,
        },
        'timed_performance_handler': {
            'level': 'INFO',
            'class': 'logging.handlers.TimedRotatingFileHandler',
            'filename': os.path.join('logs', 'application_performance.log'),
            'when': 'S',
            'interval': 10,
            'backupCount': 10,
            'formatter': 'simple',
            'encoding': 'utf-8',
            'delay': True,
        }
    },
    'loggers': {
        'application-logger': {
            'handlers': ['console_handler', 'timed_file_handler'],
            'level': 'DEBUG',
            'propagate': False,
        },
        'error-logger': {
            'handlers': ['timed_error_handler'],
            'level': 'ERROR',
            'propagate': False,
        },
        'performance-logger': {
            'handlers': ['timed_performance_handler'],
            'level': 'INFO',
            'propagate': False,
        }
    }
}


ログの出力の処理
views.pyやmodels.pyなど

import logging

# 前処理
application_logger = logging.getLogger('application-logger')
error_logger = logging.getLogger('error-logger')


# メソッド内で記述する
application_logger.debug('エラーメッセージ')
error_logger.error('エラーメッセージ')
ミドルウェアを利用する

リクエスト・レスポンス処理にフックを加えて入力と出力の値を置き換えるフレームワークのことをミドルウェアという
ミドルウェア (Middleware) | Django ドキュメント | Django

django.utils.deprecation.MiddlewareMixinを継承する

Djangoのビューを呼び出す前に実行される処理を記述する場合は
def process_view(self, request, view_func, view_args, view_kwargs):のメソッドを記述する
def process_viewのreturnは通常Noneを返す

ビューで例外が発生した場合に実行される処理を記述する場合は
def process_exception(self, request, exception) のメソッドを追記する
returnはNoneとHttpResponseオブジェクトを返す
Noneをreturnする場合は他のエラーハンドリング処理を記述する
ミドルウェアの定義の逆順に実行される(setting.pyのMIDDLEWAREの下から順番に実行される)

ビューの実行後に呼び出される場合
def process_template_response(self, request, response):


middleware.pyを記述してsetting.pyに記述する

middleware.py 例(プロジェクトフォルダに作成)

# middleware.py
import logging
from django.utils.deprecation import MiddlewareMixin
import time

application_logger = logging.getLogger('application-logger')
error_logger = logging.getLogger('error-logger')
performance_logger = logging.getLogger('performance-logger')

class MyMiddleware(MiddlewareMixin):

    def process_view(self, request, view_func, view_args, view_kwargs): # viewを呼び出す前に実行
        application_logger.info(request.get_full_path())
        
    def process_exception(self, request, exception):
        error_logger.error(exception, exc_info=True)


class PerformanceMiddlware(MiddlewareMixin):

    def process_view(self, request, view_func, view_args, view_kwargs):
        start_time = time.time()
        request.start_time = start_time

    def process_template_response(self, request, response):
        response_time = time.time() - request.start_time
        performance_logger.info(f'{request.get_full_path()}: {response_time}s')
        return response

setting.py 例

MIDDLEWARE = [
    'class_based_view.middleware.PerformanceMiddlware', # リクエストでは最初・レスポンスでは最後に実行される
    # ...
    'class_based_view.middleware.MyMiddleware'  # リクエストでは最後・レスポンスでは最初に実行される
]

OAuth認証の実装

f:id:shinseidaiki:20211220233228p:plain

offlineアクセスのオフラインとはネットワークにつながっていないということではなくユーザーがいない状態のことを差す


必要なモジュール

django-allauth

pip install django-allauth
google OAuth認証を試してみる

settings.pyのINSTALLED_APPに以下のものを追加する
settings.py

'django.contrib.sites',
'allauth',
'allauth.account',
'allauth.socialaccount',
‘allauth.socialaccount.providers.google’, # google認証

ログイン処理時に認証で行うクラスにallauthを追加する
settings.py

AUTHENTICATION_BACKENDS = (
  “django.contrib.auth.backends.ModelBackend”, # デフォルトの認証
  "allauth.account.auth_backends.AuthenticationBackend", # allauthの認証
)

settings.pyの下部に記載

SITE_ID = 1 # django_site テーブル上の認証に用いるサイトの指定 通常1(django_siteは最初のマイグレーションで作成されるテーブル)
ACCOUNT_EMAIL_REQUIRED = True # 認証にメールアドレスが必要か(デフォルトはFalse)
ACCOUNT_USERNAME_REQUIRED = False # 認証にユーザ名が必要か(デフォルトはTrue)
ACCOUNT_AUTHENTICATION_METHOD = ‘email’ # 認証に利用する要素(email or username or 
username_email)
SOCIALACCOUNT_PROVIDERS = { # プロバイダーごとの設定
  'google': {
    ‘SCOPE‘: [ # Google APIで何を取得するか
      'profile',
      'email',
    ],
    'AUTH_PARAMS': {
      ‘access_type’: ‘onine’, #バッチ処理の場合はオフラインで利用する
}}}

アクセスタイプのofflineはネットにつながっていないことでなくユーザーがいない状態例えばバッチetc. のこと

SOCIALACCOUNT_PROVIDERSの中身は以下のドキュメントのProviderの項目を見て記述する
https://django-allauth.readthedocs.io/en/latest/installation.html


google Cloud platformにプロジェクトを登録する

1
google cloud platformで検索してgcpのトップページにアクセス

2
利用規約に同意

3
コンソール画面でプロジェクトの選択>新しいプロジェクト
プロジェクト名と場所(組織なし)を入力して作成

4
プロジェクトを選択をおし
APIサービス>OAuth同意画面
UserTypeを外部に選択して作成

5
アプリ名には先ほどのプロジェクト名を
ユーザーサポート名には適当なメールアドレスを入力する
デベロッパーの連絡先情報は適当なメールアドレスを入力する
保存して次へ

6
スコープはそのままで保存して次へ
テストユーザーもそのままで保存して次へ
概要もそのままでダッシュボードに戻る

7
APIサービス>認証情報作成>認証情報を作成>OAuthクライアントIDを選択
アプリケーションの種類:ウェブアプリケーション
名前:ウェブクライアント1
承認済のJavaScript生成元にローカルのURLを記載
http://127.0.0.1:8000
承認済のリダイレクトURIにも以下のようなURIを記載
http://127.0.0.1:8000/oauth_accounts/google/login/callback/

作成

8
作成されたクライアントIDとシークレットキーをメモなどどこかに保存する

OAuth認証実装 Djangoソースコード側の手順

エディタに戻ってDjangoソースコードを編集していく
マイグレーションを実行する

py manage.py migrations 

これでマイグレーションの履歴を見るとsocialaccount_○○というOAuthで使用されるDBテーブルが4つほど作成されていることがわかる

スーパーユーザーを作成する

py manage.py createsuperuser 

作成した管理者ユーザーで管理者画面にログインする
SOCIAL ACCOUNTSの中に
social accounts
social application tokens
social applications
テーブルがあることがある
この中にOAuthの設定を記載していく

まず、Siteのテーブルの中にexampleのドメイン名があるがこれを127.0.0.1に変更する

次にSocial Applicationsをクリックして以下を追加する
Provider: google
name: OAuth App
クライアントIDは先ほどコピーした値
シークレットキーも先ほどコピーした値
Keyは空のまま
Sitesの127.0.0.1を左から右に移動させる

これで保存する
こうすることでGoogle認証でログインすることが可能となる

この後、実際にOathでログインしたユーザーはsocial accountsテーブルにメースアドレスや名前などが追加される

画面遷移を作成する

urls.pyにOauthの画面への遷移を追加する

urls.py

path('oauth_accounts/', include('allauth.urls’))


ユーザーログインのテンプレート画面にgoogleログインのためのリンクを貼り付ける
user_login.html

{% load socialaccount %}
<a href=“{% provider_login_url ‘google’ %}”>googleログイン</a>

例:user_login.html

{% extends 'base.html' %}
{% load socialaccount %}
{% block content %}
<form method="POST">
   {% csrf_token %}
   {{ form.as_p }}
   <input type="hidden" name="next" value="{{ request.GET.next }}">
   <input type="submit" value="ログイン">
</form>
<a href="{% provider_login_url 'google' %}">Googleログイン</a>
{% endblock %}
動作確認

Djangoサーバーを起動してアプリケーションにアクセス
ユーザーログイン画面からgooglerログインを選択して、よく見るグーグル認証の画面に遷移して、その後認証が通って元の画面にリダイレクトされればよい

DBの中身を確認するとSOCIAL ACCOUNTSテーブルの中にログインされたUserのEmailアドレスなどが格納されていることがわかる
Account Userの中身を見るとユーザーのEmailが見れることが確認できれば良い

画面をきれいにする

http://127.0.0.1/oauth_accounts/signup/
などと指定すると、oauthのライブラリが持っている変な画面が表示されてしまう

これを表示させたくないない場合はurls.pyに記述していた箇所を以下のように書き換える

from allauth.socialaccount.providers.google.urls import urlpatterns as google_url

path('oauth_accounts/', include(google_url)),

こうするとピンポイントにgoogleのurlだけを入れることができる

END

バッチ処理の実装・運用

ドキュメント
https://docs.djangoproject.com/ja/3.1/howto/custom-management-commands/

基本利用方法

アプリケーションフォルダの直下にmanagementフォルダを作成して、commandsフォルダを作成してその中にコマンド(sample.py)を配置

app\management\commands\sample.py

from django.core.management.base import BaseCommand

class Command(BaseCommand):
    
    def handle(self, *args, **options):
        print("バッチ実行")

このバッチファイルを実行する場合は以下のコマンドで実行できる

py manage.py sample

storesアプリケーションフォルダにも同じようなフォルダ階層でバッチファイルを作成することもできる
stores\management\commands\sample.py

from django.core.management.base import BaseCommand

class Command(BaseCommand):

    def handle(self, *args, **options):
        print('storeのバッチを実行')


なお別のアプリケーションに同じ名前のバッチファイルを作成した場合は、setting.pyに記載したアプリの上から順番に実行される(sample.pyが二つあった場合は、py manage.py sampleでは先に登録されているアプリ(app)の方のsample.pyが実行される)

引数の追加

add_argments: コマンド内で利用する引数を追加する

app/management/sample.py

class Command(BaseCommand):
  def add_arguments(self, parser):
    parser.add_argument(‘first’) # 第1引数をキーfirstに格納する
    parser.add_argument(‘second’) # 第2引数をキーsecondに格納する
    parser.add_argument(‘--option1’) # option1で指定した引数をキーoption1に格納する

  def handler(self, *args, **options):
    print(f’{options[“first”]}’) # add_argumentsで格納した値を取り出す
    print(f’{options[“option1”]}’) # add_argumentsで格納した値を取り出す
    • を使うとマップ型のkeyとして定義することができる

呼び出す場合は必ず引数を必要となり、以下のようなコマンドで呼び出すことができる

python manage.py sample A(第1引数) B(第2引数) --option1 C(option1に対する値)
コマンド内で利用する引数の追加

type: 数値型(int)、文字列型(str)で指定する
help: 説明文の追加 「python manage.py help コマンド名」 で使用できる
python manage.py help コマンド名で表示することができる
default: デフォルトの値(格納する引数が存在する場合に指定した値が格納される)
nargs: 格納する値の数を指定してリスト型で格納。呼び出す際はコマンドの引数に配列の数だけ引数を列挙して渡して実行する
action: store_true(引数が存在する場合はTrue)、store_false (引数が存在する場合はFalse)が格納される
choices: 格納できる値の一覧を記述する

例: sapmle.py

from django.core.management.base import BaseCommand


class Command(BaseCommand):
    help='ユーザ情報を表示するバッチです'

    def add_arguments(self, parser):
        parser.add_argument('name', type=str, help='名前') # 1引数
        parser.add_argument('age', type=int) # 2引数
        parser.add_argument('--birthday', default='2020-01-01')
        parser.add_argument('three_words', nargs=3)
        parser.add_argument('--active', action='store_true') # 引数--activeを指定した場合にTrueを格納する
        parser.add_argument('--color', choices=['Blue', 'Red', 'Yellow']) # 例えばBlueを指定すると以下のハンドルの対応する青が表示される

    def handle(self, *args, **options):
        name = options['name']
        age = options['age']
        birthday = options['birthday']
        three_words = options['three_words']
        active = options['active']
        print(
            f'name = {name}, age = {age}, birthday = {birthday}, three_words = {three_words}'
        )
        print(active)
        color = options['color']
        if color == 'Blue':
            print('青')
        elif color == 'Red':
            print('赤')
        elif color == 'Yellow':
            print('黄')
応用

Ordersモデルで定義されたDBの中のOrdersテーブルのデータをファイルに出力するバッチファイルの作成
例: stores/management/commands/export_orders.py

from django.core.management.base import BaseCommand
from stores.models import Orders
from ecsite_project.settings import BASE_DIR
from datetime import datetime
import os
import csv

class Command(BaseCommand):

    def add_arguments(self, parser):
        parser.add_argument('--user_id', default='all')

    def handle(self, *args, **options):
        orders = Orders.objects
        user_id = options['user_id']
        if user_id == 'all':
            orders = orders.all()
        else:
            orders = orders.filter(user_id=user_id)
        file_path = os.path.join(BASE_DIR, 'output', 'orders', f'orders_{datetime.now().strftime("%Y%m%d%H%M%S")}_{user_id}')
        with open(file_path, mode='w', newline='\n', encoding='utf-8') as csvfile:
            fieldnames = ['id', 'user', 'address', 'total_price']
            writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
            writer.writeheader()
            for order in orders:
                writer.writerow({
                    'id': order.id,
                    'user': order.user,
                    'address': order.address,
                    'total_price': order.total_price,
                })
||

こうすることで日繰りでファイルにデータをレポートとして出力させるバッチにしたりすることができる


** メッセージフレームワーク

*** messages.html を作成して、base.htmlに配置してすべての画面でメッセージを出力できるようにしておく

まずはsnippetsに以下のように {% for msg in message %} を使用したメッセージ出力用の画面を作成しておく
messages.html
>|html|
{% for msg in messages %}
<nav class="py-2 mb-0 {{ msg.tags }}">
  <div class="container">
    {{ msg }}
  </div>
</nav>
{% endfor %}

この中で

settings.pyにメッセージの重要度ごとのCSS設定を記述しておく

settings.pyに以下のような記述をしておく

from django.contrib import messages

# messages
MESSAGE_TAGS = {
    messages.ERROR: 'rounded-0 alert alert-danger',
    messages.WARNING: 'rounded-0 alert alert-warning',
    messages.SUCCESS: 'rounded-0 alert alert-success',
    messages.INFO: 'rounded-0 alert alert-info',
    messages.DEBUG: 'rounded-0 alert alert-secondary',
}
メッセージフレームワークをビューの処理の成功時・失敗時のメッセージ制御に使用する

ビューの処理毎にmessagesを付け加えることでメッセージを表示させる

成功した場合の例

from django.contrib import messages

class View(CreateView):
  def form_valid(self, form): 
    messages.success(self.request, '成功しました')
    return super().form_valid(form)

失敗した場合の例

from django.contrib import messages

class Login(LoginView):
    def form_invalid(self, form):
        messages.error(self.request, '失敗しました')
        return super().form_invalid(form)

これで処理毎に想定したメッセージが表示されていればよい

コンテキストプロセッサー

コンテキストプロセッサーとは動的なデータを表示させようと思うと画面毎に個別にViewsで画面にコンテキストを渡す必要があるが、あらゆる画面で共通して表示させたい情報があった場合はview毎に明示して記載すると面倒でかつメンテナンス性も落ちてしまうため、コンテキストプロセッサーとは記載しておけば、全ての画面にコンテキストとしてデータを渡す機能である

コンテキストプロセッサーのファイルを作成する

プロジェクト(config)フォルダの中にcustom_context_processors.pyとして作成することが一般的な模様

例 config/custom_context_processors.py

from django.conf import settings
from base.models import Item
 
def base(request):
    return {
        'TITLE': settings.TITLE,
        'POPULAR_ITEMS': items.order_by('-sold_count')
    }

上はタイトルや人気のアイテムをすべての画面にコンテキストとして渡す処理となっており、こうすることでどの画面でも人気商品をいつでも表示できるようになる
ちなみにコンテキストプロセッサーの変数は大文字で記載するのが慣習なのかもしれない

settings.pyにコンテキストプロセッサーを使用する設定を追記する

主にTEMPLATESにcontext_processorsの項目に先ほど作成したファイルのパスを記述する
configはプロジェクト名になるため、別の名前になっている場合はその名前に合わせる

settings.py

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [BASE_DIR / 'templates'],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'config.custom_context_processors.base',  # 追記
            ],
        },
    },
]

# custom_context_processors
TITLE = 'VegeKet'
テンプレートにおけるコンテキストプロセッサの表示

通常のテンプレートに変数を表示させるのと同じようにブラケット記法で {{ TITLE }} 表示させることができる
例: header.html

<nav>
    <a class="py-2 text-white" href="/">{{ TITLE }}<!--VegeKet--></a>
</nav>
TIPS! バッジの作成と表示

Webコンポーネントで言及されるバッジとは、Webアプリの通知(ベルのマーク)などで何件通知があるかなどを表示してくれるコンポーネントのことである

ヘッダーなどに設定する
コンテキストプロセッサーに登録した変数を使用すると便利
header.html

<a class="py-2 d-none d-md-inline-block text-white" href="/cart/">Cart
      {% if request.session.cart and request.session.cart.items|length != 0 %}
      <span class="badge badge-danger">{{request.session.cart.items|length}}</span>
      {% endif %}
    </a>

表示の方法としてはclass="badge badge-danger"クラスでbadgeを指定するだけでよい(Bootstrapの場合)
今回はコンテキストプロセッサーではなくセッションのcartデータから値を取得している

TIPS! テンプレート構文を使用したときにHTMLのインデントがずれてしまうとき

特にテンプレート内で比較構文を入れるとこれが起きやすい
例 {% if compare < 10 %} の < がHTMLのタグと認識されることが起きたりする
これは文法的にも問題はなく対処法も特にないので、見過ごすしかない模様

Tipa! 404 Not Foundページの実装

templatesフォルダ直下に 404.html を作成して、settings.pyの
DEBUG=False
ALLOWED_HOSTS = ['127.0.0.1']
を設定すると、404.htmlの内容が表示されるようになる
DEBUGがTrueだったり、ALLOWED_HOSTSが設定されていなかったりすると、エラーが返ってくる

500.htmlなど他のステータスコードも同様に実装することができる

404.html

<!--省略-->

<div>404 Not Found アクセスしようとしたページは見つかりません</div>

<!--省略-->

Next.js テスト実装方法 基本編勉強

この記事はUdemyの学習メモです
テストに関して解説してくれる教材はなかなか見つからないのでかなり重宝させてもらいました
www.udemy.com

Next.jsで実装するプロジェクトのテストの導入と静的テストの基本形の内容だけ記載しておきます

目次

テスト準備

1. プロジェクト作成手順

https://github.com/GomaGoma676/nextjs-testing/blob/main/README.md
この通りにやるとテストの導入ができます

1-2. 必要 module のインストール

インストールすると以下のようなメッセージが出ます

  1. axios@0.24.0
  2. swr@1.0.1
  3. msw@0.35.0

added 92 packages from 86 contributors and audited 534 packages in 28.619s

96 packages are looking for funding
run `npm fund` for details

found 0 vulnerabilities

上記のパッケージ警告は0件だがWindows(OSに関係ないのかもしれないが)でReactでパッケージインストールしていると結構な数の警告が出ます
警告が出るのは気持ち悪い派の人のために対処法(https://shinseidaiki.hatenablog.com/entry/2021/10/09/204921)はまとめてみましたが、あんまり意味がないようなので警告が出た場合は放置でいいと思います

2-1.
jest-css-modulesはテスト時に不具合を出す可能性のあるCSSのモック化のためのライブラリ

2-2.
windowsユーザーはtouch使えないので右クリックでファイルを作る

2-3.
testPathIgnorePatternsはテストとは関係ないフォルダを無視する記述
"moduleNameMapper"はCSSファイルがプロジェクトに存在した場合はmock化する記述


2-4.
package.jsonのscripts部分はnpm run ○○にコマンドを設定できる部分なので、testを記述してnpm run testでテスト実行できるようにしている
デフォルトでは全テストケースの成功失敗しか出力されない

    • env=jsdom --verboseオプションをつけると各テストケースの成功失敗が出力される

3-1.
windowsユーザーは touch は右クリック作成

3-3.
npm run devを実行するとtsconfig.jsonの内容が自動的に生成されます

3-4.
f:id:shinseidaiki:20211108003113p:plain
apiフォルダは削除

4-2.
初期化コマンドを実行すると設定ファイルが2つ自動生成される

4-3.
これはpagesやcomponentsフォルダ下の実際に使用されているtsxファイルのユーティリティファイルに対してデプロイするときに自動生成されるCSSファイルについて、記載したフォルダのものだけをCSSに自動生成する記述
これを設定しておかないと存在するtailwindのクラスユーティリティすべてがCSSに出力されてファイルサイズが膨大になる

テストの基本形

5.2のところ

Next.jsのテストはテストフォルダ__test__に○○.test.tsxという形のファイルとして保存する
f:id:shinseidaiki:20211108005427p:plain

ファイルの中身としては以下のように書く
○○.test.tsx

import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom/extend-expect'
import コンポーネント from '../pages/index'

it('テストケース説明', () => {
  render(<コンポーネント />)
  expect(screen.getByText('期待する文字列')).toBeInTheDocument()
})

renderはコンポーネントに変数などがあれば変数に値を代入してHTMLを作り出します
画面としては実際に表示しませんがitの中では仮想的に画面をいじっているのと同じ環境を作り出しています
screenはrenderでレンダリングした画面を差していて、getByTextで画面の中に'期待する文字列'、教材の場合は'Hello Nextjs'があるかどうか探します
toBeInTheDocumentでは探した文字が画面上に表示されていれば正解であると判定します
これが基本形です

npm run test でテストが失敗した場合は原因などの情報を含めて出力される
この失敗のケースでは"期待している文字列"が見つからなかったので、コンポーネントの方に"期待している文字列"をちゃんと実装しなさいと言っています
しかしながら"期待している文字列"もスペルミスなどで間違う可能性があるので真に受けすぎないのも大事です

FAIL __tests__/Home.test.tsx
× Should render hello text (40 ms)

● Should render hello text

TestingLibraryElementError: Unable to find an element with the text: Hello Nextjs2. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.

......

Test Suites: 1 failed, 1 total
Tests: 1 failed, 1 total
Snapshots: 0 total
Time: 3.296 s
Ran all test suites.
npm ERR! Test failed. See above for more details.


また上記のテスト結果だけでは原因を特定できない時にテストのデバッグがscreen.debug()でできます
ただし明示的にscreen.debug()を入れなくてもテスト失敗の時はデフォルトでデバッグ出力は出力されます

it('テストケースの説明', () => {
  render(<コンポーネント />)
  screen.debug()
  expect(screen.getByText('期待している文字列と違う文字列')).toBeInTheDocument()
})

デバックを入れているときのテストが成功する場合のコンソールへの出力例
(テスト失敗の時はデフォルトでデバッグ出力されている模様)
レンダリングされているHTMLの要素を出力させている

console.log
    <body>
      <div>
         本当の文字列
      </div>
    </body>

Static Site Generation 実装パターンのテスト作成

静的なコンテンツをテストするパターン

テストするための画面作り

まずはテストするための画面を2画面作成します
Next.jeではすべての画面で共通して使われるベースの画面components/Layout.tsxを作成して各画面はそれを継承するのが通常の実装になる

Layout.tsx 基本形

interface TITLE {
    title: string
}

const Layout: React.FC<TITLE> = ({ children, title = 'Next.js' }) => {
    return (
        <div></div>
    )
}

export default Layout

Layout.tsx のreturnの中身は Headとheaderとmainやfooterなどで通常構成される
Link href="/"タグはルーティングでSPAとして差分だけを読み込みに行ってくれる
対してa href="/"タグを使うと全量ページを読み込みに行くのでLayout.tsxのヘッダーに使われる画面遷移ではLinkを使うのが一般的
ホーム画面とブログ画面の2画面に遷移できるようにしておく

Layout.tsxのreturn の中身だけ記載

return (
        <div>
            <Head>
                <title>{title}</title>
            </Head>
            <header>
                <nav>
                   <Link href="/">
                      <a data-testid="home-nav" 
                       >Home</a>
                    </Link>
                    <Link href="/blog">
                     <a data-testid="blog-nav" 
                      >Blog</a>
                  </Link>
                </nav>
            </header>
            <main>
                {children}
            </main>
        </div>
    )

使用しているHeadやLinkのインポートはこちら

import Head from 'next/head';
import Link from 'next/link';


次に画面を作ります
ホーム画面となるindex.tsxを編集します
Layout.tsxをベースに作成するので、return 直下をLayoutで囲みます

index.tsx

import Layout from "../components/Layout"

const Home: React.FC = () => {
  return (
    <Layout title="Home">
      <p>ホーム画面です</p>
    </Layout>
  )
}
export default Home

次に2つ目の画面となるblog-page,tsxをpagesフォルダ下に作成します
next.jsはpagesフォルダ直下に格納したファイル名から自動的にルーティングするので、blog-pageという名前を使うと/blogでblog-page,tsxが自動的に参照されるような仕掛けとなっています
blog-page,tsxもindex.tsxと同じようなコードになります

blog-page,tsx

import Layout from '../components/Layout'

const BlogPage:React.FC = () => {
  return (
    <Layout title="Blog">
      <p>ブログページです</p>
    </Layout>
  )
}
export default BlogPage

テスト実装

1画面目のテスト、静的テスト基本形

まずはindex.tsxの静的な画面をテストします
これはテスト基本形のところで学んだ通り__test__フォルダを作成してHome.test.tsxを作成し、以下のような手順でコードを作成していっててテストを実装していく

まずは基本的なテストモジュールのインポートをする

import {render, screen} from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';

次にテストしたい画面モジュールをインポートする

import Home from '../pages/index'

次にテストケースのひな型を書く

describe('テストケースの説明_○○を確認する', () => {
    it('テストケース1_正常系_○○', async () => {
        
    })
    it('テストケース2_異常系_○○', async () => {
        
    })
})

テストケースの中身を書く

it('テストケース_○○', async () => {
   render(<Home />) # テストしたい画面のレンダリング
   expect(screen.getByText('期待する文字列')).toBeInTheDocument() # 検証する内容
})

このようにしていくと完成形は以下のようになる
Home.test.tsx

import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom/extend-expect'
import Home from '../pages/index'

it('テストケース説明', () => {
  render(<Home />)
  expect(screen.getByText('ホーム画面です')).toBeInTheDocument()
})

これが静的テストの基本形にあたり、Home画面の表示項目が増えたらexpectを増やしていくことで確認したい項目を増やしてテストを作っていく

2画面目のテスト、画面遷移を伴うテスト

次にBlog画面のテストだが、リンクをクリックして画面遷移をする挙動に関してもテストが行えるため画面遷移のテストもここで行うことにする

そのためには画面遷移つまりLinkコンポーネントのテストには別途必要なコンポーネントがあるためインストールする

npm install next-page-tester

では2つ目の画面をテストするテストファイル__tests__/NavBar.test.tsxを作成する

NavBar.test.tsxでは画面上のリンクのクリックを模擬するのでテスターがユーザーイベントをするためのモジュールをインポートします
またそれに伴い各種モジュールもインポートします

NavBar.test.tsx

import useEvent from '@testing-library/user-event'; // テスターが画面上の要素をクリックできるモジュール
import { getPage } from 'next-page-tester';
import { initTestHelpers } from 'next-page-tester'; // 初期化を行う

initTestHelpers()

次にルーティングからページを取得してきてレンダーする処理を施します
今回は画面が遷移するのでコンポーネントを直接レンダリングするのではなくルーティング経由にします
NavBar.test.tsx

const { page } = await getPage({
    route: '/index',
})
render(page)

今回はクリックイベントが発生するのでクリックイベントのコードは以下になります

useEvent.click(screen.getByTestId('実装時のタグに付与したtestId属性の値'))

なおクリックイベントで画面遷移をするにはLinkタグをクリックする必要があり、テスト上ではuseEvent.clickを使用することでクリックできるが、何をクリックするかのキーとなる部分に関してはgetByTestIdを使用しており、実装時の段階でLayout.tsxのLinkタグの中にdata-testidの属性を付与する必要があることは注意が必要
Layout.tsx

<Link href="/blog">
<a data-testid="blog-nav" 
 ...
>Blog</a>
</Link>

※クリックイベントにはタグ属性にtestIdを付与する必要がある


これらを実装した画面遷移を伴うテストであるNavBar.test.tsxテストの完成形はこちら

import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom/extend-expect'
import userEvent from '@testing-library/user-event'
import { getPage } from 'next-page-tester'
import { initTestHelpers } from 'next-page-tester'

initTestHelpers()

describe('画面遷移を伴うテスト', () => {
  it('テストケース1', async () => {
    const { page } = await getPage({
      route: '/index',
    })
    render(page)

    userEvent.click(screen.getByTestId('blog-nav'))
    expect(await screen.findByText('ブログページです')).toBeInTheDocument()
  })
})