Django練習としてマルチユーザ対応・MarkdownX対応のブログシステムを作ってみました。
Djangoブログ - GAEにDeploy
多くの方がそうだと思うのですが、Djangoを使い始めるときには、Class-Based Views と Function-Based Viewsのどちらを選択すべきか、迷うところです。Djangoエキスパートの方は、そんなことは気にしないような気がしますが、私のような初心者には気になります。
ブログシステムを作ってみた結果どうだったか、をまとめたのが本記事です。結論から言えば、基本はFunction-Based Viewsで、型にはまる部分のみはClass-Based Views、というのが現在の私の指針です。
またアカウント関係(ユーザ登録・ユーザ認証)に関しては、定型的な処理が多いのでClass-Based Viewsを使った方が良いでしょう。
Class-Based Views vs. Function-Based Views
#1. ViewをFunctionとClassのどちらにすべきか?
以下のurls.pyから明らかなように、単純なModelに対応したCRUDのCUDについては、ビルドインClassのCreateView、UpdateView、DeleteViewを使った方が簡潔に記述できるのでClassにする。しかしそれ以外はClassでなく、Functionにした方が良い。柔軟だし、余分な抽象化(情報隠蔽)がされていないから。
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'),
]
#2.Function-Based Views
2-1.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
##2-2.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})
##2-3.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 })
2-4.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 })
2-5.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を1増やす
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()
#3.Class-Based Views
Class-Based Viewsの場合は機能追加のために、Mix-inが多用されます。
「[Python] Mix-in(ミックスイン)とは何なのか」
- Mix-inとはインスタンス変数を持たずメソッドだけを定義したクラスの事です。
- 継承されることを前提に作られています。
- 多重継承によりメソッド間でインスタンス変数の競合が起きることを防止します。
- Mix-inを好きなだけ継承することでサブクラスで必要な機能を追加する、といった使い方をします。
今回は以下の2つのMix-inを使います。「Djangoの認証システムを使用する」
- LoginRequiredMixin - LoginRequiredMixin を使うことで login_required と同じ動作をさせることができます。 この mixin は、継承リストの一番左に記述される必要があります。
- UserPassesTestMixin - test_funcメソッドでアクセス制限をかけることができます。
以下にCreate-Update-DeleteのViewsを示します。特別なロジックを組みこむ必要がないときは、Class-Based Viewsできれいに書くことができます。Modelに対する処理は明示的に現れません。save()さえも。
3-1.PostCreateView
Class-Based Viewsではform_classでformを指定します。またfieldsを指定することでformを指定することもできます。fields は ModelFormのinner Meta classのfieldsと同じ意味を持ちます。 「クラスベースのビューでフォームを扱う」
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
3-2.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
##3-3.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の全ソースを晒します。
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を1増やす
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()
今回は以上です。