[>>原文地址][1] By Vitor Freitas
简介
欢迎来到教程系列的第六部分!在本次教程中,我们将详细探讨基于类的页面。我们还将重构一些现有的页面,以便利用内置的基于泛型类的页面。
本教程还将涉及许多其他内容,例如分页、Markdown以及添加一个简单的编辑器。我们还将使用一个名为Humanize
的内置包,可以将数据翻译为人类可读的语言。
好吧,伙计们!让我们实现一些代码,今天还有有很多工作要做!
页面实现策略
归根结底,所有DJango的界面都是函数方法,包括基于类的页面(CBV)。DJango在后台处理好了所有的逻辑,然后返回一个界面方法。
通过引入基于类的页面,开发人员可以方便地重用和扩展页面。它有许多好处,例如可扩展性、使用面向对象的功能:多继承、HTTP方法的分离处理而非条件分支,还有基于类的通用页面(GCBV)。
在继续之前,让我们先澄清这三个术语的含义:
- 基于功能的页面(FBV)
- 基于类的页面(CBV)
- 基于类的通用页面(GCBV)
FBV是Django页面中最简单的一个:它只是一个接收HttpRequest对象并返回HttpResponse的函数。
CBV是定义为Python类的Django页面,它继承自django.views.generic.View这个抽象类。CBV本质上是一个封装FBV的类,非常适合扩展和重用代码。
GCBV是内置的CBV,可以用来做诸如列表页面、创建、更新和删除页面等。
下面我们将探讨一些不同的实现策略的例子。
基于功能的页面(FBV)
views.py
def new_post(request):
if request.method == 'POST':
form = PostForm(request.POST)
if form.is_valid():
form.save()
return redirect('post_list')
else:
form = PostForm()
return render(request, 'new_post.html', {'form': form})
urls.py
<details>
<summary>原始版本</summary>
urlpatterns = [
url(r'^new_post/$', views.new_post, name='new_post'),
]
</details>
<details open>
<summary>修订版本</summary>
urlpatterns = [
re_path(r'^new_post/$', views.new_post, name='new_post'),
]
</details>
基于类的页面(CBV)
CBV是扩展View类的页面。这里的主要区别是请求是在以HTTP方法命名的类方法(例如get、post、put、head等)内处理的。
所以这里我们不需要做条件检查请求是POST还是GET,代码直接指向正确的方法。这个逻辑在页面类内部处理。
views.py
from django.views.generic import View
class NewPostView(View):
def post(self, request):
form = PostForm(request.POST)
if form.is_valid():
form.save()
return redirect('post_list')
return render(request, 'new_post.html', {'form': form})
def get(self, request):
form = PostForm()
return render(request, 'new_post.html', {'form': form})
urls.py文件指向CBV的方式稍有不同,需要改一下:
urls.py
<details>
<summary>原始版本</summary>
urlpatterns = [
url(r'^new_post/$', views.NewPostView.as_view(), name='new_post'),
]
</details>
<details open>
<summary>修订版本</summary>
urlpatterns = [
re_path(r'^new_post/$', views.NewPostView.as_view(), name='new_post'),
]
</details>
这里需要使用as_view()
这个类方法,它向url模板返回一个view函数。在某些情况下,还可以给as_view()
提供一些参数,以定制CBV的行为,就像我们使用一些身份验证页面定制模板一样。
总之,CBV的好处是我们可以添加更多更灵活的方法,还可以这样做:
from django.views.generic import View
class NewPostView(View):
def render(self, request):
return render(request, 'new_post.html', {'form': self.form})
def post(self, request):
self.form = PostForm(request.POST)
if self.form.is_valid():
self.form.save()
return redirect('post_list')
return self.render(request)
def get(self, request):
self.form = PostForm()
return self.render(request)
也可以为某些独立的功能创建一些通用页面,方便地进行复用。
以上就是本次需要了解CBV的全部内容了。
基于类的通用页面(GCBV)
关于GCBV,那就是另一回事了,正如我面提到的,这些页面是使用于常见用例的内置CBV。它们的实现大量使用了多重继承和其他面向对象策略。
它们非常灵活,可以节省许多工作时间。但在一开始,可能因为不熟悉而比较难以理解。
当第一次开始使用Django时,我发现GCBV很难使用。首先,很难判断到底发生了什么,因为代码里并不明显,因为父类中隐藏了大量代码。查看这些文档也有点困难,主要是因为属性和方法有时分布在多大八个父类中。使用GCBV时,最好有ccbv.co.uk网站打开以供快速参考。别担心,我们一起来看看吧。
现在先看一个GCBV示例。
views.py
from django.views.generic import CreateView
class NewPostView(CreateView):
model = Post
form_class = PostForm
success_url = reverse_lazy('post_list')
template_name = 'new_post.html'
这使用的是用于创建模型对象的通用页面,它执行所有表单处理,并在表单有效时保存对象。
因为这里改为了CBV,所以我们需要修改一下路由urls.py文件:
urls.py
<details>
<summary>原始版本</summary>
urlpatterns = [
url(r'^new_post/$', views.NewPostView.as_view(), name='new_post'),
]
</details>
<details open>
<summary>修订版本</summary>
urlpatterns = [
re_path(r'^new_post/$', views.NewPostView.as_view(), name='new_post'),
]
</details>
其他使用GCBV的例子还有:DetailView, DeleteView, FormView, UpdateView, ListView。
更新页面
回到项目的开发上,这次我们将使用GCBV来实现帖子编辑页面:
编辑帖子-
boards/views.py
(完整文档地址)
from django.shortcuts import redirect
from django.views.generic import UpdateView
from django.utils import timezone
class PostUpdateView(UpdateView):
model = Post
fields = ('message', )
template_name = 'edit_post.html'
pk_url_kwarg = 'post_pk'
context_object_name = 'post'
def form_valid(self, form):
post = form.save(commit=False)
post.updated_by = self.request.user
post.updated_at = timezone.now()
post.save()
return redirect('topic_posts', pk=post.topic.board.pk, topic_pk=post.topic.pk)
使用UpdateView和CreateView时,我们可以选择定义form_class
或fields
属性。在上面的例子中,我们使用fields
属性动态创建一个模型表单。Django底层将使用一个模型表单工厂来组成Post模型的表单。因为它是一个非常简单的表单,只有message字段,所以我们可以像这样编写。但是对于复杂的表单定义,最好在外部定义一个模型表单并在这里引用它。
pk_url_kwarg
将用于标识用于检索Post对象的关键字参数的名称,和我们在urls.py定义的相同。
如果不设置context_object_name
属性,Post对象将在模板中被识别为“object”使用。因此,这里我们使用context_object_name将其重命名为post
,下面的模板中可以看到我们是如何使用它的。
在这个示例中,我们必须重写form_valid()
方法,设置一些额外的字段,例如updated_by
和updated_at
。您可以在这里看到基form_valid()
方法的外观:UpdateView#form_valid。
-
myproject/urls.py
(完整文档地址)
<details>
<summary>原始版本</summary>
from django.conf.urls import url
from boards import views
urlpatterns = [
# ...
url(r'^boards/(?P<pk>\d+)/topics/(?P<topic_pk>\d+)/posts/(?P<post_pk>\d+)/edit/$',
views.PostUpdateView.as_view(), name='edit_post'),
]
</details>
<details open>
<summary>修订版本</summary>
from django.urls import re_path
from boards import views
urlpatterns = [
# ...
re_path(r'^boards/(?P<pk>\d+)/topics/(?P<topic_pk>\d+)/posts/(?P<post_pk>\d+)/edit/$',
views.PostUpdateView.as_view(), name='edit_post'),
]
</details>
把这个链接添加到编辑帖子页面:
-
templates/topic_posts.html
(完整文档地址)
{% if post.created_by == user %}
<div class="mt-3">
<a href="{% url 'edit_post' post.topic.board.pk post.topic.pk post.pk %}"
class="btn btn-primary btn-sm"
role="button">Edit</a>
</div>
{% endif %}
-
templates/edit_post.html
(完整文档地址)
{% extends 'base.html' %}
{% block title %}Edit post{% endblock %}
{% block breadcrumb %}
<li class="breadcrumb-item"><a href="{% url 'home' %}">Boards</a></li>
<li class="breadcrumb-item"><a href="{% url 'board_topics' post.topic.board.pk %}">{{ post.topic.board.name }}</a></li>
<li class="breadcrumb-item"><a href="{% url 'topic_posts' post.topic.board.pk post.topic.pk %}">{{ post.topic.subject }}</a></li>
<li class="breadcrumb-item active">Edit post</li>
{% endblock %}
{% block content %}
<form method="post" class="mb-4" novalidate>
{% csrf_token %}
{% include 'includes/form.html' %}
<button type="submit" class="btn btn-success">Save changes</button>
<a href="{% url 'topic_posts' post.topic.board.pk post.topic.pk %}" class="btn btn-outline-secondary" role="button">Cancel</a>
</form>
{% endblock %}
注意这里的调用方式post.topic.board.pk
,如果我们没有设置context_object_name
为post
,这里就只能通过object.topic.board.pk
进行调用。
测试更新页面
在boards/tests
目录下创建一个新的测试文件,命名为test_view_edit_post.py
。你可以通过下面的链接查看完整文件,这里我只将改动的部分列出来:
-
boards/tests/test_view_edit_post.py
(完整文档地址)
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from ..models import Board, Post, Topic
from ..views import PostUpdateView
class PostUpdateViewTestCase(TestCase):
'''
Base test case to be used in all `PostUpdateView` view tests
'''
def setUp(self):
self.board = Board.objects.create(name='Django', description='Django board.')
self.username = 'john'
self.password = '123'
user = User.objects.create_user(username=self.username, email='john@doe.com', password=self.password)
self.topic = Topic.objects.create(subject='Hello, world', board=self.board, starter=user)
self.post = Post.objects.create(message='Lorem ipsum dolor sit amet', topic=self.topic, created_by=user)
self.url = reverse('edit_post', kwargs={
'pk': self.board.pk,
'topic_pk': self.topic.pk,
'post_pk': self.post.pk
})
class LoginRequiredPostUpdateViewTests(PostUpdateViewTestCase):
def test_redirection(self):
'''
Test if only logged in users can edit the posts
'''
login_url = reverse('login')
response = self.client.get(self.url)
self.assertRedirects(response, '{login_url}?next={url}'.format(login_url=login_url, url=self.url))
class UnauthorizedPostUpdateViewTests(PostUpdateViewTestCase):
def setUp(self):
'''
Create a new user different from the one who posted
'''
super().setUp()
username = 'jane'
password = '321'
user = User.objects.create_user(username=username, email='jane@doe.com', password=password)
self.client.login(username=username, password=password)
self.response = self.client.get(self.url)
def test_status_code(self):
'''
A topic should be edited only by the owner.
Unauthorized users should get a 404 response (Page Not Found)
'''
self.assertEquals(self.response.status_code, 404)
class PostUpdateViewTests(PostUpdateViewTestCase):
# ...
class SuccessfulPostUpdateViewTests(PostUpdateViewTestCase):
# ...
class InvalidPostUpdateViewTests(PostUpdateViewTestCase):
# ...
这里需要注意的是:PostUpdateViewTestCase是我们定义的一个可以复用的基类,这里面进行了一些基本数据的初始化,如创建用户、主题、版块等。
LoginRequiredPostUpdateViewTests这个测试类会检查页面是否被装饰器@login_required
正确的保护着,是否只有授权用户能够访问这些页面。
UnauthorizedPostUpdateViewTests这个测试类创建了一个新用户,而不是帖子的发起者,尝试用这个新用户去访问那个帖子的编辑页面,正确的逻辑是应用程序应当只允许帖子发起人去编辑。
运行一下这个测试:
python manage.py test boards.tests.test_view_edit_post
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..F.......F
======================================================================
FAIL: test_redirection (boards.tests.test_view_edit_post.LoginRequiredPostUpdateViewTests)
----------------------------------------------------------------------
...
AssertionError: 200 != 302 : Response didn't redirect as expected: Response code was 200 (expected 302)
======================================================================
FAIL: test_status_code (boards.tests.test_view_edit_post.UnauthorizedPostUpdateViewTests)
----------------------------------------------------------------------
...
AssertionError: 200 != 404
----------------------------------------------------------------------
Ran 11 tests in 1.360s
FAILED (failures=2)
Destroying test database for alias 'default'...
首先,让我们解决@login_required
装饰器的问题,在CBV里使用这个装饰器需要进行一点改动:
-
boards/views.py
(完整文档地址)
from django.contrib.auth.decorators import login_required
from django.shortcuts import redirect
from django.views.generic import UpdateView
from django.utils import timezone
from django.utils.decorators import method_decorator
from .models import Post
@method_decorator(login_required, name='dispatch')
class PostUpdateView(UpdateView):
model = Post
fields = ('message', )
template_name = 'edit_post.html'
pk_url_kwarg = 'post_pk'
context_object_name = 'post'
def form_valid(self, form):
post = form.save(commit=False)
post.updated_by = self.request.user
post.updated_at = timezone.now()
post.save()
return redirect('topic_posts', pk=post.topic.board.pk, topic_pk=post.topic.pk)
@login_required
装饰器不能直接对类使用,这里需要用到@method_decorator
,并且需要说明哪些方法需要使用这个装饰器。在CBV中比较常见的是使用dispatch这个方法,这是DJango内部定义的方法,所有请求都会通过这个方法,对它添加装饰器就可以达到我们的要求了。
再运行一次测试:
python manage.py test boards.tests.test_view_edit_post
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..........F
======================================================================
FAIL: test_status_code (boards.tests.test_view_edit_post.UnauthorizedPostUpdateViewTests)
----------------------------------------------------------------------
...
AssertionError: 200 != 404
----------------------------------------------------------------------
Ran 11 tests in 1.353s
FAILED (failures=1)
Destroying test database for alias 'default'...
好的, 我们解决了@login_required
装饰器问题,回过头来,还需要解决非帖子所有人对帖子进行编辑的权限问题。
解决这个问题最简单的方法是通过重写UpdateView的方法get_queryset
,你可以在这里看到它的原始实现方法。UpdateView#get_queryset
-
boards/views.py
(完整文档地址)
@method_decorator(login_required, name='dispatch')
class PostUpdateView(UpdateView):
model = Post
fields = ('message', )
template_name = 'edit_post.html'
pk_url_kwarg = 'post_pk'
context_object_name = 'post'
def get_queryset(self):
queryset = super().get_queryset()
return queryset.filter(created_by=self.request.user)
def form_valid(self, form):
post = form.save(commit=False)
post.updated_by = self.request.user
post.updated_at = timezone.now()
post.save()
return redirect('topic_posts', pk=post.topic.board.pk, topic_pk=post.topic.pk)
通过queryset = super().get_queryset()
的方式,我们获取到了父类UpateView调用这个方法的结果,然后我们添加了一个额外的过滤器,该过滤器使用request对象中提供的登录用户来过滤post
。
再测试一次:
python manage.py test boards.tests.test_view_edit_post
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...........
----------------------------------------------------------------------
Ran 11 tests in 1.321s
OK
Destroying test database for alias 'default'...
全部解决了!
列表页面
可以利用CBV的功能重构一些现有的页面,以主页为例,主页是通过获取数据库中的版块数据并直接展示在HTML上:
boards/views.py
from django.shortcuts import render
from .models import Board
def home(request):
boards = Board.objects.all()
return render(request, 'home.html', {'boards': boards})
下面我们使用GCBV来对模型列表进行重构:
-
boards/views.py
(完整文档地址)
from django.views.generic import ListView
from .models import Board
class BoardListView(ListView):
model = Board
context_object_name = 'boards'
template_name = 'home.html'
我们还需要修改urls.py的路由:
-
myproject/urls.py
(完整文档地址)
<details>
<summary>原始版本</summary>
from django.conf.urls import url
from boards import views
urlpatterns = [
url(r'^$', views.BoardListView.as_view(), name='home'),
# ...
]
</details>
<details open>
<summary>修订版本</summary>
from django.urls import re_path
from boards import views
urlpatterns = [
re_path(r'^$', views.BoardListView.as_view(), name='home'),
# ...
]
</details>
这时首页页面看起来并没有任何改变,所有功能都能正常工作,不过因为它现在改为了CBV,我们得改一改它的测试用例:
-
boards/tests/test_view_home.py
(完整文档地址)
from django.test import TestCase
from django.urls import resolve
from ..views import BoardListView
class HomeTests(TestCase):
# ...
def test_home_url_resolves_home_view(self):
view = resolve('/')
self.assertEquals(view.func.view_class, BoardListView)
数据分页
虽然通过CBV我们可以很简单地实现数据分页,但这里我想手动来实现一次分页,这样有助于探索它背后的原理。
Board版块数据可能不太需要进行分页,毕竟一般来说也不会有太多的版块。不过Topic和Post的数据肯定是需要进行分页的。
现在让我们切换到board_topics
页面来继续。
首先,让我们添加点帖子数据,可以通过我们实现的用户界面或者直接通过Python Shell写脚本来解决:
python manage.py shell
from django.contrib.auth.models import User
from boards.models import Board, Topic, Post
user = User.objects.first()
board = Board.objects.get(name='Django')
for i in range(100):
subject = 'Topic test #{}'.format(i)
topic = Topic.objects.create(subject=subject, board=board, starter=user)
Post.objects.create(message='Lorem ipsum...', topic=topic, created_by=user)
主题列表
好了,现在我们有足够的数据来进行开发了。
在我们开始编写代码前,让我们先在Python Shell中调试一下:
python manage.py shell
from boards.models import Topic
# All the topics in the app
Topic.objects.count()
107
# Just the topics in the Django board
Topic.objects.filter(board__name='Django').count()
104
# Let's save this queryset into a variable to paginate it
queryset = Topic.objects.filter(board__name='Django').order_by('-last_updated')
记得一定要给需要分页的查询结果QuerySet进行排序,否则,可能会导致返回的结果出现混乱。
现在让我们使用分页工具Paginator:
from django.core.paginator import Paginator
paginator = Paginator(queryset, 20)
这里我们告诉DJango将查询的结果按每页20条数据进行分页,下面是分页工具的一些其他属性:
# count the number of elements in the paginator
paginator.count
104
# total number of pages
# 104 elements, paginating 20 per page gives you 6 pages
# where the last page will have only 4 elements
paginator.num_pages
6
# range of pages that can be used to iterate and create the
# links to the pages in the template
paginator.page_range
range(1, 7)
# returns a Page instance
paginator.page(2)
<Page 2 of 6>
page = paginator.page(2)
type(page)
django.core.paginator.Page
type(paginator)
django.core.paginator.Paginator
如果我们想要访问不存在的页码数据,分页工具Paginator会抛出一个异常:
paginator.page(7)
EmptyPage: That page contains no results
如果我们访问非法页码参数,也同样会抛出下面的异常:
paginator.page('abc')
PageNotAnInteger: That page number is not an integer
在设计我们的UI时,一定要记住这些细节。
现在让我们看一看分页器中的Page类的属性吧:
page = paginator.page(1)
# Check if there is another page after this one
page.has_next()
True
# If there is no previous page, that means this one is the first page
page.has_previous()
False
page.has_other_pages()
True
page.next_page_number()
2
# 这里要注意,因为page没有前一页
# 我们调用`previous_page_number()`方法就会导致异常:
page.previous_page_number()
EmptyPage: That page number is less than 1
基于方法的视图(FBV)的分页实现
下面是FBV实现分页的代码:
-
boards/views.py
(完整文档地址)
from django.db.models import Count
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from django.shortcuts import get_object_or_404, render
from django.views.generic import ListView
from .models import Board
def board_topics(request, pk):
board = get_object_or_404(Board, pk=pk)
queryset = board.topics.order_by('-last_updated').annotate(replies=Count('posts') - 1)
page = request.GET.get('page', 1)
paginator = Paginator(queryset, 20)
try:
topics = paginator.page(page)
except PageNotAnInteger:
# fallback to the first page
topics = paginator.page(1)
except EmptyPage:
# probably the user tried to add a page number
# in the url, so we fallback to the last page
topics = paginator.page(paginator.num_pages)
return render(request, 'topics.html', {'board': board, 'topics': topics})
这里我们用了Bootstrap 4的分页器组件来渲染每一页的页面,看看你能不能理解这里的原理。上面所有的方法都是我们前面使用过的方法,需要注意topics
不再是一个查询结果集QuerySet,而是一个paginator.Page
实例对象。
在这个主题HTML的列表后面,我们可以加一个分页器组件:
-
templates/topics.html
(完整文档地址)
{% if topics.has_other_pages %}
<nav aria-label="Topics pagination" class="mb-4">
<ul class="pagination">
{% if topics.has_previous %}
<li class="page-item">
<a class="page-link" href="?page={{ topics.previous_page_number }}">Previous</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">Previous</span>
</li>
{% endif %}
{% for page_num in topics.paginator.page_range %}
{% if topics.number == page_num %}
<li class="page-item active">
<span class="page-link">
{{ page_num }}
<span class="sr-only">(current)</span>
</span>
</li>
{% else %}
<li class="page-item">
<a class="page-link" href="?page={{ page_num }}">{{ page_num }}</a>
</li>
{% endif %}
{% endfor %}
{% if topics.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ topics.next_page_number }}">Next</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">Next</span>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
分页
GCBV的分页实现
下面是类似的实现方式,不同点在于这里使用了ListView。
-
boards/views.py
(完整文档地址)
class TopicListView(ListView):
model = Topic
context_object_name = 'topics'
template_name = 'topics.html'
paginate_by = 20
def get_context_data(self, **kwargs):
kwargs['board'] = self.board
return super().get_context_data(**kwargs)
def get_queryset(self):
self.board = get_object_or_404(Board, pk=self.kwargs.get('pk'))
queryset = self.board.topics.order_by('-last_updated').annotate(replies=Count('posts') - 1)
return queryset
在CBV里使用分页器时,模板和分页器的交互方式有些许的不同,会用到下面的这些变量:paginator
,page_obj
, is_paginated
,object_list
,以及我们在context_object_name
中定义的变量。这里这个变量的名称就是topics
,它将等同于一个object_list
。
这就是我们在扩展GCBV时添加到请求的context
里需要注意的有关get_context_data
的内容了。
主要的属性就是paginate_by
,这种情况下,添加在这里就可以了。
别忘了更新路由文件urls.py
:
-
myproject/urls.py
(完整文档地址)
<details>
<summary>原始版本</summary>
from django.conf.urls import url
from boards import views
urlpatterns = [
# ...
url(r'^boards/(?P<pk>\d+)/$', views.TopicListView.as_view(), name='board_topics'),
]
</details>
<details open>
<summary>修订版本</summary>
from django.urls import re_path
from boards import views
urlpatterns = [
# ...
re_path(r'^boards/(?P<pk>\d+)/$', views.TopicListView.as_view(), name='board_topics'),
]
</details>
还需要修改一下模板文件:
-
templates/topics.html
(完整文档地址)
{% block content %}
<div class="mb-4">
<a href="{% url 'new_topic' board.pk %}" class="btn btn-primary">New topic</a>
</div>
<table class="table mb-4">
<!-- table content suppressed -->
</table>
{% if is_paginated %}
<nav aria-label="Topics pagination" class="mb-4">
<ul class="pagination">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}">Previous</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">Previous</span>
</li>
{% endif %}
{% for page_num in paginator.page_range %}
{% if page_obj.number == page_num %}
<li class="page-item active">
<span class="page-link">
{{ page_num }}
<span class="sr-only">(current)</span>
</span>
</li>
{% else %}
<li class="page-item">
<a class="page-link" href="?page={{ page_num }}">{{ page_num }}</a>
</li>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}">Next</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">Next</span>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% endblock %}
再花一点时间来调整测试用例。
boards/tests/test_view_board_topics.py
from django.test import TestCase
from django.urls import resolve
from ..views import TopicListView
class BoardTopicsTests(TestCase):
# ...
def test_board_topics_url_resolves_board_topics_view(self):
view = resolve('/boards/1/')
self.assertEquals(view.func.view_class, TopicListView)
可复用的分页模板
就像之前的可复用的表单模板form.html
,我们需要再来创建一个类似的分页HTML模板。
现在让我们来重构主题下的帖子列表页面,尝试把它改为可重用的分页组件。
-
boards/views.py
(完整文档地址)
class PostListView(ListView):
model = Post
context_object_name = 'posts'
template_name = 'topic_posts.html'
paginate_by = 2
def get_context_data(self, **kwargs):
self.topic.views += 1
self.topic.save()
kwargs['topic'] = self.topic
return super().get_context_data(**kwargs)
def get_queryset(self):
self.topic = get_object_or_404(Topic, board__pk=self.kwargs.get('pk'), pk=self.kwargs.get('topic_pk'))
queryset = self.topic.posts.order_by('created_at')
return queryset
更新一下路由文件urls.py
(完整文档地址)
<details>
<summary>原始版本</summary>
from django.conf.urls import url
from boards import views
urlpatterns = [
# ...
url(r'^boards/(?P<pk>\d+)/topics/(?P<topic_pk>\d+)/$', views.PostListView.as_view(), name='topic_posts'),
]
</details>
<details open>
<summary>修订版本</summary>
from django.urls import re_path
from boards import views
urlpatterns = [
# ...
re_path(r'^boards/(?P<pk>\d+)/topics/(?P<topic_pk>\d+)/$', views.PostListView.as_view(), name='topic_posts'),
]
</details>
现在先从模板topics.html
中抽取出分页部分,在目录templates/includes
下创建一个新的文件命名为pagination.html
,和forms.html
放在一起:
myproject/
|-- myproject/
| |-- accounts/
| |-- boards/
| |-- myproject/
| |-- static/
| |-- templates/
| | |-- includes/
| | | |-- form.html
| | | +-- pagination.html <-- 这里!
| | +-- ...
| |-- db.sqlite3
| +-- manage.py
+-- venv/
templates/includes/pagination.html
{% if is_paginated %}
<nav aria-label="Topics pagination" class="mb-4">
<ul class="pagination">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}">Previous</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">Previous</span>
</li>
{% endif %}
{% for page_num in paginator.page_range %}
{% if page_obj.number == page_num %}
<li class="page-item active">
<span class="page-link">
{{ page_num }}
<span class="sr-only">(current)</span>
</span>
</li>
{% else %}
<li class="page-item">
<a class="page-link" href="?page={{ page_num }}">{{ page_num }}</a>
</li>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}">Next</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">Next</span>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
下面是在topic_posts.html
模板中的使用方式:
-
templates/topic_posts.html
(完整文档地址)
{% block content %}
<div class="mb-4">
<a href="{% url 'reply_topic' topic.board.pk topic.pk %}" class="btn btn-primary" role="button">Reply</a>
</div>
{% for post in posts %}
<div class="card {% if forloop.last %}mb-4{% else %}mb-2{% endif %} {% if forloop.first %}border-dark{% endif %}">
{% if forloop.first %}
<div class="card-header text-white bg-dark py-2 px-3">{{ topic.subject }}</div>
{% endif %}
<div class="card-body p-3">
<div class="row">
<div class="col-2">
<img src="{% static 'img/avatar.svg' %}" alt="{{ post.created_by.username }}" class="w-100">
<small>Posts: {{ post.created_by.posts.count }}</small>
</div>
<div class="col-10">
<div class="row mb-3">
<div class="col-6">
<strong class="text-muted">{{ post.created_by.username }}</strong>
</div>
<div class="col-6 text-right">
<small class="text-muted">{{ post.created_at }}</small>
</div>
</div>
{{ post.message }}
{% if post.created_by == user %}
<div class="mt-3">
<a href="{% url 'edit_post' post.topic.board.pk post.topic.pk post.pk %}"
class="btn btn-primary btn-sm"
role="button">Edit</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
{% include 'includes/pagination.html' %}
{% endblock %}
别忘了将主循环修改为{% for post in posts %}
。
同样的,我们也可以在topics.html
中使用这个分页组件:
-
templates/topics.html
(完整文档地址)
{% block content %}
<div class="mb-4">
<a href="{% url 'new_topic' board.pk %}" class="btn btn-primary">New topic</a>
</div>
<table class="table mb-4">
<!-- table code suppressed -->
</table>
{% include 'includes/pagination.html' %}
{% endblock %}
为了测试方便,可以创建一些帖子并且修改每页展示的数量(例如2个),来查看它是否能正常工作:
主题列表更新测试用例:
boards/tests/test_view_topic_posts.py
from django.test import TestCase
from django.urls import resolve
from ..views import PostListView
class TopicPostsTests(TestCase):
# ...
def test_view_function(self):
view = resolve('/boards/1/topics/1/')
self.assertEquals(view.func.view_class, PostListView)
我的个人资料页面
好了,终于来到教程的最后一个页面,在这之后我们将主要对已开发的功能和页面进行优化处理。
-
accounts/views.py
(完整文档地址)
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User
from django.urls import reverse_lazy
from django.utils.decorators import method_decorator
from django.views.generic import UpdateView
@method_decorator(login_required, name='dispatch')
class UserUpdateView(UpdateView):
model = User
fields = ('first_name', 'last_name', 'email', )
template_name = 'my_account.html'
success_url = reverse_lazy('my_account')
def get_object(self):
return self.request.user
-
myproject/urls.py
(完整文档地址)
<details>
<summary>原始版本</summary>
from django.conf.urls import url
from accounts import views as accounts_views
urlpatterns = [
# ...
url(r'^settings/account/$', accounts_views.UserUpdateView.as_view(), name='my_account'),
]
</details>
<details open>
<summary>修订版本</summary>
from django.urls import re_path
from accounts import views as accounts_views
urlpatterns = [
# ...
re_path(r'^settings/account/$', accounts_views.UserUpdateView.as_view(), name='my_account'),
]
</details>
templates/my_account.html
{% extends 'base.html' %}
{% block title %}My account{% endblock %}
{% block breadcrumb %}
<li class="breadcrumb-item active">My account</li>
{% endblock %}
{% block content %}
<div class="row">
<div class="col-lg-6 col-md-8 col-sm-10">
<form method="post" novalidate>
{% csrf_token %}
{% include 'includes/form.html' %}
<button type="submit" class="btn btn-success">Save changes</button>
</form>
</div>
</div>
{% endblock %}
我的个人资料
添加对Markdown的支持
现在让我们来优化一下文本的用户体验,添加对Markdown的支持,DJango里非常简单。
首先下载并安装Python-Markdown:
pip install markdown
我们可以给Post类添加一个方法:
-
boards/models.py
(完整文档地址)
from django.db import models
from django.utils.html import mark_safe
from markdown import markdown
class Post(models.Model):
# ...
def get_message_as_markdown(self):
return mark_safe(markdown(self.message, safe_mode='escape'))
现在正在处理用户的输入部分,必须要小心一点。在使用markdown
方法时,首先需要转义特殊字符,然后解析markdown标签,最后我们才可以安全地使用最后输出的字符串。
topic_posts.html
和reply_topic.html
里就需要将:
{{ post.message }}
改为:
{{ post.get_message_as_markdown }}
现在用户就可以使用markdown来编辑帖子啦:
Markdown MarkdownMarkdown编辑器
我们也可以使用一个酷炫的Markdown
编辑器组件SimpleMD。
可以选择下载JavaScript库文件或直接使用他们的CDN:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.css">
<script src="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.js"></script>
修改一下base.html
来预留JavaScript的位置:
-
templates/base.html
(完整文档地址)
<script src="{% static 'js/jquery-3.2.1.min.js' %}"></script>
<script src="{% static 'js/popper.min.js' %}"></script>
<script src="{% static 'js/bootstrap.min.js' %}"></script>
{% block javascript %}{% endblock %} <!-- 这个位置为JavaScript预留 -->
首先修改reply_topic.html
模板文件:
-
templates/reply_topic.html
(完整文档地址)
{% extends 'base.html' %}
{% load static %}
{% block title %}Post a reply{% endblock %}
{% block stylesheet %}
<link rel="stylesheet" href="{% static 'css/simplemde.min.css' %}">
{% endblock %}
{% block javascript %}
<script src="{% static 'js/simplemde.min.js' %}"></script>
<script>
var simplemde = new SimpleMDE();
</script>
{% endblock %}
这个插件会默认将第一个文本编辑框修改为Markdown编辑器,所以这样就可以了:
同样的方式修改一下edit_post.html
:
-
templates/edit_post.html
(完整文档地址)
{% extends 'base.html' %}
{% load static %}
{% block title %}Edit post{% endblock %}
{% block stylesheet %}
<link rel="stylesheet" href="{% static 'css/simplemde.min.css' %}">
{% endblock %}
{% block javascript %}
<script src="{% static 'js/simplemde.min.js' %}"></script>
<script>
var simplemde = new SimpleMDE();
</script>
{% endblock %}
人性化(Humanize)
我建议使用DJango内置包humanize,可以将数据转化为更加人性化的格式。
举个例子,我们可以用它来更自然地显示日期和时间,如2020-8-20 11:30:22
这样的格式变为2分钟前
。
我们开始吧,首先将django.contrib.humanize
加到INSTALLED_APPS
里:
myproject/settings.py
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.humanize', # <- 这里
'widget_tweaks',
'accounts',
'boards',
]
好了,现在修改一下模板topics.html
:
-
templates/topics.html
(完整文档地址)
{% extends 'base.html' %}
{% load humanize %}
{% block content %}
<!-- code suppressed -->
<td>{{ topic.last_updated|naturaltime }}</td>
<!-- code suppressed -->
{% endblock %}
这里需要做的就是添加{% load humanize %}
,然后在使用的地方增加过滤器{{ topic.last_updated|naturaltime }}
:
你也可以在其他地方试一试。
Gravatar
通过使用Gravatar,我们可以非常简单的处理用户头像。
在目录boards/templatetags
下,创建一个新文件gravatar.py
:
boards/templatetags/gravatar.py
import hashlib
from urllib.parse import urlencode
from django import template
from django.conf import settings
register = template.Library()
@register.filter
def gravatar(user):
email = user.email.lower().encode('utf-8')
default = 'mm'
size = 256
url = 'https://www.gravatar.com/avatar/{md5}?{params}'.format(
md5=hashlib.md5(email).hexdigest(),
params=urlencode({'d': default, 's': str(size)})
)
return url
基本上我是直接使用他们提供的原始代码,只是适配了一下Python 3。
好了,现在可以在模板里使用它了,就像我们使用人性化模板过滤器一样:
-
templates/topic_posts.html
(完整文档地址)
{% extends 'base.html' %}
{% load gravatar %}
{% block content %}
<!-- code suppressed -->
<img src="{{ post.created_by|gravatar }}" alt="{{ post.created_by.username }}" class="w-100 rounded">
<!-- code suppressed -->
{% endblock %}
Gravatar
最后的调整
你可能已经注意到了,在用户回复帖子这里有个小问题,没有同步更新last_update
字段,所以会导致主题列表的排序出现混乱。
让我们解决它:
boards/views.py
@login_required
def reply_topic(request, pk, topic_pk):
topic = get_object_or_404(Topic, board__pk=pk, pk=topic_pk)
if request.method == 'POST':
form = PostForm(request.POST)
if form.is_valid():
post = form.save(commit=False)
post.topic = topic
post.created_by = request.user
post.save()
topic.last_updated = timezone.now() # <- here
topic.save() # <- and here
return redirect('topic_posts', pk=pk, topic_pk=topic_pk)
else:
form = PostForm()
return render(request, 'reply_topic.html', {'topic': topic, 'form': form})
接下来需要优化一下查看计数,忽略掉用户刷新时造成的多次查看计数的增加,这里我们可以使用会话sessions
:
boards/views.py
class PostListView(ListView):
model = Post
context_object_name = 'posts'
template_name = 'topic_posts.html'
paginate_by = 20
def get_context_data(self, **kwargs):
session_key = 'viewed_topic_{}'.format(self.topic.pk) # <-- 这里开始
if not self.request.session.get(session_key, False):
self.topic.views += 1
self.topic.save()
self.request.session[session_key] = True # <-- 到这里
kwargs['topic'] = self.topic
return super().get_context_data(**kwargs)
def get_queryset(self):
self.topic = get_object_or_404(Topic, board__pk=self.kwargs.get('pk'), pk=self.kwargs.get('topic_pk'))
queryset = self.topic.posts.order_by('created_at')
return queryset
目前用户只能通过点击主题标题返回第一页,我们可以在主题列表里提供一个更友好的导航器:
boards/models.py
import math
from django.db import models
class Topic(models.Model):
# ...
def __str__(self):
return self.subject
def get_page_count(self):
count = self.posts.count()
pages = count / 20
return math.ceil(pages)
def has_many_pages(self, count=None):
if count is None:
count = self.get_page_count()
return count > 6
def get_page_range(self):
count = self.get_page_count()
if self.has_many_pages(count):
return range(1, 5)
return range(1, count + 1)
然后在模板topics.html
中实现代码:
templates/topics.html
<table class="table table-striped mb-4">
<thead class="thead-inverse">
<tr>
<th>Topic</th>
<th>Starter</th>
<th>Replies</th>
<th>Views</th>
<th>Last Update</th>
</tr>
</thead>
<tbody>
{% for topic in topics %}
{% url 'topic_posts' board.pk topic.pk as topic_url %}
<tr>
<td>
<p class="mb-0">
<a href="{{ topic_url }}">{{ topic.subject }}</a>
</p>
<small class="text-muted">
Pages:
{% for i in topic.get_page_range %}
<a href="{{ topic_url }}?page={{ i }}">{{ i }}</a>
{% endfor %}
{% if topic.has_many_pages %}
... <a href="{{ topic_url }}?page={{ topic.get_page_count }}">Last Page</a>
{% endif %}
</small>
</td>
<td class="align-middle">{{ topic.starter.username }}</td>
<td class="align-middle">{{ topic.replies }}</td>
<td class="align-middle">{{ topic.views }}</td>
<td class="align-middle">{{ topic.last_updated|naturaltime }}</td>
</tr>
{% endfor %}
</tbody>
</table>
就像每个主题下的迷你分页器,注意这里使用了table-striped
来优化表格样式。
在回复页面上,目前是列举出了所有的主题回复,这里最好限制为仅列举最新的10条。
boards/models.py
class Topic(models.Model):
# ...
def get_last_ten_posts(self):
return self.posts.order_by('-created_at')[:10]
templates/reply_topic.html
{% block content %}
<form method="post" class="mb-4" novalidate>
{% csrf_token %}
{% include 'includes/form.html' %}
<button type="submit" class="btn btn-success">Post a reply</button>
</form>
{% for post in topic.get_last_ten_posts %} <!-- here! -->
<div class="card mb-2">
<!-- code suppressed -->
</div>
{% endfor %}
{% endblock %}
还有当用户回复帖子成功后,会被重定向到第一个分页,这里可以优化一下,重定向到最后一页,用户刚发的帖子的页面。
我们可以给每一页添加一个id
:
templates/topic_posts.html
{% block content %}
<div class="mb-4">
<a href="{% url 'reply_topic' topic.board.pk topic.pk %}" class="btn btn-primary" role="button">Reply</a>
</div>
{% for post in posts %}
<div id="{{ post.pk }}" class="card {% if forloop.last %}mb-4{% else %}mb-2{% endif %} {% if forloop.first %}border-dark{% endif %}">
<!-- code suppressed -->
</div>
{% endfor %}
{% include 'includes/pagination.html' %}
{% endblock %}
这里最重要的代码是<div id="{{ post.pk }}" ...>
。
在修改一下页面方法文件:
boards/views.py
@login_required
def reply_topic(request, pk, topic_pk):
topic = get_object_or_404(Topic, board__pk=pk, pk=topic_pk)
if request.method == 'POST':
form = PostForm(request.POST)
if form.is_valid():
post = form.save(commit=False)
post.topic = topic
post.created_by = request.user
post.save()
topic.last_updated = timezone.now()
topic.save()
topic_url = reverse('topic_posts', kwargs={'pk': pk, 'topic_pk': topic_pk})
topic_post_url = '{url}?page={page}#{id}'.format(
url=topic_url,
id=post.pk,
page=topic.get_page_count()
)
return redirect(topic_post_url)
else:
form = PostForm()
return render(request, 'reply_topic.html', {'topic': topic, 'form': form})
在topic_post_url
这一行代码我们用最后一个页面创建了一个URL,并给id等于这个帖子ID的那条数据添加了一个锚点。
这样就需要修改一下下面的测试用例:
boards/tests/test_view_reply_topic.py
class SuccessfulReplyTopicTests(ReplyTopicTestCase):
# ...
def test_redirection(self):
'''
A valid form submission should redirect the user
'''
url = reverse('topic_posts', kwargs={'pk': self.board.pk, 'topic_pk': self.topic.pk})
topic_posts_url = '{url}?page=1#2'.format(url=url)
self.assertRedirects(self.response, topic_posts_url)
从上图可以看到,下面的分页控件在页码数过多时会显示异常,最简单的方式是直接修改分页组件模板pagination.html
:
templates/includes/pagination.html
{% if is_paginated %}
<nav aria-label="Topics pagination" class="mb-4">
<ul class="pagination">
{% if page_obj.number > 1 %}
<li class="page-item">
<a class="page-link" href="?page=1">First</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">First</span>
</li>
{% endif %}
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}">Previous</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">Previous</span>
</li>
{% endif %}
{% for page_num in paginator.page_range %}
{% if page_obj.number == page_num %}
<li class="page-item active">
<span class="page-link">
{{ page_num }}
<span class="sr-only">(current)</span>
</span>
</li>
{% elif page_num > page_obj.number|add:'-3' and page_num < page_obj.number|add:'3' %}
<li class="page-item">
<a class="page-link" href="?page={{ page_num }}">{{ page_num }}</a>
</li>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}">Next</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">Next</span>
</li>
{% endif %}
{% if page_obj.number != paginator.num_pages %}
<li class="page-item">
<a class="page-link" href="?page={{ paginator.num_pages }}">Last</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">Last</span>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
小结
通过本教程,我们完成了Django论坛应用程序的实现。我可能会发布一个后续的实现教程来改进代码,这样我们就可以一起探索很多事情。例如,数据库优化、改进用户界面、处理文件上载、创建一个调度系统等等。
下一个教程将重点介绍部署。这将是一个完整的指南,教你如何将你的代码投入生产,同时处理所有重要的细节。
项目的源代码可以在GitHub上找到。项目的当前状态可以在发布标签v0.6-lw下找到。下面的链接将带您找到正确的位置:
https://github.com/sibtc/django-beginners-guide/tree/v0.6-lw
网友评论