[Two Scoops of Django] 12장. 폼 패턴들

guava·2021년 11월 6일
0

Two Scoops of Django

목록 보기
12/12

Two Scoops of Django 3.x를 보고 정리한 글입니다.

  • 장고 폼은 유효성 검사를 위해 쓰인다.
  • 강력한 동시에 유용하며 확장 가능한 구조이다. 또한 장고 어드민과 클래스 기반 뷰에서 광범위하게 사용된다.
  • 이번 장에서는 장고 개발자라면 반드시 알아야 할 다섯가지 폼 패턴을 다룬다.

12.1 패턴 1: 간단한 모델 폼과 기본 Validator

ModelForm을 사용하고 기본 Validator를 수정 없이 이용한다.

Example 12.1: flavors/views.py

  • Flavor 모델을 FlavorCreateViewFlavorUpdateView에서 이용하도록 한다.
  • 두 뷰에서 Flavor 모델에 기반을 둔 ModelForm을 자동 생성한다.
  • 생성된 ModelForm이 Flavor 모델의 기본 필드 유효성 검사기(Validator)를 이용하게 된다.
from django.contrib.auth.mixins import LoginRequiredMixin 
from django.views.generic import CreateView, UpdateView

from .models import Flavor

class FlavorCreateView(LoginRequiredMixin, CreateView): 
    model = Flavor
    fields = ['title', 'slug', 'scoops_remaining']
    
class FlavorUpdateView(LoginRequiredMixin, UpdateView): 
    model = Flavor
    fields = ['title', 'slug', 'scoops_remaining']

이와 같이 뷰에 모델을 설정하는 것만으로 ModelForm을 사용하도록 할 수 있다. 이를 통해 기본 Validator도 활용하게 된다.

12.2 패턴2: 모델폼에서 커스텀 폼 필드 Validator 이용하기

간단한 커스텀 필드 Validator를 활용한다.

Example 12.2: validators.py

  • 우선 Flavor, Milkshake 모델의 title 필드에 대해 유효성을 검사하기 위해 validators.py 모듈을 제작한다.
  • validator는 장고 프로젝트의 데이터 충돌을 방지하는 중요한 기능이다. 따라서 테스트에 주의를 기울ㅇ여야 한다.
# core/validators.py
from django.core.exceptions import ValidationError

def validate_tasty(value):
    """단어가'Tasty'로 시작하지 않으면 ValidationError를 일으킨다."""
    if not value.startswith('Tasty'): 
        msg = 'Must start with Tasty'
        raise ValidationError(msg)

Example 12.3: Adding Custom Validator to a Model

Example 12.2에서 정의한 validator를 프로젝트 전반에서 이용할 수 있도록 한다.
이를 위해 추상화 모델을 정의하고 필드에 추가한다.

# core/models.py
from django.db import models
from .validators import validate_tasty


class TastyTitleAbstractModel(models.Model):
    title = models.CharField(max_length=255, validators=[validate_tasty])

    class Meta:
        abstract = True

Example 12.4: Inheriting Validators

  • Example 12.3에서 정의한 TastyTitleAbstractModel을 부모 클래스로 지정한다.
  • TastyTitleAbstractModel를 상속받은 모델들은 title필드가 validate_tasty의 유효성 검사를 통과하지 못하면 에러가 발생할 것이다.
# flavors/models.py
from django.db import models 
from django.urls import reverse

from core.models import TastyTitleAbstractModel

class Flavor(TastyTitleAbstractModel):
    slug = models.SlugField()
    scoops_remaining = models.IntegerField(default=0)
    
    def get_absolute_url(self):
        return reverse('flavors:detail', kwargs={'slug': self.slug})

Example 12.5: Adding Custom Validators to a Model Form

validate_tasty()를 폼에만 적용하고 싶거나 다른 필드에 적용하고 싶다면 다음과 같이 커스텀 폼을 작성하자.

# flavors/forms.py
from django import forms 

from .models import Flavor
from core.validators import validate_tasty

class FlavorForm(forms.ModelForm):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields['title'].validators.append(validate_tasty)
        self.fields['slug'].validators.append(validate_tasty)
        
    class Meta:
        model = Flavor

Example 12.6: Overriding the CBV form_class Attribute

  • 장고는 뷰의 모델 속성을 기반으로 모델 폼을 자동 생성 하지만 FlavorForm을 오버라이딩 하였다.
# flavors/views.py
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import CreateView, DetailView, UpdateView

from .models import Flavor
from .forms import FlavorForm


class FlavorActionMixin:
    model = Flavor
    fields = ['title', 'slug', 'scoops_remaining']

    @property
    def success_msg(self):
        return NotImplemented

    def form_valid(self, form):
        messages.info(self.request, self.success_msg)
        return super().form_valid(form)


class FlavorCreateView(LoginRequiredMixin, FlavorActionMixin, CreateView):
    success_msg = 'created'
    # FlavorForm 클래스를 명시적으로 추가
    form_class = FlavorForm


class FlavorUpdateView(LoginRequiredMixin, FlavorActionMixin, UpdateView):
    success_msg = 'updated'
    # FlavorForm 클래스를 명시적으로 추가
    form_class = FlavorForm


class FlavorDetailView(DetailView):
    model = Flavor

12.3 패턴3: 유효성 검사(Validation)의 클린 상태(Clean Stage) 오버라이딩

  • 기본 또는 커스텀 필드 Validator가 실행된 후 장고는 clean(), clean_<field name>() 메서드를 이용하여 입력된 데이터의 유효성 검사 절차를 진행한다.

왜 또 한번의 유효성 검사를 거치는가?

  1. clean()메서드는 두 개 또는 그 이상의 필드들에 대해 서로 간의 유효성을 검사하는 공간이 된다.
  2. 클린 유효성 검사 단계(clean validation stage)는 영속 데이터(persistant data)에 대한 유효성을 검사하기 위한 장소다. 이미 유효성 검사를 마친 데이터에 대해 불필요한 데이터베이스 연동을 줄일 수 있다.
    clean_<field name>() 에서 통과한 데이터를 clean() 단계에서 활용할 수 있다.
  3. 이미 유효성 검사가 끝난 데이터베이스의 데이터가 포함된 유효성 검사가 가능하다.
    clean_<field name>() 또는 clean()함수 내에서 ORM 문법을 통해 데이터베이스에 접근

Example 12.7: Custom clean_slug() Method

  • clean_slug()에서 이미 유효성 검사가 끝난 DB의 데이터가 포함된 유효성 검사를 진행하고 있다.
  • clean()에서 다중 필드에 대한 유효성 검사를 진행하고 있다.
# flavors/forms.py
from django import forms

from flavors.models import Flavor


class IceCreamOrderForm(forms.Form):
    """일반적으로 forms.ModelForm을 이요하면 된다. 하지만
    모든 종류의 폼에서 이와 같은 방식을 적용할 수 있음을 보이기 위해
    form.Form을 이용했다.
    """
    slug = forms.ChoiceField(label='Flavor')
    toppings = forms.CharField()

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        """Flavor 필드에서 정의하지 않고 __init__함수에서 선택 항목을 동적으로 설정한다. 
        필드 정의에서 설정하면 서버를 다시 시작하지 않으면 상태 업데이트가 양식에 반영되지 않습니다.
        """
        self.fields['slug'].choices = [
            (x.slug, x.title) for x in Flavor.objects.all()
        ]
        # NOTE: filter를 사용해서 Flavor에 scoops이 남았는지 확인할 수 있으나
        # filter()가 아닌 clean_slug를 이용하는 방법을 예로 들었다.

    def clean_slug(self):
        slug = self.cleaned_data['slug']

        if Flavor.objects.get(slug=slug).scoops_remaining <= 0:
            msg = 'Sorry, we are out of that flavor.'
            raise forms.ValidationError(msg)
        return slug
    
    # Example 12.8: Custom clean() Form Method
    def clean(self):
        cleaned_data = super().clean()
        slug = cleaned_data.get('slug', '')
        toppings = cleaned_data.get('toppings', '')
        # "too much chocolate" 유효성 검사의 예
        in_slug = 'chocolate' in slug.lower()
        in_toppings = 'chocolate' in toppings.lower()
        if in_slug and in_toppings:
            msg = 'Your order has too much chocolate.'
            raise forms.ValidationError(msg)
        return cleaned_data

12.4 패턴 4: 폼 필드 해킹하기 (두 개의 CBV, 두 개의 폼, 한 개의 모델

  • 상점 등록을 폼을 통해서 입력받는다고 가정해보자.
  • 우선 상점 타이틀, 주소를 입력받고 전화번호, 설명 등의 데이터는 추후에 입력받는 경우가 있을 것이다.

Example 12.9: IceCreamStore Model

모델 정의는 다음과 같다. title, block_address는 입력 받아야 하고 phone, description은 입력 받지 않아도 된다. (blank=True 속성)

# stores/models.py
from django.db import models
from django.urls import reverse


class IceCreamStore(models.Model):
    title = models.CharField(max_length=100)
    block_address = models.TextField()
    phone = models.CharField(max_length=20, blank=True)
    description = models.TextField(blank=True)

    def get_absolute_url(self):
        return reverse('stores:store_detail', kwargs={'pk': self.pk})

Example 12.10: Repeated Heavily Duplicated Code (나쁜 예제)

  • 모델 필드를 폼으로 복사, 붙여넣기를 함으로써 DRY원칙에 위배된다.
  • 만약 모델에서 help_text 추가하려고 할 때, 이 폼에서도 추가해야 템플릿에서도 반영된다.
  • 폼에서 필드의 속성을 수정하고 싶다면 Example 12.11를 보자
# stores/forms.py
from django import forms

from .models import IceCreamStore


class IceCreamStoreUpdateForm(forms.ModelForm):
    # Don't do this! Duplication of the model field!
    phone = forms.CharField(required=True)
    # Don't do this! Duplication of the model field!
    description = forms.TextField(required=True)

    class Meta:
        model = IceCreamStore

Example 12.11: Overriding Init to Modify Existing Field Attributes

  • 실체화된 폼 객체는 유사 딕셔너리 객체인 fields속성 안에 필드를 저장한다.
  • 따라서 폼에서 필드를 정의하는것이 아니라 ModelForm의 __init__()메서드에서 지정된 필드의 기존 속성을 간단히 수정할 수 있다.
# stores/forms.py
# self.fields라는 유사 딕셔너리 객체에서 description, phone을 호출
from django import forms

from .models import IceCreamStore

class IceCreamStoreUpdateForm(forms.ModelForm):

    class Meta:
        model = IceCreamStore

    def __init__(self, *args, **kwargs):
        # 
        super().__init__(*args, **kwargs)
        self.fields['phone'].required = True
        self.fields['description'].required = True

Example 12.12: Using Inheritance to Clean Up Forms

  • 상속을 통해 더 많은 코드를 줄일 수 있다.
  • Meta.feilds는 이용하되 Meta.exclude는 이용하지 말라 (사용되는 필드를 명확하게 작성하기 위함이다)
# stores/forms.py
from django import forms

from .models import IceCreamStore

class IceCreamStoreCreateForm(forms.ModelForm):
    class Meta:
        model = IceCreamStore
        fields = ['title', 'block_address', ]

class IceCreamStoreUpdateForm(IceCreamStoreCreateForm):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields['phone'].required = True
        self.fields['description'].required = True

    class Meta(IceCreamStoreCreateForm.Meta):
        # 모든 필드를 보여준다.
        fields = ['title', 'block_address', 'phone', 'description', ]

Example 12.13: Revised Create and Update Views

폼 클래스를 IceCreamStore의 create와 update뷰에서 이용한다.

# stores/views

from django.views.generic import CreateView, UpdateView

from .forms import IceCreamStoreCreateForm, IceCreamStoreUpdateForm
from .models import IceCreamStore

class IceCreamCreateView(CreateView): 
    model = IceCreamStore
    form_class = IceCreamStoreCreateForm
    
class IceCreamUpdateView(UpdateView): 
    model = IceCreamStore
    form_class = IceCreamStoreUpdateForm

12.5 패턴5: 재사용 가능한 검색 미스인 뷰

  • 각기 다른 두 개의 모델에 연동되는 두 개의 뷰에 하나의 폼을 재사용 한다.
  • 두 개의 모델에 title이라는 필드가 있다. (프로젝트 차원의 이름 관례를 따르면 왜 좋은지 알 수 있다.)
  • 하나의 믹스인을 상속받아 뷰에서 공통된 기능(검색)을 구현하겠다.

Example 12.14: TitleSearchMixin a simple search class

검색을 위한 믹스인을 정의한다.

# core/views.py
class TitleSearchMixin:
    def get_queryset(self):
        # 부모의 get_queryset으로 부터 queryset을 가져온다.
        queryset = super().get_queryset()
        # q라는 GET 파라미터 가져오기
        q = self.request.GET.get('q')
        if q:
            # return a filtered queryset
            return queryset.filter(title__icontains=q)
        # No q is specified so we return queryset
        return queryset

Example 12.15: Adding TitleSearchMixin to FlavorListView

Flavor를 위한 view를 정의한다. Example 12.14에서 정의한 믹스인을 상속받아 제목에 대한 검색 기능을 구현한다.

# add to flavors/views.py
from django.views.generic import ListView

from .models import Flavor
from core.views import TitleSearchMixin

class FlavorListView(TitleSearchMixin, ListView):
       model = Flavor

Example 12.16: Adding TitleSearchMixin to IceCreamStoreListView

Store를 위한 view를 정의한다. Example 12.14에서 정의한 믹스인을 상속받아 제목에 대한 검색 기능을 구현한다.

# add to stores/views.py
from django.views.generic import ListView

from .models import Store
from core.views import TitleSearchMixin

class IceCreamStoreListView(TitleSearchMixin, ListView):
    model = Store

Example 12.17: Snippet from stores/store_list.html

store를 위한 검색 폼

{# form to go into stores/store_list.html template #}
<form action="" method="GET">
  <input type="text" name="q" />
  <button type="submit">search</button>
</form>

Example 12.18: Snippet from flavors/flavor_list.html

flavor를 위한 검색 폼

{# form to go into flavors/flavor_list.html template #}
<form action="" method="GET">
  <input type="text" name="q" />
  <button type="submit">search</button>
</form>

0개의 댓글