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

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-部署发布(译&改)

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335