Django の Class-Based Views と Function-Based Views

Django練習としてマルチユーザ対応・MarkdownX対応のブログシステムを作ってみました。
[Djangoブログ - GAEにDeploy](https://djangoblog.appspot.com/)

多くの方がそうだと思うのですが、Djangoを使い始めるときには、**Class-Based Views** と **Function-Based Views**のどちらを選択すべきか、迷うところです。Djangoエキスパートの方は、そんなことは気にしないような気がしますが、私のような初心者には気になります。


ブログシステムを作ってみた結果どうだったか、をまとめたのが本記事です。結論から言えば、基本は**Function-Based Views**で、型にはまる部分のみは**Class-Based Views**、というのが現在の私の指針です。

またアカウント関係（ユーザ登録・ユーザ認証）に関しては、定型的な処理が多いので**Class-Based Views**を使った方が良いでしょう。

[Class-Based Views vs. Function-Based Views](https://simpleisbetterthancomplex.com/article/2017/03/21/class-based-views-vs-function-based-views.html)



#１． ViewをFunctionとClassのどちらにすべきか？
以下のurls.pyから明らかなように、単純なModelに対応したCRUDのCUDについては、ビルドインClassのCreateView、UpdateView、DeleteViewを使った方が簡潔に記述できるのでClassにする。しかしそれ以外はClassでなく、Functionにした方が良い。柔軟だし、余分な抽象化（情報隠蔽）がされていないから。


urls.pyを以下に示しておきます。


```blog:urls.py
from django.urls import path
from . import views 


app_name = 'blog'

urlpatterns = [
    path('list/', views.post_list, name='post_list'),
    path('list/<username>/', views.post_user_list, name='post_user_list'),
    path('mylist/', views.post_my_list, name='post_my_list'),
    path('post/<username>/<int:year>/<int:month>/<int:day>/<int:id>/', views.post_detail, name='post_detail'),
    path('good/<int:good_id>', views.good, name='good'),
    path('post/create/', views.PostCreateView.as_view(), name='post_create'),
    path('post/<int:pk>/update/', views.PostUpdateView.as_view(), name='post_update'),
    path('post/<int:pk>/delete/', views.PostDeleteView.as_view(), name='post_delete'),
]
```


#２．Function-Based Views

## ２－１．post_list

単純に最新順にpostsを表示します。ページャで5 Posts/1 Pageの表示です。

それとは別にlatestsには最新のposts をMY_LATESTS_NUM個だけ入れます。これでサイドメニューに最新記事を表示できるようにします。

**Function-Based Views** は以下のようになります。

```
def post_list(request):
    object_list = Post.published.all()
    posts = paginate_queryset(request, object_list, 5)
    latests = Post.published.all()[:settings.MY_LATESTS_NUM]
    return render(request, 'blog/post_list.html', {'posts': posts, 'latests': latests})
```

対応する**Class-Based Views**は以下のようになります。この例で言えばFunction-Based Viewsの方が断然よさそうです。

```
class PostListView(ListView):
    queryset = Post.published.all()
    context_object_name = 'posts'
    paginate_by = 5
    template_name = 'blog/post_list.html'
    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['latests'] = Post.published.all()[:settings.MY_LATESTS_NUM]
        return context
```



##２－２．post_user_list

ユーザ毎のposts listを表示します。

まずは簡単にPost modelを確認しておきます。

```
class Post(models.Model): 
---
    author = models.ForeignKey(User, 
                               on_delete=models.CASCADE,
                               related_name='blog_posts')
---
```

**related_name**が指定されているので、user.post_setではなくuser.blog_postsでユーザのpostsを取得します。

post_listと同じように**Function-Based Views**で良い気がします。

```
def post_user_list(request, username):
    user = get_object_or_404(User, username=username)
    object_list = user.blog_posts.all()
    posts = paginate_queryset(request, object_list, 5)
    latests = Post.published.all()[:settings.MY_LATESTS_NUM]
    return render(request, 'blog/post_list.html', {'posts': posts, 'userobj': user, 'latests': latests})
```



##２－３．post_my_list

ログイン・ユーザ毎のposts listを表示します。ログイン時のみ有効です。

post_user_listとほとんど同じなので**Function-Based Views**で良い気がします。

```
@login_required
def post_my_list(request):
    # user = get_object_or_404(User, username=username)
    user = request.user
    object_list = user.blog_posts.all()
    posts = paginate_queryset(request, object_list, 20)
    latests = Post.published.all()[:settings.MY_LATESTS_NUM]
    return render(request, 'blog/post_mylist.html', {'posts': posts, 'latests': latests })
```



## ２－４．post_detail

まずはComment modelを見てみます。related_nameの指定によりpost.commentsでpost毎のcommentを取得できるのがわかります。


```
class Comment(models.Model): 
    post = models.ForeignKey(Post,
                             on_delete=models.CASCADE,
                             related_name='comments')
---
```


request.method や comment_form.is_valid()で場合分けして、コメントを保存します。**Function-Based Views**で書くと、ロジックがわかりやすいです。


```
def post_detail(request, username, year, month, day, id):
    post = get_object_or_404(Post, pk=id)
    
    # List of active comments for this post
    comments = post.comments.filter(active=True)

    new_comment = None

    if request.method == 'POST':
        # A comment was posted
        comment_form = CommentForm(data=request.POST)
        if comment_form.is_valid():
            # Create Comment object but don't save to database yet
            new_comment = comment_form.save(commit=False)
            # Assign the current post to the comment
            new_comment.post = post  # 外部キーをフォーム画面で選択させず、自動挿入する。
            # Save the comment to the database
            new_comment.save()
            messages.success(request, 'コメント投稿が成功しました。')

            return redirect(post)  # post.get_absolute_url()
        else:
            messages.error(request, 'エラー：コメント投稿に失敗しました。入力に誤りがあります。')
    else:
        comment_form = CommentForm()

    # 画像uploadの有無をTemplateに伝える
    if post.upload and hasattr(post.upload, 'url'):
        has_image = True
    else:
        has_image = False


    latests = Post.published.all()[:settings.MY_LATESTS_NUM]
    return render(request,
                  'blog/post_detail.html',
                  {'post': post,
                   'has_image': has_image,
                   'comments': comments,
                   'new_comment': new_comment,
                   'comment_form': comment_form,
                   'latests': latests })
```



## ２－５．good

「いいね」ボタンの実装です

まずはGood modelを見ておきましょう。「いいね」はUserとPostとの関連付けに他なりません。

```
# Goodクラス
class Good(models.Model):
    owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name='good_owner')
    post = models.ForeignKey(Post, on_delete=models.CASCADE)
    created = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return 'good for "' + str(self.post) + '" (by ' + str(self.owner) + ')'
```

これは定型的な処理ではないので、Class-Based Viewsにするには厳しいと思われます。

```
# goodボタンの処理
@login_required(login_url='/account/login/') # login_urlの指定もできる
def good(request, good_id):
    # goodするPostを取得
    good_post = Post.objects.get(id=good_id)

    if good_post.author == request.user: # 同じUserのインスタンスで、pkの比較を行っている
        messages.success(request, '自分の記事に「いいね」はできません。')
        return redirect(good_post)  # good_post.get_absolute_url()

    # 自分がpostにGoodした数を調べる
    is_good = Good.objects.filter(owner=request.user).filter(post=good_post).count()
    # ゼロより大きければ既にgood済み  is_good = 0 or 1
    if is_good > 0:
        messages.success(request, '既にこの記事にはGoodしています。')
        return redirect(good_post)  # good_post.get_absolute_url()
    
    # Postのgood_countを１増やす
    good_post.good_count += 1
    good_post.save()
    # Goodを作成し、設定して保存
    good = Good()
    good.owner = request.user
    good.post = good_post
    good.save()
    # メッセージを設定
    messages.success(request, '記事にGoodしました！')
    return redirect(good_post)  # good_post.get_absolute_url()
```





#３．Class-Based Views

Class-Based Viewsの場合は機能追加のために、Mix-inが多用されます。
「[[Python] Mix-in(ミックスイン)とは何なのか](https://btj0.com/%E3%83%96%E3%83%AD%E3%82%B0/python/python-mix-in%E3%83%9F%E3%83%83%E3%82%AF%E3%82%B9%E3%82%A4%E3%83%B3%E3%81%A8%E3%81%AF%E4%BD%95%E3%81%AA%E3%81%AE%E3%81%8B/)」

* Mix-inとはインスタンス変数を持たずメソッドだけを定義したクラスの事です。
* 継承されることを前提に作られています。
* 多重継承によりメソッド間でインスタンス変数の競合が起きることを防止します。
* Mix-inを好きなだけ継承することでサブクラスで必要な機能を追加する、といった使い方をします。

今回は以下の2つのMix-inを使います。「[Djangoの認証システムを使用する](https://docs.djangoproject.com/ja/2.2/topics/auth/default/#django.contrib.auth.mixins.UserPassesTestMixin)」

* **LoginRequiredMixin** - LoginRequiredMixin を使うことで login_required と同じ動作をさせることができます。 この mixin は、継承リストの一番左に記述される必要があります。
* **UserPassesTestMixin** -  test_funcメソッドでアクセス制限をかけることができます。



以下にCreate-Update-DeleteのViewsを示します。特別なロジックを組みこむ必要がないときは、Class-Based Viewsできれいに書くことができます。Modelに対する処理は明示的に現れません。save()さえも。



## ３－１．PostCreateView


Class-Based Viewsでは**form_class**でformを指定します。また**fields**を指定することでformを指定することもできます。fields は ModelFormのinner Meta classのfieldsと同じ意味を持ちます。 「[クラスベースのビューでフォームを扱う](https://docs.djangoproject.com/ja/2.2/topics/class-based-views/generic-editing/)」



```
class PostCreateView(LoginRequiredMixin, CreateView):
    model = Post
    form_class = PostForm
    template_name = 'blog/post_create.html'
    login_url = 'login'  # LoginRequiredMixin

    def form_valid(self, form):
        form.instance.author = self.request.user   # 外部キーをフォーム画面で選択させず、自動挿入する。
        messages.success(self.request, '記事投稿が成功しました。')
        return super().form_valid(form)

    def form_invalid(self, form):
        messages.error(self.request, 'エラー：記事投稿に失敗しました。入力に誤りがあります。')
        return super().form_invalid(form)

    def get_context_data(self, **kwargs):
        context = super(PostCreateView, self).get_context_data(**kwargs) # はじめに継承元のメソッドを呼び出す
        context["content_full"] = True
        return context
```




## ３－２．PostUpdateView


```
class PostUpdateView(LoginRequiredMixin, UserPassesTestMixin, UpdateView):
    model = Post
    form_class = PostForm
    template_name = 'blog/post_create.html'
    # fields = ['title', 'body', 'status']
    login_url = 'login'   # LoginRequiredMixin

    def test_func(self):  # UserPassesTestMixin
        obj = self.get_object()
        if obj.author != self.request.user:
            messages.error(self.request, 'エラー：記事更新に失敗しました。他人の記事は編集できません。')
        return obj.author == self.request.user

    def form_valid(self, form):
        obj = self.get_object()
        if obj.status == 'draft' and form.instance.status == 'published':
            form.instance.publish = timezone.now()
        messages.success(self.request, '記事更新が成功しました。')
        return super().form_valid(form)

    def form_invalid(self, form):
        messages.error(self.request, 'エラー：記事更新に失敗しました。入力に誤りがあります。')
        return super().form_invalid(form)

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs) # はじめに継承元のメソッドを呼び出す
        context["content_full"] = True
        return context
```


##３－３．PostDeleteView

```
class PostDeleteView(LoginRequiredMixin, UserPassesTestMixin, DeleteView):
    model = Post
    template_name = 'blog/post_delete.html'
    success_url = reverse_lazy('blog:post_my_list')
    login_url = 'login'   # LoginRequiredMixin

    def test_func(self):  # UserPassesTestMixin
        obj = self.get_object()
        if obj.author != self.request.user:
            messages.error(self.request, 'エラー：記事更新に失敗しました。他人の記事は編集できません。')
        return obj.author == self.request.user

    def form_valid(self, form):
        messages.success(self.request, '記事削除が成功しました。')
        return super().form_valid(form)

    def form_invalid(self, form):
        messages.error(self.request, 'エラー：記事削除に失敗しました。入力に誤りがあります。')
        return super().form_invalid(form)

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs) # はじめに継承元のメソッドを呼び出す
        return context
```




# 全ソース


一応、views.pyの全ソースを晒します。


```blog:views.py
from django.shortcuts import render, get_object_or_404 
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from django.views.generic import ListView
from django.views.generic.edit import CreateView, UpdateView, DeleteView
from django.urls import reverse_lazy, reverse
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User
from .models import Post, Good 
from .forms import PostForm, CommentForm
from django.shortcuts import redirect
from django.contrib import messages
from django.utils import timezone

from django.conf import settings


# Commentsのcreateはpost_detail()で行う。
# Comenntoの削除や有効／無効はadminで行えばよい。便利。


def paginate_queryset(request, queryset, count):
    """Pageオブジェクトを返す。

    ページングしたい場合に利用してください。

    countは、1ページに表示する件数です。
    返却するPgaeオブジェクトは、以下のような感じで使えます。

        {% if page_obj.has_previous %}
          <a href="?page={{ page_obj.previous_page_number }}">Prev</a>
        {% endif %}

    また、page_obj.object_list で、count件数分の絞り込まれたquerysetが取得できます。

    """
    paginator = Paginator(queryset, count)
    page = request.GET.get('page')
    try:
        page_obj = paginator.page(page)
    except PageNotAnInteger:
        page_obj = paginator.page(1)
    except EmptyPage:
        page_obj = paginator.page(paginator.num_pages)
    return page_obj


def post_home(request):
    object_list = Post.published.all()
    posts = paginate_queryset(request, object_list, 3)
    latests = Post.published.all()[:settings.MY_LATESTS_NUM]
    return render(request, 'blog/post_home.html', {'posts': posts, 'latests': latests})


def post_list(request):
    object_list = Post.published.all()
    posts = paginate_queryset(request, object_list, 5)
    latests = Post.published.all()[:settings.MY_LATESTS_NUM]
    return render(request, 'blog/post_list.html', {'posts': posts, 'latests': latests})



def post_user_list(request, username):
    user = get_object_or_404(User, username=username)
    object_list = user.blog_posts.all()
    posts = paginate_queryset(request, object_list, 5)
    latests = Post.published.all()[:settings.MY_LATESTS_NUM]
    return render(request, 'blog/post_list.html', {'posts': posts, 'userobj': user, 'latests': latests})



# if the user is not authenticated, it redirects the user to the login URL
# with the originally requested URL as a GET parameter named next
@login_required
def post_my_list(request):
    # user = get_object_or_404(User, username=username)
    user = request.user
    object_list = user.blog_posts.all()
    posts = paginate_queryset(request, object_list, 20)
    latests = Post.published.all()[:settings.MY_LATESTS_NUM]
    return render(request, 'blog/post_mylist.html', {'posts': posts, 'latests': latests })




# username, year, month, dayはURLの可読性のためにのみ存在する。
def post_detail(request, username, year, month, day, id):
    post = get_object_or_404(Post, pk=id)
    
    # List of active comments for this post
    comments = post.comments.filter(active=True)

    new_comment = None

    if request.method == 'POST':
        # A comment was posted
        comment_form = CommentForm(data=request.POST)
        if comment_form.is_valid():
            # Create Comment object but don't save to database yet
            new_comment = comment_form.save(commit=False)
            # Assign the current post to the comment
            new_comment.post = post  # 外部キーをフォーム画面で選択させず、自動挿入する。
            # Save the comment to the database
            new_comment.save()
            messages.success(request, 'コメント投稿が成功しました。')

            # return redirect(reverse('blog:post_detail', args=(username, year, month, day, id))) # args=tuple
            return redirect(post)  # post.get_absolute_url()
        else:
            messages.error(request, 'エラー：コメント投稿に失敗しました。入力に誤りがあります。')
    else:
        comment_form = CommentForm()


    if post.upload and hasattr(post.upload, 'url'):
        has_image = True
    else:
        has_image = False


    latests = Post.published.all()[:settings.MY_LATESTS_NUM]
    return render(request,
                  'blog/post_detail.html',
                  {'post': post,
                   'has_image': has_image,
                   'comments': comments,
                   'new_comment': new_comment,
                   'comment_form': comment_form,
                   'latests': latests })
                   # 'similar_posts': similar_posts})



class PostCreateView(LoginRequiredMixin, CreateView):
    model = Post
    form_class = PostForm
    template_name = 'blog/post_create.html'
    # fields = ['title', 'body', 'status', 'tags', 'upload'] => form_class
    login_url = 'login'  # LoginRequiredMixin

    def form_valid(self, form):
        form.instance.author = self.request.user   # 外部キーをフォーム画面で選択させず、自動挿入する。
        messages.success(self.request, '記事投稿が成功しました。')
        return super().form_valid(form)

    def form_invalid(self, form):
        messages.error(self.request, 'エラー：記事投稿に失敗しました。入力に誤りがあります。')
        return super().form_invalid(form)

    def get_context_data(self, **kwargs):
        context = super(PostCreateView, self).get_context_data(**kwargs) # はじめに継承元のメソッドを呼び出す
        context["content_full"] = True
        return context

# Comments の外部キー(Post)。関数viewの例。
# https://github.com/PacktPublishing/Django-2-by-Example/blob/master/Chapter02/mysite/blog/views.py


class PostUpdateView(LoginRequiredMixin, UserPassesTestMixin, UpdateView):
    model = Post
    form_class = PostForm
    template_name = 'blog/post_create.html'
    # fields = ['title', 'body', 'status']
    login_url = 'login'   # LoginRequiredMixin

    def test_func(self):  # UserPassesTestMixin
        obj = self.get_object()
        if obj.author != self.request.user:
            messages.error(self.request, 'エラー：記事更新に失敗しました。他人の記事は編集できません。')
        return obj.author == self.request.user

    def form_valid(self, form):
        obj = self.get_object()
        if obj.status == 'draft' and form.instance.status == 'published':
            form.instance.publish = timezone.now()
        messages.success(self.request, '記事更新が成功しました。')
        return super().form_valid(form)

    def form_invalid(self, form):
        messages.error(self.request, 'エラー：記事更新に失敗しました。入力に誤りがあります。')
        return super().form_invalid(form)

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs) # はじめに継承元のメソッドを呼び出す
        context["content_full"] = True
        return context


class PostDeleteView(LoginRequiredMixin, UserPassesTestMixin, DeleteView):
    model = Post
    template_name = 'blog/post_delete.html'
    success_url = reverse_lazy('blog:post_my_list')
    login_url = 'login'   # LoginRequiredMixin

    def test_func(self):  # UserPassesTestMixin
        obj = self.get_object()
        if obj.author != self.request.user:
            messages.error(self.request, 'エラー：記事更新に失敗しました。他人の記事は編集できません。')
        return obj.author == self.request.user

    def form_valid(self, form):
        messages.success(self.request, '記事削除が成功しました。')
        return super().form_valid(form)

    def form_invalid(self, form):
        messages.error(self.request, 'エラー：記事削除に失敗しました。入力に誤りがあります。')
        return super().form_invalid(form)

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs) # はじめに継承元のメソッドを呼び出す
        return context


# goodボタンの処理
@login_required(login_url='/account/login/') # login_urlの指定もできる！？
def good(request, good_id):
    # goodするPostを取得
    good_post = Post.objects.get(id=good_id)

    if good_post.author == request.user: # 同じUserのインスタンスで、pkの比較を行っている
        messages.success(request, '自分の記事に「いいね」はできません。')
        return redirect(good_post)  # good_post.get_absolute_url()

    # 自分がpostにGoodした数を調べる
    is_good = Good.objects.filter(owner=request.user).filter(post=good_post).count()
    # ゼロより大きければ既にgood済み  is_good = 0 or 1
    if is_good > 0:
        messages.success(request, '既にこの記事にはGoodしています。')
        return redirect(good_post)  # good_post.get_absolute_url()
    
    # Postのgood_countを１増やす
    good_post.good_count += 1
    good_post.save()
    # Goodを作成し、設定して保存
    good = Good()
    good.owner = request.user
    good.post = good_post
    good.save()
    # メッセージを設定
    messages.success(request, '記事にGoodしました！')
    return redirect(good_post)  # good_post.get_absolute_url()
```



今回は以上です。