add_errorの使い方

今回はDjangoでのバリデーション処理に使う
`add_error`の使い方についてアウトプット記事を書いていきます。

# 目的
ユーザー編集の部分で `add_error` を使って
複数フィールドのバリデーションエラーを発生させる

以下が公式ドキュメントのエラー処理の部分です。

https://docs.djangoproject.com/en/5.1/ref/forms/validation/#raising-validation-error

# 前提

:::note warn
必要最低限のファイルの内容しか書きません
ご了承ください。
:::
以下に前提条件を記します。
- フォルダ構成
- Model
- View
## フォルダ構成
```
manage.py
config/
apps/
    accounts/
        models.py
        forms.py
    profiles/
        views.py
```

## Model
```accounts/models.py
import datetime

from django.db import models
from django.contrib.auth.models import AbstractUser
from cloudinary.models import CloudinaryField

# デフォルトを定義
DEFAULT_VALUES = {
    'icon_image': 'https://res.cloudinary.com/dyfjcfcfm/image/upload/v1742972473/default_icon_uienbj.jpg',
    'header_image': 'https://res.cloudinary.com/dyfjcfcfm/image/upload/v1742977104/header_image_q4wgax.jpg',
    'self_introduction': 'NO_SELF_INTRODUCTION',
    'place': 'NO_PLACE',
    'website': 'NO_WEBSITE',
    'birthdate': datetime.date(1000, 1, 1),
}

class CustomUser(AbstractUser):
    # 不要なフィールドは None にする
    first_name = None
    last_name = None
    date_joined = None

    username = models.CharField(
        verbose_name='ユーザー名',
        max_length=50,
        unique=True,
        validators=[AbstractUser.username_validator],
        help_text='英数字とアンダースコアのみ使用できます。50文字以内で入力して下さい。',
        error_messages={
            'unique': 'このユーザー名はすでに使用されています。',
            'invalid': '使用できない文字が含まれています',
        }
    )
    email = models.EmailField(
        verbose_name='メールアドレス',
        max_length=254,
        unique=True,
        help_text='.com, .co.jp, .jp で終わる254文字以内のメールアドレスを使用できます。',
        error_messages={
            'unique': 'このメールアドレスはすでに使用されています。',
            'invalid': '有効なメールアドレスを入力して下さい。',
            'required': 'メールアドレスは必須です。',
        }
    )
    icon_image = CloudinaryField('icon_image', default=DEFAULT_VALUES['icon_image'])
    header_image = CloudinaryField('header_image', default=DEFAULT_VALUES['header_image'])
    self_introduction = models.CharField(
        max_length=160,
        blank=True,
        default=DEFAULT_VALUES['self_introduction'],
        help_text='最大文字数は160文字です。'
    )
    place = models.CharField(
        max_length=30,
        blank=True,
        default=DEFAULT_VALUES['place'],
        help_text='30文字以内で入力して下さい'
    )
    website = models.CharField(
        max_length=100,
        blank=True,
        default=DEFAULT_VALUES['website']
    )
    phone_number = models.CharField(
        verbose_name="電話番号",
        max_length=10,
        default="NO_PHONE",
        blank=True,
        help_text='ハイフンなしで入力して下さい'
    )
    birthdate = models.DateField(
        verbose_name="生年月日",
        default=DEFAULT_VALUES['birthdate'],
        blank=True,
        help_text='今日より前の日付を選択して下さい'
    )

    class Meta:
        db_table = "users"

    def __str__(self):
        return self.username
```

## View
```profiles/views.py
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic.edit import UpdateView
from apps.accounts.models import CustomUser
from apps.accounts.forms import CustomUpdateForm

class ProfileUpdateView(LoginRequiredMixin, UpdateView):
    model = CustomUser
    form_class = CustomUpdateForm
    template_name = "profile.html"
    success_url = reverse_lazy('profile:posts')

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['view_mode'] = 'edit'
        return context
    
    def form_invalid(self, form):
        """ バリデーション失敗 """
        return super().form_invalid(form)
    
    def form_valid(self, form):
        """ バリデーション成功 """
        return super().form_valid(form)
```

# 処理の流れ
プロフィールページ
↓
プロフィール編集ボタンクリック
↓
プロフィール情報編集フォーム出力
↓
編集して更新ボタンをクリック
↓
エラーがある場合バリデーションエラーを出力
↓
バリデーションが成功の場合はプロフィールページに戻る

# 今回は何故ValidationErrorを使わないのか
`ValidationError`を使用しない理由は以下です。
- 複数のフィールドで相互検証やエラーを蓄積したい
- `ValidationError`は単一フィールドの検証で処理を中断してしまう

:::note
一例ですが、`add_error`を使うと以下の3つの状態で更新ボタンをクリックした際に全てに対してエラーを出力可能です。
- ユーザー名で使用できない記号を使った → エラー
- メールアドレスで文字数オーバーで入力した → エラー
- パスワードで簡単すぎるパスワードにした → エラー
:::

例えば以下のように生年月日をDBに保存する際
今日の日付より前の日付だった場合は
「生年月日」という単一フィールドの検証で処理を中断する必要があるため
`ValidationError`を使用することが適しています。
```python
import datetime

from django.core.exceptions import ValidationError

class User(models.Model):
    birth_date = models.DateField()
    
    def clean(self):
        if self.birth_date and self.birth_date > datetime.date.today():
            raise ValidationError({'birth_date': '生年月日は今日より前の日付である必要があります'})
```
# Form
```accounts/forms.py
from apps.accounts.models import CustomUser
from django import forms
from apps.accounts.models import DEFAULT_VALUES
from cloudinary.forms import CloudinaryFileField

class CustomUpdateForm(forms.ModelForm):
    class Meta:
        model = CustomUser
        fields = ['username', 'email', 'header_image', 'icon_image',  'self_introduction', 'place', 'website', 'birthdate']
        labels = {
            'username': 'ユーザー名（必須）',
            'email': 'メールアドレス（必須）',
            'self_introduction': '自己紹介',
            'place': '場所',
            'website': 'Webサイト',
            'birthdate': '生年月日',
        }
        widgets = {
            'self_introduction': forms.Textarea(attrs={
                'name': 'self_introduction', 
                'rows': 4,
                'class': 'form-control',
                'id': 'id_self_introduction'
            }),
            'birthdate': forms.NumberInput(attrs={
                'type': 'date',
                'name': 'birthdate', 
                'class': 'form-control',
                'id': 'birthdate'
            }),
        }
    
    def clean(self):
        cleaned_data = super().clean()
        old_password = cleaned_data.get('old_password')
        new_password = cleaned_data.get('new_password')
        confirm_password = cleaned_data.get('confirm_password')

        # 古いパスワードのみが入力された場合
        if old_password and not new_password and not confirm_password:
            self.add_error('new_password', '新しいパスワードを入力して下さい')

        # 新しいパスワードが入力されたが古いパスワードがない場合
        if (new_password or confirm_password) and not old_password:
            self.add_error('old_password', '古いパスワードを入力して下さい')

        # 新しいパスワードと確認用パスワードが一致しない場合
        if new_password and confirm_password and new_password != confirm_password:
            self.add_error('confirm_password', '新しいパスワードと確認用パスワードが一致しません')

        # 古いパスワードの検証
        if old_password and self.instance and self.instance.pk:
            if not self.instance.check_password(old_password):
                self.add_error('old_password', '古いパスワードが正しくありません')

        return cleaned_data
```

# Template
今回は`add_error`でフィールドを指定しているので
`field.errors`リストにエラーメッセージが追加されます。
そのため、テンプレート側ではエラーがあったらループで出力されるようにします。
```html
{% if field.errors %}
    <div class="alert alert-danger">
        <ul>
            {% for error in field.errors %}
            <li>{{ error }}</li>
            {% endfor %}
        </ul>
    </div>
{% endif %}
```

## `non_field_error`は？
今回のFormは全てfieldを指定していたため
`field.errors`で完了しています。

ただ、以下の例の場合は`non_field_errors`を使うことになります。
- `add_error`でフィールドを指定していない（None）場合
- `ValidationError`を指定する場合
```python
def clean(self):
    cleaned_data = super().clean()
    start_date = cleaned_data.get('start_date')
    end_date = cleaned_data.get('end_date')
    
    if start_date and end_date and start_date > end_date:
        # フィールド名としてNoneを指定することでnon_field_errorsになる
        self.add_error(None, "終了日は開始日より後である必要があります")
```

```python
class LoginForm(forms.Form):
    username = forms.CharField()
    password = forms.CharField(widget=forms.PasswordInput)
    
    def clean(self):
        cleaned_data = super().clean()
        username = cleaned_data.get('username')
        password = cleaned_data.get('password')
        
        user = authenticate(username=username, password=password)
        if not user:
            # 認証失敗をフォーム全体のエラーとして表示
            raise ValidationError("ユーザー名またはパスワードが正しくありません")
```

# 最後に
このアウトプットを書いた理由として
`add_error`をよく理解せずに実装をしており
フィールドを指定しているのにも関わらず`non_field_errors`を
テンプレートで使用していました。

そのため、デバックでエラー処理ができているが
バリデーションエラーが出力されないといったことになってしまいました。

もしよかったら共有してくださるとありがたいです。