ECサイト作成で学んだこと③

今回はECサイトを作成で学んだことを4つに分けて記事にしていきます。

:::note warn
以下の箇条書きの記事をそれぞれ作成します。
記事が出来次第、リンクへ変更になりますのでお待ちください。
:::

- エンジニアリングについて
- Djangoについて
- Pythonの文法
- 汎用ビューについて

# Pythonの文法
- select_related
- aggregate
- 文字列の抽出（スライス）
- 文字列の置き換え and 削除（replace)
- 型の確認（type, isinstance）
- f stringの応用
- 変数が辞書型のループ
- リスト、タプル、辞書の違い
- renderとredirectの違い

## `select_related`
`select_related`は、DjangoのORMで**外部キーを持つテーブルのデータを最適化して取得するためのメソッドです。使用するメリットとして、DBのクエリ回数を減らしてパフォーマンスを向上することができます。

### 具体例
以下の`CartPageView`クラスでは、カートに入っている商品情報を取得する際に `select_related("product")を使用しています。
```python
class CartPageView(ListView):
    template_name = 'cart/cart_page.html'
    model = CartProduct

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        # カートオブジェクトを取得
        cart = Cart.get_or_create_cart(self.request)
        # カートオブジェクトを元にカート内の商品を取得
        cart_products = cart.cart_products.select_related("product")

        for cart_product in cart_products:
            print(cart_product.product.name)  # JOIN により、1回のクエリで取得可能
        
        return context
```
- `cart.cart_products.select_related("product")`によって、`CartProduct`の`product` (外部キー)に関連するJOINを使って一度に取得
- `cart_product.product.name`いなくセスしても、追加のDBクエリが発生しない
### `select_related`を使わない場合（N+1問題発生）

```python
cart_products = cart.cart_products.all()  # ここではカート内の商品リストのみ取得
for cart_product in cart_products:
    print(cart_product.product.name)  # ここで毎回追加のクエリが発生
```
この場合のDBクエリ回数
- `cart.cart_product.all()`でカート内の商品リストを取得（1回のクエリ）
- ループ内で`cart_product.product.name` にアクセスするたびに追加のクエリが発生（商品数N回)

したがって、合計で1+N回のクエリが発生し、パフォーマンスが低下します。
商品が1つしかない場合はクエリの回数は変わりませんが、2つ以上となると無駄なクエリが発生します。

### `select_related`の動作の仕組み
`select_related`は、SQLの`JOIN`を使用して関連データを一度に取得することで、効率的なデータ取得を可能にします。
```sql
/* Django ORMが実行するSQL(`select_related`なし）*/
SELECT * FROM cart_product;
SELECT * FROM product WHERE id = 1;
SELECT * FROM product WHERE id = 2;
SELECT * FROM product WHERE id = 3;
SELECT * FROM product WHERE id = 4;
SELECT * FROM product WHERE id = 5;
...
```
上記のコードだとループごとに追加クエリが発生して、N+1問題が発生

次に`select_related`を使用した場合を見ましょう
```sql
/* Django ORM が実行する SQL（select_related あり） */
SELECT cart_product.*, product.*
FROM cart_product
INNER JOIN product ON cart_product.product_id = product.id;
```
このようにJOINにより、1回のクエリでデータを取得できます。


## `aggregate`(集計処理）
`aggregate`は、Django ORMでデータの集計（合計・平均・最大・最小など)を行うためのメソッドです。

### 実際の使用例（注文商品の合計金額を計算）
以下のコードは、特定の注文(`Order`)に紐づくすべての`OrderItem`の`subtotal`(小計)を合計し、注文ごとの合計金額を取得しています。
```python
from django.db.models import Sum

# 各注文に対して関連する OrderItem の合計金額を計算
for order in orders:
    total_subtotal = (
        OrderItem.objects.filter(order=order)
        .aggregate(total=Sum('subtotal'))['total'] or 0
    )

    # 注文情報をリストに格納
    order_summary.append({
        "order_id": order.id,
        "country": order.billing_address.country,
        "username": order.billing_address.username,
        "tota_price": total_subtotal,
    })
```
このコードのポイントは以下です。
- `Sum('subtotal')`を使用して、該当注文に紐づく全ての`OrderItem`の`subtotal`を合計
- `aggregate()`を使用すると、DBで計算が行われるため高速である
- `['total'] or 0`により、合計が`None`の場合でも`0`を返す
(データが存在しないケースの考慮）
- 辞書形式で結果が返される

今回は`Sum`を使用しましたが、`Ave`, `Min`, `Max`が使用できます。
詳しくは以下のURLをご覧ください。

https://django.codor.co.jp/how-to-use-aggregation


## 文字列の置き換えと削除（`replace()`)
`replace()`は、特定の文字を別の文字に置き換えたり削除したりするために使用します。

### `replace()`の基本的な使い方
```python
text = "2024/03/05"
print(text.replace("/", "-"))  # 2024-03-05
print(text.replace("/", ""))  # 20240305
```
特定の文字を削除するには`replace("削除したい文字", "")`を使用

### 実際の使用例（クレカの有効期限のフォーマットを整形）
:::note alert
前回も記載しましたが、ポートフォリオの作成ということで
特例としてクレカ情報をDBに保存しています。
本来は絶対にやらないことです。ご了承ください。
:::
以下のコードでは、有効期限の入力データから `/` を削除して数値のみを取得するために `replace()` を使用しています。
```python
def clean_expiration_date(self, *args, **kwargs):
    # ユーザーが入力した「MM/YY」形式の有効期限から「/」を削除
    expiry = self.cleaned_data.get("expiration_date", "").replace("/", "")

    # 取得した有効期限が4桁でなければバリデーションエラーを出す
    if len(expiry) != 4 or not expiry.isdigit():
        self.add_error("expiration_date", "有効期限は「MM/YY」の形式で入力してください")
```
- `replace("/", "")`によって、ユーザが入力した `/`（スラッシュ）を削除
- `expiry`には「MMYY」の形式でデータが格納される
- `len(expiry) != 4 or not expiry.isdigit()` をチェックすることで不正な入力を防ぐ

## 文字列の抽出（スライス）
### スライスの基本的な使い方
```python
text = "Pythonプログラミング"
print(text[0:6])  # Python
print(text[-5:])  # ミング
print(text[::2])  # Ptoプラミグ（2文字おき）
```
`[開始:終了]`で範囲を指定
また、`[開始:終了:ステップ]` で感覚を指定可能

今回は上記の続きでクレカの有効期限のチェックの場面で使用しました。

```python
# 1,2文字目を取得（例: "05", "11"）
expiry_month = int(expiry[:2])
# 3,4文字目を取得（例: "24", "30"）
expiry_year = int(expiry[2:])
```
- `expiry[:2]` → 最初の2文字を取得（有効期限の月）
- `expiry[2:]` → 3文字目以降を取得（有効期限の年）


## 型の確認（`type()` と `isinstance()`)
`type()` と `isinstance()` は、Python において 変数のデータ型を確認するためのメソッド です。
### `type()` の実際の使用例（価格データのバリデーション）
```python
def clean_price(self):
    price = self.clean_data.get('price')
    # priceが空だったらバリデーションエラー
    if not price:
        raise ValidationError("価格を入力してください。")
    # priceが0以下、int型以外だったらバリデーションエラー
    if price < 0 or type(price) != int:
        raise ValidationError("価格は1以上の整数を入力してください。™)
```
- `type(price) != int`で価格が整数型(`int`)であるか確認
- `type()`は変数の方を確認するために使用される

### `isinstance()` の実際の使用例
以下のコードでは、プロモーションコードのデータ型をチェックして、適切に処理しています。
```python
NO_PROMO_CODE = (1, "NOTHING", 0)

if isinstance(session_promo, str):
    try:
        promotion = PromotionCode.objects.get(promo_code=session_promo)
        return (promotion.id, promotion.promo_code, promotion.discount)
    # DBに存在しない値だったらNO_PROMO_CODEを返す
    except PromotionCode.DoesNotExist:
        return NO_PROMO_CODE
```
- `isinstance(session_promo, str)` を使用することで、変数`session_promo`が文字列(`str`)であるかをチェック
- 型が適切な場合にのみ、DB検索処理を実行する

### `type()` と `isinstance()`の違い
- `isinstance(変数, 型)`を使うと、継承関係のあるサブクラスを認識可能
- `type(変数) == 型`は、サブクラスのインスタンスを適切に判定できない
```python
class Animal():
    pass

class Dog(Animal):
    pass

dog = Dog()

print(type(dog) is Animal) # False
print(type(dog) is Dog) # True

print(isinstance(dog, Animal)) # True
print(isinstance(dog, Dog)) # True

```
- `Animal` クラスを継承した `Dog` というクラスがある
- `dog` を `Dog` でインスタンス化する
- 継承元の `Animal` クラスのインスタンスか `Dog` のインスタンスか調べる

:::note info
 Python では isinstance() を使う事を推奨されるケースが多い！
:::

## f-stringの応用
通常の使い方は以下のように使うことが多いと思います。
```python
price = 1000
print(f"ビックマックセット: {price}円")
# ビックマックセット: 1000円
```
今回は少し応用ということで
クレカの有効期限の際に**ゼロ埋め**を活用しました。

```python
def clean_expiration_date(self, *args, **kwargs):
    expiry_month = int(expiry[:2])
    expiry_year = int(expiry[2:])
    
    return f"{expiry_month:02}/{expiry_year:02}"
```
- `{expiry_month:02}` → 2桁の数値に変換し、ゼロ埋めする
  - 1 → 01, 9 → 09など
- `{expiry_year:02}` → 年の値もゼロ埋めして2桁にする
  - 2024 → 24, 2030 → 30など
- `f"{expiry_month:02}/{expiry_year:02}"` によって、スラッシュ `/` を挟んだフォーマットが作成される
  - 05/27, 11/26など


## 変数の中身が辞書型の場合のループ
基本的な使い方は以下です。
```python
data = {"name": "Alice", "age": 25}
for key, value in data.items():
    print(f"{key}: {value}")

# 実行結果
name: Alice
age: 25
```
`items()`を使うとキーと値をの両方を取得できます。

### 実際の使用例（プロモーションコードの割引額）
以下のコードは、ランダムに生成したプロモーションコードに対して、ランダム位な割引額を付与し、辞書に格納しています。
```python
import random, string

def promotion_code_generate():
    CODE_LENGTH = 7
    NUM_CODES = 10
    chars = string.ascii_letters + string.digits
    return [''.join(random.choices(chars, k=CODE_LENGTH))

def generate_discount(promotion_code_list):
    discount_list = list(range(100, 1001, 100))
    discount_dict = {}

    for promotion_code in promotion_code_list:
        discount_dict[promotion_code] = random.choice(discount_list)

    return discount_dict

# 生成したプロモーションコードと割引額を辞書として取得
promotino_codes = promotion_code_generate()
discount_mapping =your  generate_discount(promotino_codes)

# 辞書の中身をループ処理して出力
for code, discont in discont_mapping.items():
    print(f"プロモーションコード: {code}, 割引額: {discount}円")
```
- `promotion_code_generate()` で ランダムなプロモーションコード（7桁の英数字）を 10 個生成
- `generate_discount()` で 各コードに100円〜1000円の割引額をランダムに付与
- `for code, discount in discount_mapping.items():` で 辞書のキー（プロモーションコード）と値（割引額）をループ処理
- `f"プロモーションコード: {code}, 割引額: {discount}円"` で f-string を活用して出力


## リスト・タプル・辞書の違い
pythonはデータを格納する際にリスト・タプル・辞書という異なるデータ構造を使うことが可能です。
### リスト（`list`)
リストは可変（変更可能）なデータ構造で順序を持つ要素の集合です。
```python
fruits = ["apple", "banana", "cherry"]
print(fruits[0])  # apple
fruits.append("orange")  # 要素を追加
print(fruits)  # ['apple', 'banana', 'cherry', 'orange']
```
- 要素を追加・削除できる（`append()`, `remove()`など)
- インデックスを使ってアクセス可能

### タプル（`tuple`)
タプルは不変（変更不可）なデータ構造で順序を持つ要素の集合です。
```python
NO_PROMO_CODE = (1, "NOTHING", 0)
print(NO_PROMO_CODE[1])  # NOTHING
# NO_PROMO_CODE.append("2025/03/06")  # エラー（タプルは変更不可）
```
- リストと異なり、要素を変更・追加・削除できない
- インデックスを使ってアクセス可能
- イミュータブル（変更不可）なので、高速でメモリ効率が良い
- データの可変を防ぐ必要がある場合に適している

### 辞書（`dict`)
辞書はキーと値のペアでデータを保持するデータ構造
```python
person = {"name": "Alice", "age": 25}
print(person["name"])  # Alice
person["city"] = "Tokyo"  # 新しいキーと値を追加
print(person)  # {'name': 'Alice', 'age': 25, 'city': 'Tokyo'}
```
- キーと値のペアで管理（`key: value`)
- キーを指定して値にアクセス可能
- 高速な検索が可能

### まとめ
| データ構造 | 変更可否 | アクセス方法 |
|:-:|:-:|:-:|
| リスト(`list`)  |  可能 |  インデックス（`fruits[0]`) |
|  タプル(`tuple`) | 不可  | インデックス（`NO_PROMO_CODE[0]`)  |
| 辞書(`dict`)  |  可能 |  キー(`person["name"]`) |
- データを変更する可能性があるならリスト
- 変更を防ぎたい場合はタプル
- キーと値のペアで管理したいなら辞書

## `render` と `redirect`の違い
Djangoでは、ページ遷移を行う際に `render()` と `redirect()` のどちらを使うかが重要です。

### `render()` とは
`render()` は、テンプレートを指定して HTML をレンダリングし、ユーザーにページを表示する ための関数です。
```python
from django.shortcuts import render

def cart_page(request):
    cart = Cart.get_or_create_cart(request)
    return render(request, "cart/cart_page.html", {"cart": cart})
```
- ページ遷移なしで HTML を描画する（クライアント側でリロードなしに表示可能）
- コンテキスト（データ）を渡して、テンプレート内で使用可能
- フォームのバリデーションエラー時に使うことが多い

### `redirect()`とは
`redirect()` は、別の URL にリダイレクトする ための関数です。POST リクエスト後のリダイレクトなどに利用されます。
```python
from django.shortcuts import redirect

def add_to_cart(request, product_id):
    cart = Cart.get_or_create_cart(request)
    product = get_object_or_404(Product, id=product_id)
    cart.add_product(product, 1)
    messages.success(request, f'{product.name}をカートに追加しました')
    return redirect("cart:cart_page")
```
- ページ遷移が発生する（新しいリクエストが発生）
- 成功メッセージを表示した後のリダイレクトによく使われる
- POST-REDIRECT-GET パターン（PRG パターン）の実装に適している

今回の実装では `redirect()`を多く使用しました。
エラーメッセージなど一時的に保存したい値が発生した際は
セッションを使いました。

### 実際の例
```python
class OrderView(View):
    def post(self, request):
        billing_address_form = BillingAddressForm(request.POST)
        payment_form = PaymentForm(request.POST)
        cart = Cart.get_or_create_cart(self.request)
        cart_products = cart.cart_products.select_related("product")
        # プロモーションコードを取得 なければデフォルト値を格納
        no_promo_code = (1, "NOTHING", 0)
        promotion_code = self.request.session.get("promotion_code", no_promo_code)

        if not cart_products:
            messages.error(request, "カート内に商品がありません")
            return render(request, "cart/cart_page.html", {
                "billing_address_form": billing_address_form,
                "payment_form": payment_form,
            })
        
        error_messages = []
        for field, errors in billing_address_form.errors.items():
            for error in errors:
                error_messages.append(f"{billing_address_form[field].label}: {error}")
        
        for field, errors in payment_form.errors.items():
            for error in errors:
                error_messages.append(f"{payment_form[field].label}: {error}")

        request.session["billing_address_form_data"] = request.POST.dict()
        request.session["payment_form_data"] = request.POST.dict()
        
        # どちらかがエラーの場合、エラーメッセージと共に元の画面へリダイレクト
        if error_messages:
            for message in error_messages:
                messages.error(request, message)

            return redirect(reverse("cart:cart_page"))
```
- バリデーションエラーをセッションに保存
- カートページへリダイレクト（バリデーションエラーはテンプレート側で出力可能）


# 最後に
今回はECサイト作成で学んだこと③ということで
pythonの文法について記載しました。

次回は汎用ビューについてを書きます！