ダイキのアプリ開発ナレッジ

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

買った指輪が最高!給料3か月分で買った結婚指輪の魅力

はじめに

買ってよかった2023かきかきします~

2023年に買ってよかったもの

ずばり、給料3か月分の婚約指輪です

去年の2023年にダイキは結婚したのですが、その時に結婚指輪を購入しました

その時に買った給料3か月分の指輪が、本当にイマ思っても買ってよかったものだなと思っています

ダイヤの価値より思い出重視

ダイヤに関しては、調べると、ダイヤ自体の価値って右肩下がりになるような未来しか想定されていないようですね

自分の時期ではダイヤの値段が高騰していましたが、人工ダイヤモンドの技術革新があって、それとの差別化ができなくなってきているようですね

その部分に関しては、理解していました

しかし、それでもなお買いました


なぜなら、ダイヤが大事だからではないからです

僕が大事なのは妻です

その妻との思い出が一番大事だからです

給料3か月というエピソードこそが最も尊いものだと感じたからです

そして、実際に買ってみてその通りだったと信じています

ちなみに、これはプロポーズの後に知ったことですが、プロポーズにダイヤモンドを女性に贈るのは、ダイヤ売り会社のマーケティング戦略らしいですね

僕はまんまとその戦略に乗せられたようですが、全然それでよかったなと思います

むしろそういった王道ストーリーを用意してくれてよかったなと思います

給料3か月分とかそういうのも非常に王道で僕はこういうの好きです

しかし、最近だと給料3か月って言うのをやっている男性は少ないらしいですね

この辺の流れは、指輪を買いに行くまで知らなくて、周囲の男性はあんまし金使わないのかというのに驚きました

周囲の男性は一斉一大のイベントなんだから頑張ったらいいのにと思いますが、そのムーブメントのおかげで自分のやっていることが余計に妻想いのエピソードになったので、むしろありがたいかなと今では思っています

やはり、自分のしたことはすごく漢気を見せられたと思います

やはり男はかっこつけてなんぼですなと思いました

ダイヤ自体もいいものを買えた

あと、ダイヤの値段が高騰している時期でしたが、自分は滑り込みセーフでまだ価格が爆上がりしていない頃のダイヤを買うことができました

なので、値段の割にしょぼすぎるしょぼぼぼんというようなダイヤでもありませんでした

かなりいいものです

個人的にめちゃくちゃいいものを買えたと思っています

すごくきれいで、そのダイヤ一生見てられるくらいきれいでした

他のダイヤと違って何かが違うと思ってそれにしましたが、やっぱりなんか違うんですよね

僕たちに買われるためのダイヤだったんだろうなと思っています

おわりに

これから、プロポーズを控えている男性は、がんばれるなら背伸びしましょう

きっとものすごく喜んでくれるはずです

健闘を祈ります






▲ ここに「買ってよかった2023」を書こう

▶ 【PR】はてなブログ 「AIタイトルアシスト」リリース記念 特別お題キャンペーン
お題と新機能「AIタイトルアシスト」についてはこちら!
by はてなブログ

買ってよかった2023

Three.js 学習記録 基本的な使い方

はじめに

Three.jsの学習を記録していきます
Three.jsを使いこなしてゆくゆくは3次元的なサービスを提供できるフロントエンジニアを目指します

ビルドツールとThree.jsの導入

Viteを利用します
ja.vitejs.dev

自分はユーザー名に半角スペースを含むため、yarnではコマンドが失敗するためnpmでインストールします
ユーザー名の半角スペースでつまずく場合の参考:https://qiita.com/koseidaiki/items/d2be30379a7f05c37f62

 npm create vite@latest [プロジェクト名]

純粋なJSで開発する場合はvanilla
vueやreactで開発する場合はそちらを選択します
以下では純粋にJavaScriptで開発する方法で基本的な使い方を記載します

その後基本準備として

その後プロジェクトフォルダに移動して、

npm install three

package.jsonの内容をインストールする

npm install

Three.jsパッケージ使い方基本

JSで開発する場合にどうやって使うかですが、JSファイルに以下のモジュールをインポートして使います

import * as THREE from "three";

Three.js必要三要素と基本的な使い方

1. シーン 2. カメラ 3. レンダー

以下の3つの要素が必要であり、基本形は以下のようになる
1. シーン
2. カメラ
3. レンダー

main.js

import * as THREE from "three"; 

let scene, camera, renderer;

window.addEventListener("load", init);

function init() {
    // シーン
    scene = new THREE.Scene();
    // カメラ(視野角、アスペクト比、開始距離、終了距離)
    camera = new THREE.PerspectiveCamera(
        50,
        window.innerWidth / window.innerHeight, // ブラウザの横幅 / ブラウザの縦幅
        0.1,
        1000
    );
    camera.position.set(0, 0, 500)
    // レンダラ
    renderer = new THREE.WebGLRenderer({alpha: true}); // 背景を設定する場合は透過度を設定する
    renderer.setSize(window.innerWidth, window.innerHeight)
    document.body.appendChild(renderer.domElement);
    renderer.render(scene, camera);
} 

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ThreeJs</title>
</head>
<body>
    <script src="main.js" type="module"></script>
</body>
</html>

4 テクスチャ 5. ジオメトリ 6. マテリアル 7. メッシュ

上記では物体が表示されないので物体を配置したい場合はテクスチャ、ジオメトリ、マテリアル、メッシュを設定する
基本形は以下

main.js

    // テクスチャ = 柄 (https://threejs.org/docs/index.html?q=texture#api/en/loaders/TextureLoader)
    let textures = new THREE.TextureLoader().load("画像のPath")
    // ジオメトリ = 骨格 (https://threejs.org/docs/index.html?q=geometry#api/en/geometries/SphereGeometry)
    let ballGeometry = new THREE.SphereGeometry(100, 64, 32) // 例:球形
    // マテリアル = 材質 (https://threejs.org/docs/index.html?q=material#api/en/materials/MeshPhysicalMaterial)
    let ballMaterial = new THREE.MeshPhysicalMaterial({
        map: textures // テクスチャを設定
    }); 
    // メッシュ = ジオメトリ + マテリアル
    let ballMesh = new THREE.Mesh(ballGeometry, ballMaterial)
    scene.add(ballMesh) // 最後にシーンに追加する

    // 平行光源を設定することで物体を視認することができる
    let directionalLight = new THREE.DirectionalLight(0xffffff, 2);
    directionalLight.position.set(1,1,1)
    scene.add(directionalLight)  // 最後にシーンに追加する

8. アニメーション

また、基本的に動きのある画面を作りたいはずなので、アニメーションを使う
以下のようにアニメーション関数を作って、その中にレンダリングを入れて、変更のたびに再レンダリングをするような実装を行う


main.js

function animate() {
  // おまじない
	requestAnimationFrame( animate );
        // 動きのロジック - 例えばメッシュ(物体)の位置を変更するアニメーションをつけるなど
        // ballMesh.position.x += 時間に応じて加算
        // レンダラをこちらに入れる
	renderer.render( scene, camera );
}
// アニメーションを実行
animate();


ところで、これらにはバリエーションがありそのユースケースは以下のようになる

1. シーンについて

シーンについては基本的にバリエーションはなく思考停止で

scene = new THREE.Scene();

を使用すればよい
https://threejs.org/docs/index.html#api/en/scenes/Scene

2. カメラについて

カメラについては2つほどバリエーションがある
自分のアプリをどのように見せたいかを考えて適切なカメラを選択するとよい

1. PerspectiveCamera
遠近法(遠くのものは小さく)が適用される一番直観的な普通のカメラ
ユースケースは一番多いと思われる
https://threejs.org/docs/index.html#api/en/cameras/PerspectiveCamera


2. OrthographicCamera
遠近法が適用されないカメラ
遠くのものも近くのものも同じ大きさに見える
https://threejs.org/docs/index.html#api/en/cameras/OrthographicCamera

他にはStereoCameraがあって、立体的にみられるカメラもある模様
VRバイスが必須

2.1. カメラや物体のコントロールについて

カメラや物体のコントロール制御をできる簡便な機能がある

基本的には一番直観的なOrbitControlsを使っておけばよい
https://threejs.org/docs/index.html?q=Controls#examples/en/controls/OrbitControls

他一人称視点でコントロールしたいときは、FirstPersonControlsがある
https://threejs.org/docs/index.html?q=Controls#examples/en/controls/FirstPersonControls

2.2 光源について

Three.jsでは光源を必要とするマテリアル(MeshPhysicalMaterial)と必要としないマテリアル(MeshBasicMaterial、MeshNormalMaterial)があり、光源を必要とするマテリアルを使う場合に光源を設定する必要がある

光源にも種類があり、およそ4パターンほどある


1. 全体光源 AmbientLight
全方向から均一の光が当たる光源です
ちなみにThree.jsでは同じ物体の表裏の影はシミュレーションで表現されるが、他の物体に光がさえぎられても影ができることは基本的になかったり、他の物体から反射する光などはシミュレーションされていないので、物体の裏側の影の強さを表現することに使ったりすることができます
https://threejs.org/docs/index.html?q=Light#api/en/lights/AmbientLight


2. 平行光源 DirectionalLight
名が体を表している光源です
https://threejs.org/docs/index.html?q=Light#api/en/lights/DirectionalLight


3. 半球光源 HemisphereLight
2つの平行光源が互いに逆方向からやってくる光源です色を変えるなどができます
ユースケースは空と大地の対比を表現したいときに便利です
https://threejs.org/docs/index.html?q=Light#api/en/lights/HemisphereLight


4. 点光源 PointLight
とある1点から拡散する光源です
豆電球のイメージです
https://threejs.org/docs/index.html?q=Light#api/en/lights/PointLight


5. スポットライト光源 SpotLight
スポットライトや懐中電灯の光源のイメージです
https://threejs.org/docs/index.html?q=Light#api/en/lights/SpotLight


6. RectAreaLight
スタジオにおいてある四角形に広がる光源です
THREE デフォルトにないパッケージであることに注意が必要です
https://threejs.org/docs/index.html?q=Light#api/en/lights/RectAreaLight

3. レンダーについて

こちらもあまりバリエーションは考えなくていい
WebGLRendererを使う
https://threejs.org/docs/index.html#api/en/renderers/WebGLRenderer

4 テクスチャ

テクスチャーは画像を用意して物体に貼るというユースケースが最も多いと思われる
その場合は思考停止でTextureLoaderを使えばよい
https://threejs.org/docs/index.html?q=texture#api/en/loaders/TextureLoader

5. ジオメトリ

ジオメトリがそもそも何なのかというと物体の表面の任意の頂点を結んだ格子上の骨格のことです
不規則な形をとる物体の場合、任意の頂点の数が多ければ多いほど再現度が高くなります
フラーレンのイメージです

ジオメトリは種類が多く、こちらから使いたい出来合いのジオメトリを使うことが便利です
https://threejs.org/docs/index.html?q=geometry

よく使うものとしてはBoxGeometry, PlaneGeometry, SphereGeometry, TorusGeometry あたりだと思います
任意の形を自作する場合はBufferGeometryのベーシッククラスを継承して作成するか他の3Dソフトで作成したものをインポートする必要がある

6. マテリアル

マテリアルは物体の材質に相当するものです
大きく分けて光源を必要とするものと光源を必要としないものに分かれます
光源を必要としないものはMeshBasicMaterialとMeshNormalMaterialがあります
光源を必要とするものにはMeshPhysicalMaterialがあります
他のパラメータとしては金属光沢や粗さ、色などがあります
パラメータが多くデザインの範囲なのでUIデバッグをつけると便利です
https://threejs.org/docs/index.html?q=material

7. メッシュ

フラーレンに皮をつければサッカーボールになるイメージです
メッシュも基本的には組み立て作業に相当するためバリエーションは特にありません
思考停止でMeshオブジェクトを生成します
これが物体に相当します
注意点としてはこれを作った後にシーンに追加することを忘れないようにします

const box = new THREE.Mesh(
  new THREE.BoxGeometry(1,1,1), 
  new THREE.MeshNormalMaterial()
)
scene.add(box) // これを忘れない

UIデバッグ

UIデバッグというのはわざわざコードに戻らなくても画面上でパラメータの変更ができる機能のことです
この機能を使うと画面右上にパラメータをいじれるUIが出てくる

lil-guiを使用する
インストール方法は下記
lil-gui.georgealways.com

インスタンスを生成して、パラメータを追加するとすぐに利用できる(CDNを利用)
sceneにメッシュを追加するのと同じ要領

import GUI from 'https://cdn.jsdelivr.net/npm/lil-gui@0.16/+esm';
const gui = new GUI()

// boxというオブジェクトを作成

gui.add(box.position, "x") // x軸に沿って動かせるようになる
gui.add(box.position, "x", -3, 3, 0.01) // ドラッグで動かせるようになる
gui.add(box.position, "x").min(-3).max(3).step(0.01) // こういう書き方もできる

.nameを使用すると名前を付けることができる

THREE デフォルトにないパッケージを使う

THREE デフォルトにないパッケージを使う場合、jsmフォルダ内にある可能性がある
フォルダパス自体は以下
three.js-r[VERSION]\three.js-r[VERSION]\examples\jsm

光源のhelpers関数などがこちらに入っている場合がある

おわりに

今後は本格的なフロントエンド開発を行っていきたいので、Vue.jsにおけるThree.jsの使い方を調査していこうとおもいます

[追記]
残念ながらVue.js3.0でThree.js開発を行うことは現実的ではない様子
Three.jsをそのまま使う場合、Vueの書き方との相違が多くかなり使いずらいことが分かっている
そのためVueGLというライブラリが用意されているが、それもVue3.0系にはまだ対応しておらず、実質VueでのThree.js開発はあきらめた方が良いことが分かった
最近のフロントエンドはほぼReactによってきつつあるようにも見えるので、まだフロントエンドのどのフレームワークにも染まっていない人はReactを選んでおけば間違いがない気がします(Vue3.0簡単とか言ってるやつ、あれは詐欺です。Vueは書き方がありすぎてもはやブログなどで検索して出てきた情報が全くアテにならなかったりするので、ぶっちゃけ初心者には難しいです。Vueのドキュメントで読んで理解が進められる人はReactでも同じではと思ったりします。サポートされているライブラリもReactの方が多い印象ですし。)

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

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

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

Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Process

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

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

vue create [プロジェクト名]

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


できること(抜粋)

講座1

講座2

  • 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認証の実装


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

Next.jsのSSG(Static Site Generation)に関する4パターンの整理

Next.js は基本的にデフォルトでビルド時にHTMLを事前生成する
ビルド時にDBからPre-FetchをしてHTMLを事前生成する方式とアプリ運用時のサーバーアクセス時にDBからHTMLを生成する方法が大きく4種類あるので整理する

SSGはStatic Site Generationの略でユーザーに渡す情報は基本的には静的データを渡そうというコンセプトのもとに作られたアプリがユーザーにデータを渡す仕組みである
従来のデータ配信方式ではDBに格納された情報を毎回動的にHTMLにレンダリングして渡していたが、SSGで実装されたアプリでは一度HTMLにレンダリングされた情報は静的コンテンツにしておいて次回のアクセス時には静的コンテンツとして配信される
このHTMLにレンダリングするタイミングの違いによってSSGは以下の4種類のパターンに分類される

SSGについて例えを出すならば今までは飲食店で注文を受けたらその都度料理していたが、開店前に作り置きを作っておいて注文を受けたらそれを出すようなものです
また飲食店は新規メニューのオーダーも都度受け付けていて新規メニューの注文があればその場で料理を行って提供し、その後は作り置きを提供するような仕組みになっています

ISRはIncremental Static Regenerationの略で最新情報をHTMLにレンダリングしておいて静的コンテンツにしておく技術である

ちなみにややこしい話ですがSSRという単語もあって、こちらはServer Side Renderingの略でHTMLは従来クライアント側で生成していたものをサーバ側で作成して渡すようにしたことにあえて名前がついたものになっています
SSRについて例えを出すならば今までコープで食材だけもらって料理は自宅でするのが当たり前だったが生活様式が変化してきてUserEatsを使う人が増えたので"出前"という言葉がはやり始めたみたいな状況です

1.SSG

DocumentやHelpなどのように「完全に静的なページ」
SEOは特に意識しない
代表的なものとしては Index ページがこの実装にあたる場合が多いと思われる
あとはDocumentやHelpページ


2.SSG + Prefetch

ビルド時にデータベースから取得してきた情報を事前にHTMLにレンダリングして静的データとしておくパターンです
Googleクローラーに読み込まれるため、SEO対策に有効です
使われるページの例としてはブログページや商品一覧などが考えられています
ただしそのままだとビルド時の情報のみ使用されるため最新情報を反映させ合たい場合は別途対策が必要です
最新情報を取得する場合にはISRが必要になり、基本的にはISRをセットで利用する場合が多いと思われます
ISRを加えた2.1のパターンがNext.jsで一番よく実装されるであろうと考えられます

2-1. SSG + Prefetch + ISR

Next.jsはビルド時の情報が使われてその後情報が更新されません
後々情報を更新したい場合はISR(DBの最新データで静的サイトを作り直す技術)を使って更新します。
ビルド時にデータを事前にレンダリングするだけでなく、ビルド以降に作成・更新された最新情報に関してもHTML事前レンダリングが実行されて(初回アクセスがトリガー)グーグルのクローラーに分析してもらえるようになり、最新の情報にもSEO対策が施されます


3.SSG + Client side fetching

SEOを意識する必要はないが、最新情報を取得したいページに利用されるパターンです
通常の「create-react-app」(従来のウェブアプリの実装)と似たようなレンダリングの仕組みになります
Client side fetching(通常のAxiosなどのAPI経由でデータを取得する方法)を使って、その都度バックエンドから取得してきたデータをレンダリングしてページを表示させます。
最新データはHTMLにレンダリングされないために新規作成もしくは更新された情報はDBから情報を取得して毎回レンダリングされる仕様となります
DBから情報を取得しないとレンダリングされないのでグーグルのクローラーは有益な情報は何もないと判断してしまい最新の情報に関してはSEOされることはありません
代表的なものとしてはDashboardやTodoリストなどのようなページです


4.SSG + Prefetch + Client side fetching(useSWR)

データをリアルタイムで作成・更新する必要があって、かつ、最新情報にもSEO対策を施す必要があるようなページに利用されるパターンです
2のパターンとほぼ同様ですが、Client side fetchingを使用することで、データの更新をリアルタイムに反映することができます
ただしこの状態では画面表示においてビルド時の過去データと最新情報に乖離がある場合に過去データが表示されたのちに最新データに更新されるという挙動が起きてしまうので、以下の最新データから静的コンテンツを作成しておくISRと組み合わせて使う実装が実用的である

4-1. SSG + Prefetch + Client side fetching(useSWR) + ISR

Next.jsはビルド時の情報が使われてその後情報が更新されません。
後々情報を更新したい場合はISR(DBの最新データで静的サイトを作り直す技術)を使って更新します
ビルド時にデータを事前にレンダリングするだけでなく、ビルド以降に作成・更新された最新情報に関してもHTML事前レンダリングが実行されて(初回アクセスがトリガー)グーグルのクローラーに分析してもらえるようになり、最新の情報にもSEO対策が施されます
初回アクセス以降は事前レンダリングされた最新情報のHTMLが配信されるためISRを使用しないと過去データが表示されたのちに最新データが表示される現象も解決する(具体的にはISRのrevaridateを設定する必要がある)

Next.jsのテスト実行時に.env.test.localの環境変数が読み込まれない時の対処

はじめに

Next.js開発においてAPIエンドポイントのURLを環境変数化した場合にアプリ自体は動くがテストが通らなくなる事案が発生
調べによるとJESTは環境変数を.env.test.localに記載してもうまく読み込んでくれない場合がある模様

環境

OS: Windows 10
node -v: v14.18.0
yarn -v: 1.22.17

package.json

{
  "name": "hoge-project",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "test": "jest --env=jsdom --verbose",
    "gen-types": "graphql-codegen --config codegen.yml"
  },
  "dependencies": {
    "@apollo/client": "^3.4.17",
    "@apollo/react-hooks": "^4.0.0",
    "@heroicons/react": "^1.0.5",
    "autoprefixer": "^10.4.0",
    "cross-fetch": "^3.1.4",
    "graphql": "^16.0.1",
    "next": "11.1.2",
    "postcss": "^8.3.11",
    "react": "17.0.2",
    "react-dom": "17.0.2",
    "setimmediate": "^1.0.5",
    "tailwindcss": "^2.2.19"
  },
  "devDependencies": {
    "@babel/core": "^7.16.0",
    "@graphql-codegen/cli": "2.3.0",
    "@graphql-codegen/typescript": "^2.4.1",
    "@graphql-codegen/typescript-operations": "2.2.1",
    "@graphql-codegen/typescript-react-apollo": "3.2.2",
    "@testing-library/dom": "^8.11.1",
    "@testing-library/jest-dom": "^5.15.0",
    "@testing-library/react": "^12.1.2",
    "@testing-library/user-event": "^13.5.0",
    "@types/jest": "^27.0.3",
    "@types/node": "^16.11.8",
    "@types/react": "^17.0.35",
    "babel-jest": "^27.3.1",
    "eslint": "7",
    "eslint-config-next": "12.0.4",
    "jest": "^27.3.1",
    "jest-css-modules": "^2.1.0",
    "msw": "^0.35.0",
    "next-page-tester": "^0.30.0",
    "typescript": "^4.5.2"
  },
  "jest": {
    "testPathIgnorePatterns": [
      "<rootDir>/.next/",
      "<rootDir>/node_modules/"
    ],
    "moduleNameMapper": {
      "\\.(css)$": "<rootDir>/node_modules/jest-css-modules"
    }
  }
}

対処法

対処1 Home.test.tsx以外の全てのテストファイルの import 直下に下記環境変数を定義してからyarn testを実行
process.env.NEXT_PUBLIC_HASURA_URL = 'https://xxx.hasura.app/v1/graphql'

こちらはテストをGitHubにアップロードするのをためらってしまうため実用的ではない

対処2 環境変数を各テストケースで毎回読み込む

以下の記事に解決策は書いてあったものその通りの方法を実施した場合ではエラーが出たため自環境では各テストケースで環境変数を読み込む実装とした
Jestを用いたNext.jsのテスト内で環境変数を利用する - Breath Note

プロジェクト直下に
test/setupEnv.ts を作成し、以下を実装

import { loadEnvConfig } from '@next/env'

export const setupEnv = async (): Promise<void> => {
  loadEnvConfig(process.env.PWD || process.cwd())
}

各テストのテストケースで上記のsetupEnvを実行する
XXXXX.test.tsx

// ..........................
import { setupEnv } from "../test/setupEnv" 

//.......................

describe('.................................', () => {
    it ('.................................', async () => {
        await setupEnv()
        // ......................................

こちらは各テストケースに記載する手間はあるが、外部ファイルから環境変数を読み込む形でテストを利用できる

対処3 環境変数をテスト準備時に読み込む

対処2ではhandlersがsetupEnvより前に記述されている場合に環境変数を読み込んでくれないという事案が発生する
解決方法としては await setupEnv() の処理をテスト準備処理をしているコード上部に持ってきてawaitを取り除いてsetupEnv() を行う
こうすることでsetupEnv()を各テストケースに記載する手間も省けるのでこの形が最も良い


XXXXX.test.tsx

// ..........................
import { setupEnv } from "../test/setupEnv" 

setupEnv()

//.......................テスト準備の処理

// 本チャンテストにsetupEnvを記述する必要なし
describe('.................................', () => {
    it ('.................................', async () => {
        // ......................................

Create-React-Appでfound 24 vulnerabilities (8 moderate, 15 high, 1 critical) run `npm audit fix` to fix them, or `npm audit` for detailsの警告が出る対処法

Windows環境でnodeを使うといろいろなところで支障が出るとネットの記事に書いていましたが、例にもれずそのあおりを受けました

環境

  • Windows 10
  • nvm --version 1.1.7
  • node --version v14.18.0
  • npm --version 6.14.15

事象

npx craate-react-app [アプリ名]
のコマンドを打つと、実行はされるが、以下のような警告が発生
気持ち悪いので、どうにかしたい

153 packages are looking for funding
  run `npm fund` for details

found 24 vulnerabilities (8 moderate, 15 high, 1 critical)
  run `npm audit fix` to fix them, or `npm audit` for details

対処

この場合、

npm audit fix

のコマンドでうまくいくケースがおおいらしいが、自分の環境ではうまくいかず、手動でどうにかしないといけない模様

[参考]
npm audit fixでは解決できなかった脆弱性の修正

npm audit

を実行。24個の修正対象パッケージが表示される。以下抜粋

                       === npm audit security report ===


                                 Manual Review
             Some vulnerabilities require your attention to resolve

          Visit https://go.npm.me/audit-guide for additional guidance


  Moderate         Inefficient Regular Expression Complexity in
                  chalk/ansi-regex

  Package         ansi-regex

  Patched in      >=5.0.1

  Dependency of   react-scripts

  Path            react-scripts > webpack-dev-server > yargs > cliui >
                  string-width > strip-ansi > ansi-regex

  More info       https://github.com/advisories/GHSA-93q8-gq69-wqmw

...他多数

  Critical        Prototype Pollution in immer

  Package         immer

  Patched in      >=9.0.6

  Dependency of   react-scripts

  Path            react-scripts > react-dev-utils > immer

  More info       https://github.com/advisories/GHSA-33f9-j839-rf8h


  High            Uncontrolled Resource Consumption in ansi-html

  Package         ansi-html

  Patched in      No patch available

  Dependency of   react-scripts

  Path            react-scripts > @pmmmwh/react-refresh-webpack-plugin >
                  ansi-html

  More info       https://github.com/advisories/GHSA-whgm-jr23-g3j9

....他多数

found 24 vulnerabilities (8 moderate, 15 high, 1 critical) in 1956 scanned packages
  24 vulnerabilities require manual review. See the full report for details.

ここでcriticalになってるimmerを調べる

npm ls immer

すると、依存関係がわかる

project_name@0.1.0 C:\Users\[MyUser]\Documents\Development\ReactTutorial\tutorial
`-- react-scripts@4.0.3
  `-- react-dev-utils@11.0.4
    `-- immer@8.0.1

これはpackage.jsonには記述されていない箇所なので、この依存関係を修正していく
package-lock.jsonの方を修正する(lockの方ね)
immerで検索してヒットした箇所のバージョンを変更する

[修正前]

"immer": "8.0.1",

[修正後]

"immer": "9.0.6",

node-modulesを削除して、

npm install 

を実行

これをエラーがなくなるまで警告が出ているパッケージに繰り返す
パッチがないものもあり、最後まで警告解消できないものもある
気持ち悪いが仕方ない....

npm install 実行後、npm audit実行

                       === npm audit security report ===


                                 Manual Review
             Some vulnerabilities require your attention to resolve

          Visit https://go.npm.me/audit-guide for additional guidance


  High            Uncontrolled Resource Consumption in ansi-html

  Package         ansi-html

  Patched in      No patch available

  Dependency of   react-scripts

  Path            react-scripts > @pmmmwh/react-refresh-webpack-plugin >
                  ansi-html

  More info       https://github.com/advisories/GHSA-whgm-jr23-g3j9


  High            Uncontrolled Resource Consumption in ansi-html

  Package         ansi-html

  Patched in      No patch available

  Dependency of   react-scripts

  Path            react-scripts > webpack-dev-server > ansi-html

  More info       https://github.com/advisories/GHSA-whgm-jr23-g3j9

found 2 high severity vulnerabilities in 1941 scanned packages
  2 vulnerabilities require manual review. See the full report for details.


しかしながら、この状態でまた何らかのパッケージをインストール(npm install redux)すると、package-lockがもとに戻ってまたエラーが出てしまった
本番環境に挙げるときにこの作業するか、Linux環境作ってそこで実行する方がいいのかもと思った......


修正したpackage-lock.jsonは一応どこかに保管して差し替えて使おうと思う(自分の場合はuser直下のprojectsフォルダ直下に格納)
次にCreate-React-Appした際にこのlockファイルにさしえることで今回の手間を削減できるはず