python / Django
作成日:2020/3/6 更新日:2020/3/9

DjangoでGCPを活用した実践的な開発 Part3 [ 3.x対応 ]

前回はDjangoのモデルについて、実践的な実装について説明しました。 今回はView側の処理を無駄なくコーディングする方法について解説します。 汎用性のあるクラスベースのView、デコレーターによるロジックや、認証によるビューへのアクセス権限について、実例を挙げて説明していきます。

ビューの実装についておさらい

Djangoのルーティングについて、以下の要約しているコードを見ながら確認しましょう。基本はsettings.pyで最初に呼び出すルーティングのurls.pyを指定しています。このurls.pyにはモジュール毎のurls.pyをincludeで呼び出すようにしておくと、モジュール毎にネームスペースをつけることで衝突を避けられます。
モジュールのurls.pyにはビューをURLパスと紐付けてあり、それに従ってルーティングが行われます。

# settings.py 80,81

# NOTE: Routing settings
ROOT_URLCONF = 'src.urls'

--------------------------------

#src/urls.py 
# ルートとなるURLパターンとビューの紐付け

from django.contrib import admin
from django.urls import path, include
from django.conf.urls.i18n import i18n_patterns


urlpatterns = i18n_patterns(
    path('', include(('src.app.urls', 'app'),)),
    path('admin/', admin.site.urls),
    prefix_default_language=False
)

--------------------------------

# src/app/urls.py 
# モジュールのURLパターン

from django.contrib.auth.views import LogoutView
from django.urls import path

from . import views

urlpatterns = [
    path('', views.IndexView.as_view(), name='index'),
    path('dashboard', views.DashboardView.as_view(), name='dashboard'),
    path('paid-content', views.PaidContentView.as_view(), name='paid-content'),
    path('free-content', views.FreeContentView.as_view(), name='free-content'),
    path(
        'registration', views.RegistrationView.as_view(),
        name='registration'
    ),
    path('login', views.InheritedLoginView.as_view(), name='login'),
    path(
        'logout', LogoutView.as_view(template_name='logout.html'),
        name='logout'
    ),
]

ビューの実装

# src/app/views.py

import logging

from django.urls import reverse_lazy
from django.utils.decorators import method_decorator
from django.contrib.auth.decorators import login_required

from django.views.generic.base import TemplateView
from django.views.generic import CreateView
from django.contrib.auth.views import LoginView

from . import forms, decorators

logger = logging.getLogger(__name__)


class IndexView(TemplateView):
    template_name = 'index.html'


@method_decorator([login_required], 'dispatch')
class DashboardView(TemplateView):
    template_name = 'dashboard.html'


@method_decorator([login_required, decorators.subscriber_required], 'dispatch')
class PaidContentView(TemplateView):
    template_name = 'paid-content.html'


@method_decorator([login_required], 'dispatch')
class FreeContentView(TemplateView):
    template_name = 'free-content.html'


class RegistrationView(CreateView):
    form_class = forms.RegistrationForm
    success_url = reverse_lazy('app:login')
    template_name = 'registration.html'


class InheritedLoginView(LoginView):
    template_name = 'login.html'

    def get_success_url(self) -> str:
        url = self.get_redirect_url()
        return url or reverse_lazy('app:dashboard')

ビューの処理ではDjangoの既存クラスを継承して、変更が必要な要素、メソッドを少し書き換えて、できるだけ自分で処理を作らないように実装しましょう。
たとえば、認証状態の確認をする処理の書き方を例にすると

# e.g.
from django.contrib.auth.decorators import login_require

@method_decorator([login_required], 'dispatch')
class DashboardView(TemplateView):
    template_name = 'dashboard.html


method_decoratorを使って、復数のデコレーターをViewクラスの全てのメソッドにdispatchを経由するタイミングで認証済みであるかチェックできます。最初から復数のデコレーターをリストで渡せるようにしているのは、開発中に特定のユーザーのみ、例えば課金ユーザー、トライアル期間のユーザー、匿名ユーザーなどのビジネスロジックによって、ビューへのアクセス制御を行いたいといった実装は、ほぼどんなプロジェクトでも要件となることが多いのです。こういった場合は以下の様にデコレータを追加してあげるだけでよいです。

# src/app/decorators.py

import logging
import functools

from django.core.exceptions import PermissionDenied


logger = logging.getLogger(__name__)


def subscriber_required(fn):
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        request = args[0]
        if request.user.is_subscriber:
            return fn(*args, **kwargs)
        raise PermissionDenied

    return wrapper

# e.g.

@method_decorator([login_required, decorators.subscriber_required], 'dispatch')
class PaidContentView(TemplateView):
    template_name = 'paid-content.html'

このデコレーターの振る舞いは、「認証しているか?」「定期購読しているユーザーか?」と順にチェックしています。もし、認証はOKだけど、定期購読ユーザーでない場合は403(PermissionDenied)の例外を投げるようにしています。

Djangoのビュー実装で基本となるクラス

# django.views.generic.base.py

class TemplateView(TemplateResponseMixin, ContextMixin, View):
    """
    Render a template. Pass keyword arguments from the URLconf to the context.
    """
    def get(self, request, *args, **kwargs):
        context = self.get_context_data(**kwargs)
        return self.render_to_response(context)


# django.views.generic.detail.py

class SingleObjectMixin(ContextMixin):
    """
    Provide the ability to retrieve a single object for further manipulation.
    """
    model = None
    queryset = None
    slug_field = 'slug'
    context_object_name = None
    slug_url_kwarg = 'slug'
    pk_url_kwarg = 'pk'
    query_pk_and_slug = False

    def get_object(self, queryset=None):
        """
        Return the object the view is displaying.

        Require `self.queryset` and a `pk` or `slug` argument in the URLconf.
        Subclasses can override this to return any object.
        """
        # Use a custom queryset if provided; this is required for subclasses
        # like DateDetailView
        if queryset is None:
            queryset = self.get_queryset()

        # Next, try looking up by primary key.
        pk = self.kwargs.get(self.pk_url_kwarg)
        slug = self.kwargs.get(self.slug_url_kwarg)
        if pk is not None:
            queryset = queryset.filter(pk=pk)

        # Next, try looking up by slug.
        if slug is not None and (pk is None or self.query_pk_and_slug):
            slug_field = self.get_slug_field()
            queryset = queryset.filter(**{slug_field: slug})

        # If none of those are defined, it's an error.
        if pk is None and slug is None:
            raise AttributeError(
                "Generic detail view %s must be called with either an object "
                "pk or a slug in the URLconf." % self.__class__.__name__
            )

        try:
            # Get the single item from the filtered queryset
            obj = queryset.get()
        except queryset.model.DoesNotExist:
            raise Http404(_("No %(verbose_name)s found matching the query") %
                          {'verbose_name': queryset.model._meta.verbose_name})
        return obj

    def get_queryset(self):
        """
        Return the `QuerySet` that will be used to look up the object.

        This method is called by the default implementation of get_object() and
        may not be called if get_object() is overridden.
        """
        if self.queryset is None:
            if self.model:
                return self.model._default_manager.all()
            else:
                raise ImproperlyConfigured(
                    "%(cls)s is missing a QuerySet. Define "
                    "%(cls)s.model, %(cls)s.queryset, or override "
                    "%(cls)s.get_queryset()." % {
                        'cls': self.__class__.__name__
                    }
                )
        return self.queryset.all()

    def get_slug_field(self):
        """Get the name of a slug field to be used to look up by slug."""
        return self.slug_field

    def get_context_object_name(self, obj):
        """Get the name to use for the object."""
        if self.context_object_name:
            return self.context_object_name
        elif isinstance(obj, models.Model):
            return obj._meta.model_name
        else:
            return None

    def get_context_data(self, **kwargs):
        """Insert the single object into the context dict."""
        context = {}
        if self.object:
            context['object'] = self.object
            context_object_name = self.get_context_object_name(self.object)
            if context_object_name:
                context[context_object_name] = self.object
        context.update(kwargs)
        return super().get_context_data(**context)

class BaseDetailView(SingleObjectMixin, View):
    """A base view for displaying a single object."""
    def get(self, request, *args, **kwargs):
        self.object = self.get_object()
        context = self.get_context_data(object=self.object)
        return self.render_to_response(context)

class DetailView(SingleObjectTemplateResponseMixin, BaseDetailView):
    """
    Render a "detail" view of an object.

    By default this is a model instance looked up from `self.queryset`, but the
    view will support display of *any* object by overriding `self.get_object()`.
    """

ほとんどWebアプリケーションで必要な機能は、データのCRUD(登録、読み取り、更新、削除)が基本となります。それらの機能を実装するときにビューのget,postに処理を書くのではなく、Djangoの汎用クラスを使って実装しましょう。

DjangoのDetailClass(読み取り)

# django.views.generic.detail.py

class SingleObjectMixin(ContextMixin):
    """
    Provide the ability to retrieve a single object for further manipulation.
    """
    model = None
    queryset = None
    slug_field = 'slug'
    context_object_name = None
    slug_url_kwarg = 'slug'
    pk_url_kwarg = 'pk'
    query_pk_and_slug = False

    def get_object(self, queryset=None):
        """
        Return the object the view is displaying.

        Require `self.queryset` and a `pk` or `slug` argument in the URLconf.
        Subclasses can override this to return any object.
        """
        # Use a custom queryset if provided; this is required for subclasses
        # like DateDetailView
        if queryset is None:
            queryset = self.get_queryset()

        # Next, try looking up by primary key.
        pk = self.kwargs.get(self.pk_url_kwarg)
        slug = self.kwargs.get(self.slug_url_kwarg)
        if pk is not None:
            queryset = queryset.filter(pk=pk)

        # Next, try looking up by slug.
        if slug is not None and (pk is None or self.query_pk_and_slug):
            slug_field = self.get_slug_field()
            queryset = queryset.filter(**{slug_field: slug})

        # If none of those are defined, it's an error.
        if pk is None and slug is None:
            raise AttributeError(
                "Generic detail view %s must be called with either an object "
                "pk or a slug in the URLconf." % self.__class__.__name__
            )

        try:
            # Get the single item from the filtered queryset
            obj = queryset.get()
        except queryset.model.DoesNotExist:
            raise Http404(_("No %(verbose_name)s found matching the query") %
                          {'verbose_name': queryset.model._meta.verbose_name})
        return obj

    def get_queryset(self):
        """
        Return the `QuerySet` that will be used to look up the object.

        This method is called by the default implementation of get_object() and
        may not be called if get_object() is overridden.
        """
        if self.queryset is None:
            if self.model:
                return self.model._default_manager.all()
            else:
                raise ImproperlyConfigured(
                    "%(cls)s is missing a QuerySet. Define "
                    "%(cls)s.model, %(cls)s.queryset, or override "
                    "%(cls)s.get_queryset()." % {
                        'cls': self.__class__.__name__
                    }
                )
        return self.queryset.all()

    def get_slug_field(self):
        """Get the name of a slug field to be used to look up by slug."""
        return self.slug_field

    def get_context_object_name(self, obj):
        """Get the name to use for the object."""
        if self.context_object_name:
            return self.context_object_name
        elif isinstance(obj, models.Model):
            return obj._meta.model_name
        else:
            return None

    def get_context_data(self, **kwargs):
        """Insert the single object into the context dict."""
        context = {}
        if self.object:
            context['object'] = self.object
            context_object_name = self.get_context_object_name(self.object)
            if context_object_name:
                context[context_object_name] = self.object
        context.update(kwargs)
        return super().get_context_data(**context)


class BaseDetailView(SingleObjectMixin, View):
    """A base view for displaying a single object."""
    def get(self, request, *args, **kwargs):
        self.object = self.get_object()
        context = self.get_context_data(object=self.object)
        return self.render_to_response(context)


class DetailView(SingleObjectTemplateResponseMixin, BaseDetailView):
    """
    Render a "detail" view of an object.

    By default this is a model instance looked up from `self.queryset`, but the
    view will support display of *any* object by overriding `self.get_object()`.
    """

端折っていますが、DetailClassを使って、メソッドや要素を少し書き換えるだけで、簡単に詳細ページの実装ができます。

# e.g. 

#views.py

from django.views.generic import DetailView
from . import models


@method_decorator([login_required, subscriber_required], 'dispatch')
class PaidContentDetailView(DetailView):
    template_name = 'paid-content-detail.html'
    model = models.PaidContent
    queryset = models.PaidContent.objects


# src/app/urls.py

# SingleObjectMixinのpk_url_kwarg, slug_url_kwargを変えることで変数名を変えれる
# 基本のまま使っておいたほうが慣れている人のミスにならないので、極力変えないほうがいい

urlpatterns = [
    # PK
    path('paid-content-detail/<int:pk>', BusinessDetailView.as_view(),
         name='paid-content-detail')
    # PKをセットせずにSLUGで実装したい場合
    path('paid-content-detail/<slug:slug>', BusinessDetailView.as_view(),
         name='paid-content-detail')
]

# e.g. 関連するほかのオブジェクトや、N数のオブジェクトも取得してテンプレートで使いたい

@method_decorator([login_required, subscriber_required], 'dispatch')
class PaidContentDetailView(DetailView):
    template_name = 'paid-content-detail.html'
    model = models.PaidContent
    queryset = models.PaidContent.objects

    def get(self, request, *args, **kwargs):
        self.object = self.get_object()
        ctx = self.get_context_data(
            object=self.object,
            news=News.objects.all(),
            subscription=Subscription.objects.get(user=request.user)
        )
        return self.render_to_response(ctx)



getの処理を書いていませんが、継承しているクラス、ミックスインしているクラスを読むとわかるように、BaseDetailViewでコンテキストデータの更新をしています。ですので、objectという変数でテンプレート側でも取得しているモデルオブジェクトを参照できるようになっています。

{% extends "_base.html" %}
{% load static i18n widget_tweaks %}
{% block contents %}
{% include '_global_menu.html' %}
<div class="uk-container">
  <ul class="uk-breadcrumb">
    <li><a href="{% url 'index' %}">{% trans 'Home' %}</a></li>
    <li><a href="{% url 'dashboard' %}">{% trans 'Dashboard' %}</a></li>
    <li>{% trans 'Paid Content' %}: {{ object.name }}</li>
  </ul>
</div>
{% endblock contents %}

DjangoのCreateClass(登録)

次はCreateViewをみていましょう。

# django.views.generic.edit.py

class FormMixin(ContextMixin):
    """Provide a way to show and handle a form in a request."""
    initial = {}
    form_class = None
    success_url = None
    prefix = None

    def get_initial(self):
        """Return the initial data to use for forms on this view."""
        return self.initial.copy()

    def get_prefix(self):
        """Return the prefix to use for forms."""
        return self.prefix

    def get_form_class(self):
        """Return the form class to use."""
        return self.form_class

    def get_form(self, form_class=None):
        """Return an instance of the form to be used in this view."""
        if form_class is None:
            form_class = self.get_form_class()
        return form_class(**self.get_form_kwargs())

    def get_form_kwargs(self):
        """Return the keyword arguments for instantiating the form."""
        kwargs = {
            'initial': self.get_initial(),
            'prefix': self.get_prefix(),
        }

        if self.request.method in ('POST', 'PUT'):
            kwargs.update({
                'data': self.request.POST,
                'files': self.request.FILES,
            })
        return kwargs

    def get_success_url(self):
        """Return the URL to redirect to after processing a valid form."""
        if not self.success_url:
            raise ImproperlyConfigured("No URL to redirect to. Provide a success_url.")
        return str(self.success_url)  # success_url may be lazy

    def form_valid(self, form):
        """If the form is valid, redirect to the supplied URL."""
        return HttpResponseRedirect(self.get_success_url())

    def form_invalid(self, form):
        """If the form is invalid, render the invalid form."""
        return self.render_to_response(self.get_context_data(form=form))

    def get_context_data(self, **kwargs):
        """Insert the form into the context dict."""
        if 'form' not in kwargs:
            kwargs['form'] = self.get_form()
        return super().get_context_data(**kwargs)


class ModelFormMixin(FormMixin, SingleObjectMixin):
    """Provide a way to show and handle a ModelForm in a request."""
    fields = None

    def get_form_class(self):
        """Return the form class to use in this view."""
        if self.fields is not None and self.form_class:
            raise ImproperlyConfigured(
                "Specifying both 'fields' and 'form_class' is not permitted."
            )
        if self.form_class:
            return self.form_class
        else:
            if self.model is not None:
                # If a model has been explicitly provided, use it
                model = self.model
            elif getattr(self, 'object', None) is not None:
                # If this view is operating on a single object, use
                # the class of that object
                model = self.object.__class__
            else:
                # Try to get a queryset and extract the model class
                # from that
                model = self.get_queryset().model

            if self.fields is None:
                raise ImproperlyConfigured(
                    "Using ModelFormMixin (base class of %s) without "
                    "the 'fields' attribute is prohibited." % self.__class__.__name__
                )

            return model_forms.modelform_factory(model, fields=self.fields)

    def get_form_kwargs(self):
        """Return the keyword arguments for instantiating the form."""
        kwargs = super().get_form_kwargs()
        if hasattr(self, 'object'):
            kwargs.update({'instance': self.object})
        return kwargs

    def get_success_url(self):
        """Return the URL to redirect to after processing a valid form."""
        if self.success_url:
            url = self.success_url.format(**self.object.__dict__)
        else:
            try:
                url = self.object.get_absolute_url()
            except AttributeError:
                raise ImproperlyConfigured(
                    "No URL to redirect to.  Either provide a url or define"
                    " a get_absolute_url method on the Model.")
        return url

    def form_valid(self, form):
        """If the form is valid, save the associated model."""
        self.object = form.save()
        return super().form_valid(form)

class ProcessFormView(View):
    """Render a form on GET and processes it on POST."""
    def get(self, request, *args, **kwargs):
        """Handle GET requests: instantiate a blank version of the form."""
        return self.render_to_response(self.get_context_data())

    def post(self, request, *args, **kwargs):
        """
        Handle POST requests: instantiate a form instance with the passed
        POST variables and then check if it's valid.
        """
        form = self.get_form()
        if form.is_valid():
            return self.form_valid(form)
        else:
            return self.form_invalid(form)

    # PUT is a valid HTTP verb for creating (with a known URL) or editing an
    # object, note that browsers only support POST for now.
    def put(self, *args, **kwargs):
        return self.post(*args, **kwargs)

class BaseCreateView(ModelFormMixin, ProcessFormView):
    """
    Base view for creating a new object instance.

    Using this base class requires subclassing to provide a response mixin.
    """
    def get(self, request, *args, **kwargs):
        self.object = None
        return super().get(request, *args, **kwargs)

    def post(self, request, *args, **kwargs):
        self.object = None
        return super().post(request, *args, **kwargs)


これらの継承、ミックスインに少しの処理を追加、変更、削除すれば処理内容を思い通りに変更するためのメソッドが実装されています。

# e.g.

# Sample Form
class PaidContentForm(forms.ModelForm):

    class Meta:
        model = PaidContent
        fields = '__all__'

    
# Create View
@method_decorator([admin_required], 'dispatch')
class PaidContentCreateView(CreateView):
    template_name = 'paid-content-create.html'
    form_class = PaidContentForm

    def get_success_url(self):
        return reverse_lazy(
            'paid-content-detail', args=[self.object.id])

    def form_valid(self, form):
        # ログインしているユーザーを取り出して、モデルに紐づけたいケース
        form.instance.user = self.request.user
        return super().form_valid(form)

テンプレート側はこのようになります。

# e.g. paid-content-create.html
{% extends "_base.html" %}
{% load static i18n widget_tweaks %}
{% block extra_head %}
{% endblock extra_head %}
{% block contents %}
{% include '_global_menu.html' %}
<div class="uk-container">
  <ul class="uk-breadcrumb">
    <li><a href="{% url 'index' %}">{% trans 'Home' %}</a></li>
    <li><a href="{% url 'dashboard' %}">{% trans 'Dashboard' %}</a></li>
    <li>{% trans 'Create Paid Content' %}</li>
  </ul>
  <div>
    <div class="uk-card-large uk-card-default uk-width-1-1@m">
      <div class="uk-card-header">
        <div class="uk-grid-small uk-flex-middle" uk-grid>
          <div class="uk-width-auto">
            <a href="" class="uk-icon-button" uk-icon="user"></a>
          </div>
          <div class="uk-width-expand">
            <h3 class="uk-card-title uk-margin-remove-bottom">{% trans 'Create Paid Content' %}</h3>
          </div>
        </div>
      </div>
      <div class="uk-card-body">
        <form class="uk-form-stacked" method="post">
          {% csrf_token %}
          <div class="uk-margin">
            <label class="uk-form-label" for="{{ form.email.id_for_label }}">{{ form.name.label }}</label>
            <div class="uk-form-controls">
              {{ form.name|add_class:"uk-input"}}
              {% include '_input_alert.html' with errors=form.name.errors %}
            </div>
          </div>
          <button class="uk-button uk-button-primary uk-button-large uk-align-right" type="submit">{% trans 'Save' %}</button>
        </form>
      </div>
    </div>
  </div>
</div>
{% endblock contents %}


# _input_alert.html
{% for e in errors %}
<div class="uk-alert-danger" uk-alert>
  <a class="uk-alert-close" uk-close></a>
  <p>{{ e }}</p>
</div>
{% endfor %}

DjangoのUpdateClass(更新)

さきほどのDetailClass,CreateClassを混ぜた感じで、インスタンスしたときからオブジェクトを持っているか、いないかの違いです。コードを見てみましょう。

# django.views.generic.edit.py

class BaseCreateView(ModelFormMixin, ProcessFormView):
    """
    Base view for creating a new object instance.

    Using this base class requires subclassing to provide a response mixin.
    """
    def get(self, request, *args, **kwargs):
        self.object = None
        return super().get(request, *args, **kwargs)

    def post(self, request, *args, **kwargs):
        self.object = None
        return super().post(request, *args, **kwargs)


class BaseUpdateView(ModelFormMixin, ProcessFormView):
    """
    Base view for updating an existing object.

    Using this base class requires subclassing to provide a response mixin.
    """
    def get(self, request, *args, **kwargs):
        self.object = self.get_object()
        return super().get(request, *args, **kwargs)

    def post(self, request, *args, **kwargs):
        self.object = self.get_object()
        return super().post(request, *args, **kwargs)

# どちらもModelFormMixinをミックスインしているので、classのsuccess_urlにstr.format形式でキーワードを渡せる
def get_success_url(self):
        """Return the URL to redirect to after processing a valid form."""
        if self.success_url:
            # モデルの持つプロパティのキーワードでstr.formatメソッドによって値が挿入される
            url = self.success_url.format(**self.object.__dict__)

# e.g. 
class PaidContentUpdateView(models.Model):
    success_url = "paid-content-detail/{id}"
    

DjangoのDeleteClass(削除)

これまでのCRUとはMixinが少し変わっていますが、ほぼ似たりよったりです。

class DeletionMixin:
    """Provide the ability to delete objects."""
    success_url = None

    def delete(self, request, *args, **kwargs):
        """
        Call the delete() method on the fetched object and then redirect to the
        success URL.
        """
        self.object = self.get_object()
        success_url = self.get_success_url()
        self.object.delete()
        return HttpResponseRedirect(success_url)

    # Add support for browsers which only accept GET and POST for now.
    def post(self, request, *args, **kwargs):
        return self.delete(request, *args, **kwargs)

    def get_success_url(self):
        if self.success_url:
            return self.success_url.format(**self.object.__dict__)
        else:
            raise ImproperlyConfigured(
                "No URL to redirect to. Provide a success_url.")


class BaseDeleteView(DeletionMixin, BaseDetailView):
    """
    Base view for deleting an object.

    Using this base class requires subclassing to provide a response mixin.
    """

Djangoの汎用ビュークラスを継承したり、Viewにデコレータを復数追加することで、処理がすっきりしますし、既存のコードを使うことによって、テストする項目が少なくなるため、バグが出にくくなります。是非これらのクラスを使って無駄なコードは書かないようにしてみましょう。
次回はpytestとfactory_boyを使った、ビューとユニットテストについて書きたいと思います。

関連記事

ポリシー

この記事のすべてまたは一部の複製は、著作権者の同意なしでは禁止されています。 引用については著者名と記事のURLが表示されている場合に限り認められます。