美文网首页
Django初学者入门指南6-基于类的页面(译&改)

Django初学者入门指南6-基于类的页面(译&改)

作者: 心甘情愿_Root | 来源:发表于2020-09-29 14:47 被阅读0次

    Django初学者入门指南1-初识(译&改)

    Django初学者入门指南2-基础知识(译&改)

    Django初学者入门指南3-高级概念(译&改)

    Django初学者入门指南4-登录认证(译&改)

    Django初学者入门指南5-存储数据(译&改)

    Django初学者入门指南6-基于类的页面(译&改)

    Django初学者入门指南7-部署发布(译&改)

    [>>原文地址][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来实现帖子编辑页面:

    编辑帖子
    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)
    

    使用UpdateViewCreateView时,我们可以选择定义form_classfields属性。在上面的例子中,我们使用fields属性动态创建一个模型表单。Django底层将使用一个模型表单工厂来组成Post模型的表单。因为它是一个非常简单的表单,只有message字段,所以我们可以像这样编写。但是对于复杂的表单定义,最好在外部定义一个模型表单并在这里引用它。

    pk_url_kwarg将用于标识用于检索Post对象的关键字参数的名称,和我们在urls.py定义的相同。

    如果不设置context_object_name属性,Post对象将在模板中被识别为“object”使用。因此,这里我们使用context_object_name将其重命名为post,下面的模板中可以看到我们是如何使用它的。

    在这个示例中,我们必须重写form_valid()方法,设置一些额外的字段,例如updated_byupdated_at。您可以在这里看到基form_valid()方法的外观:UpdateView#form_valid

    <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>

    把这个链接添加到编辑帖子页面:

    {% 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 %}
    
    {% 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_namepost,这里就只能通过object.topic.board.pk进行调用。

    编辑帖子
    测试更新页面

    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里使用这个装饰器需要进行一点改动:

    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

    @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来对模型列表进行重构:

    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的路由:

    <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,我们得改一改它的测试用例:

    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版块数据可能不太需要进行分页,毕竟一般来说也不会有太多的版块。不过TopicPost的数据肯定是需要进行分页的。

    现在让我们切换到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实现分页的代码:

    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的列表后面,我们可以加一个分页器组件:

    {% 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

    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里使用分页器时,模板和分页器的交互方式有些许的不同,会用到下面的这些变量:paginatorpage_objis_paginatedobject_list,以及我们在context_object_name中定义的变量。这里这个变量的名称就是topics,它将等同于一个object_list

    这就是我们在扩展GCBV时添加到请求的context里需要注意的有关get_context_data的内容了。

    主要的属性就是paginate_by,这种情况下,添加在这里就可以了。

    别忘了更新路由文件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>

    还需要修改一下模板文件:

    {% 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模板。

    现在让我们来重构主题下的帖子列表页面,尝试把它改为可重用的分页组件。

    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模板中的使用方式:

    {% 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中使用这个分页组件:

    {% 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)
    

    我的个人资料页面

    好了,终于来到教程的最后一个页面,在这之后我们将主要对已开发的功能和页面进行优化处理。

    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
    

    <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类添加一个方法:

    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.htmlreply_topic.html里就需要将:

    {{ post.message }}
    

    改为:

    {{ post.get_message_as_markdown }}
    

    现在用户就可以使用markdown来编辑帖子啦:

    Markdown Markdown
    Markdown编辑器

    我们也可以使用一个酷炫的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的位置:

        <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模板文件:

    {% 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

    {% 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

    {% 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。

    好了,现在可以在模板里使用它了,就像我们使用人性化模板过滤器一样:

    {% 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

    上一节:Django初学者入门指南5-存储数据(译&改)

    下一节:Django初学者入门指南7-部署发布(译&改)

    相关文章

      网友评论

          本文标题:Django初学者入门指南6-基于类的页面(译&改)

          本文链接:https://www.haomeiwen.com/subject/uxowyktx.html