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

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

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

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

# Djangoについて
- Validation Error出力までの流れ
- Formクラスでのフィールドの再定義
- messageフレームワークの使用
- 各カラムの制約
- clsmethodとpropatyの違い

## Validation Error出力までの流れ
Django では、フォームのバリデーションエラーを処理する際に add_error() を活用することで、エラーメッセージを form.errors に蓄積し、ユーザーに適切にフィードバックできます。

:::note warn
本来なら以下の3つを実行してバリデーションエラーをチェックしていきます
- `Field.clean()`
  - `CharField`, `IntegerField`などのフィールドごとのバリデーション
- `clean_<field>()`
  - 各フィールのカスタムバリデーション
- `clean()`
  - デフォルトでは実行されない
  - フォーム全体のバリデーションをしたい場合にオーバーライドして実行

しかし、今回のECサイトでは`clean()`を定義しませんでした。
以下2点が理由です。ご了承ください。
- `clean_<field>()`で対応可能だったから
- 複数フィールドのバリデーションが必要ではなかったから
  - password, confirm_passwordなど
:::

### `add_error()`を使うメリット
Django のフォームバリデーションでは、add_error() を使うことで、複数のエラーを一度にチェックし、フォーム全体のバリデーションを適切に処理できます。

**raise ValidationError** を使うと…
- エラーが発生した時点で処理が 中断 される
- 1つのエラーが検出された時点で、後続のバリデーションが実行されない

**add_error()** を使うと…
- 複数のエラーを一度に処理できる
- フォーム全体のバリデーションをまとめて実行できる
- エラーメッセージを form.errors に蓄積できる

今回は購入処理の部分の請求先住所と支払い情報の部分にFormが必要になりましたので、そこを実例として書いていきます。

### Validation Error出力までの流れ
- POSTリクエストの送信
```views.py
class OrderView(View):
    def post(self, request):
        billing_address_form = BillingAddressForm(request.POST)
        payment_form = PaymentForm(request.POST)
```
### `is_valid()`の実行（バリデーションチェック）
Django では、フォームの form.errors にアクセスすると is_valid()が**自動的に実行**される。
```views.py
for field, errors in billing_address_form.errors.items():
    for error in errors:
        error_messages.append(f"{billing_address_form[field].label}: {error}")
```
- `form.errors` にアクセスすると、Django は内部的に `is_valid()` を実行する
- `is_valid()` は `clean_<field>()` → `clean()` の順でバリデーションを実行

```python
# clean_<field>() フィールド単体のバリデーション
def clean_last_name(self):
    last_name = self.cleaned_data.get("last_name", "")
    if not re.fullmatch(r'[a-zA-Zぁ-んァ-ン一-鿿々-〇]+', last_name):
        self.add_error("last_name", "姓はアルファベット、ひらがな、カタカナ、漢字で入力してください")
    return last_name
```

### バリデーションエラーの収集
エラーが`form.errors`に格納された後、メッセージを`messages.error()`に追加
```python
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 message in error_messages:
    messages.error(request, message)
```
### エラーをセッションに保存
エラーがある場合、ユーザーの入力内容を保持しながらリダイレクト
```python
request.session["billing_address_form_data"] = request.POST.dict()
request.session["payment_form_data"] = request.POST.dict()

return redirect(reverse("cart:cart_page"))
```
### `.html`でエラーメッセージを出力
エラーメッセージはDjangoの`message`を使ってテンプレートに出力
(messageフレームワークの詳しい説明はスクロールしてご確認ください)
```html
{% if messages %}
    <div class="alert alert-danger">
        <ul>
        {% for message in messages %}
            <li>{{ message }}</li>
        {% endfor %}
        </ul>
    </div>
{% endif %}
```

## Formクラスでのフィールドの再定義
### Formクラスでフィールを再定義する理由
Djangoの`ModelForm`を使用する場合、デフォルトでは`Meta.fields`に定義されたフィールドが`Model`の定義に基づいて自動で作成されます。しかし、フォームの仕様に応じて以下のような理由で**フィールドを再定義**することがあります。
- モデルのフィールドと異なるバリデーションを適用したい
- ラベルやヘルプテキストを変更したい
- デフォルトとな異なるウィジェット（フォームの入力欄）を使用したい
- ユーザー入力の前処理を行いたい（ハイフンを削除するなど）

:::note alert
ポートフォリオということで今回だけ**特別**にクレカ情報をDBに保存しています。本来は絶対にやってはいけないことです。その点を理解した上でご覧ください。
:::

実際のコードを見ていきましょう

```order/models.py
class Payment(models.Model):
    card_holder = models.CharField("カード名義")
    card_number_hash = models.CharField("カード番号（暗号化）", max_length=255)
    card_last4 = models.CharField("カード下4桁", max_length=19)
    expiration_date = models.CharField("有効期限", max_length=5)
```

以下の `PaymentForm` では `card_number` と `expiration_date` を フォームレベルで再定義 しています。

```order/forms.py
class PaymentForm(forms.ModelForm):
    card_number = forms.CharField(
        label="カード番号",
        max_length=19,
        required=True,
        help_text="クレジットカード番号（ハイフンなしまたは 0000-0000-0000-0000）"
    )

    expiration_date = forms.CharField(
        label="有効期限",
        max_length=5,
        required=True,
        help_text="MM/YY の形式で入力してください"
    )
    
    class Meta:
        model = Payment
        fields = ("card_holder",)
```
- `card_number` は モデルには存在しないが、フォームで追加されている
- `expiration_date` は モデルフィールドと同じだが、ヘルプテキストやラベルを変更している

### フィールドを再定義するメリット
- バリデーションをフォーム側で制御可能

再定義した `card_number` は、フォームレベルでバリデーションが追加できます。
- `clean_card_number()`を実装することで、フォームのバリデーションを柔軟に変更可能
- ユーザーが「`-`」を入力しても削除するといった前処理ができる
```python
def clean_card_number(self):
    card_number = self.cleaned_data.get("card_number", "").replace("-", "").strip()
    if not card_number.isdigit():
        self.add_error("card_number", "カード番号は数字を入力してください")
    if not (14 <= len(card_number) <= 16):
        self.add_error("card_number", "カード番号は14~16桁で入力してください")
    return card_number
```
- モデルに保存しない一時的なフィールドを追加できる

モデルに存在しない card_number をフォームに追加し、保存時に 暗号化して card_number_hash に保存 する処理を実装できます。
今回のモデルには card_number を保存せず、ハッシュ化して保存することにしました。
```python
def save(self, commit=True):
    instance = super().save(commit=False)
    card_number = self.cleaned_data["card_number"]
    instance.card_number_hash = Payment.hashed_card_number(card_number)  # ハッシュ化
    instance.card_last4 = Payment.card_number_slice(card_number)  # 下4桁を保存
    if commit:
        instance.save()
    return instance
```

## messagesフレームワークの使用
`messages`フレームワークとはユーザーへの通知メッセージを管理するための仕組みです。
今回の実装では以下の2点で使用しました。
### フォーム送信後の成功メッセージ出力
```python
messages.success(request, "ご購入ありがとうございます")
```
![スクリーンショット 2025-03-06 1.20.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3918502/e175b7dd-4a93-484c-a20d-1a921882e704.png)

### 送信失敗時のエラーメッセージ出力
```python
messages.error(request, "カート内に商品がありません")
```
![スクリーンショット 2025-03-06 1.19.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3918502/6b6a3edc-961c-4874-ae94-a0cbd72209d1.png)

共通で`html`を出力します。
- `messages` が存在する場合、すべてのメッセージを表示
- `message.tags` を使うことで、メッセージの種類 (`success`, `error` など) に応じた CSS を適用できる
```html
{% if messages %}
    <div class="alert-messages">
        {% for message in messages %}
            <div class="alert alert-{{ message.tags }}">
                {{ message }}
            </div>
        {% endfor %}
    </div>
{% endif %}
```

他にも種類がありますので詳しくは以下のリンクよりご覧ください。

https://docs.djangoproject.com/ja/5.1/ref/contrib/messages/

## 各カラムの制約
DB設計をしてmodelを書き始める際に
`unique`, `not null`などはどのように書けばいいのかということで調べました。
### max_length (最大文字数）
`max_length`を設定することで、DBレベルでの最大文字数を制限できます。
```python
name = models.CharField("商品名", max_length=20)
```
- `max_length=20`により、20文字以内の文字列のみ保存可能
- フォーム（forms.CharField)にも自動的に適用される
### unique制約
`unique=True`を設定すると、そのカラムの値が重複しないように制約されます。
```python
username = models.CharField("ユーザー名", unique=True)
```
- 同じ値を持つレコードを作成しようとするとエラーが発生
- DBレベルで`UNIQUE`制約が設定
- CharField, IntegerFieldなど、他のフィールドにも適用可能
### verbose_name (管理画面の表示名）
`verbose_name`を設定すると、Djangoの管理画面(admin)やフォームでフィールド名の表示をカスタマイズできます。
```python
cart = models.ForeignKey("cart.Cart",
                        verbose_name=("カートID"),
                        on_delete=models.CASCADE,
                        related_name="cart_products")
```
### default（デフォルト値の設定）
`default`を設定すると、新しいレコードを作成する際に、値を入力しなくてもデフォルト値が自動で設定されます。
```python
quantity = models.IntegerField(verbose_name='個数', default=1)
```
- フィールドがからの場合でも `default` の値が自動で入る
- データベースレベルのデフォルト値として設定される
### blankとnullの違い
`blank=True`：フォームで必須入力にしない
→バリデーションの制御

`null=True`：データベースでNULLを許可
→DBレベルの制御

今回は実装しませんでしたが例を紹介
```python
class UserProfile(models.Model):
    nickname = models.CharField(max_length=50, blank=True, null=True)
```
### validators（値のバリデーション）
`validators`を使用すると、保存する値の最小値・最大値を制限可能
```python
from django.core.validators import MinValueValidator
...
price = models.PositiveIntegerField("商品価格", validators=[MinValueValidator(1)])
quantity = models.PositiveIntegerField("個数", validators=[MinValueValidator(1)])
subtotal = models.PositiveIntegerField("小計", validators=[MinValueValidator(1)])
```

## クラスメソッドとプロパティの違い
### クラスメソッド(@classmethod)とは？
`@classmethod`とは、クラスに対して直接呼び出せるメソッドのことです。クラス全体に関わる処理を定義する際に使用します。

#### 特徴
- 第一引数が`cls`(クラス自身)
- インスタンス化せず呼び出せる
- DBの検索やオブジェクトの作成に役立つ

#### 実装例 (Cart.get_or_create_cart())
次に実装例を見ていきましょう
Cart.get_or_create_cart() は、セッション ID を利用してカートオブジェクトを取得または作成するクラスメソッドです。
```cart/models.py
class Cart(models.Model):
    session_id = models.CharField(max_length=32, unique=True, default="temp_session_id")

    @classmethod
    def get_or_create_cart(cls, request):
        if not request.session.session_key:
            request.session.create()
        session_id = request.session.session_key

        cart, _ = cls.objects.get_or_create(session_id=session_id)
        return cart
```
1. `request.session.session_key`を確認して、セッションがなければ作成
1. `session_id`を使って`Cart`モデルのオブジェクトを検索
1. 該当するカートがなければ、新しく作成して返す
1. インスタンス化せずに `Cart.get_or_create_cart(request)として呼び出せる

```cart/views.py
def get_context_data(self, **kwargs):
        # カートオブジェクトを取得
        cart = Cart.get_or_create_cart(self.request)
```

### プロパティ（@property)とは
`@property`とは、インスタンスメソッドを「属性」のように使えるデコレーターのことです。計算結果を変えるプロパティを作成するのに適しています。

#### 特徴
- インスタンスごとに異なる値を計算できる
- メソッドのように呼び出す必要がなく、プロパティのようにアクセスできる
- DBの集計処理に適している

#### 実装例 (Cart.total_price & Cart.total_quantity)
```cart/models.py
class Cart(models.Model):
    session_id = models.CharField(max_length=32, unique=True, default="temp_session_id")
......
    @property
    def total_price(self):
        return sum(cart_product.sub_total_price() for cart_product in self.cart_products.all())
    
    @property
    def total_quantity(self):
        return sum(cart_product.quantity for cart_product in self.cart_products.all())
```
1. `total_price`は、カート内のすべての商品価格を合計して返す
1. `total_quantity`は、カート内のすべての商品数を合計して返す
1. メソッドのように`cart.total_price()とは書かず、`cart.total_price`としてアクセス可能
1. DBのフィールドを直接持たず、計算処理を実装できる

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

次回はPythonの文法についてを書きます！