Django个人博客系统(6-10)

在上篇中,我们已经学会了Django的一些基本操作,本篇在其基础上进一步完善。

6.登录注册与重置密码

用户的登录注册是大部分网站的基本功能,而Django非常贴心地内置了用户管理模型——User,利用这个内置模型可以满足绝大多数网站的需求,但是这里由于需要用到用户头像等User中没有的字段,因此我们将用自定义的用户模型UserProfile来覆盖User
首先新建一个userprofile的应用:

python manage.py startapp userprofile

然后在settings.py文件的INSTALLED_APPS中添加应用的名称:

INSTALLED_APPS = [
     ...原内容省略...
     'userprofile',
]

最后将其路由添加到项目的urls.py中:

urlpatterns = [
    ...原内容省略...
    path('userprofile/', include('userprofile.urls', namespace='userprofile')),
]

以上就是注册一个app的基本流程。接下来我们在userprofile应用中新建一个用户模型UserProfile

class UserProfile(AbstractUser):
    avatar = models.ImageField(upload_to="avatar/%Y%m%d/", default="avatar/20210705/default.png", blank=True)

    class Meta:
        ordering = ['id']

    def __str__(self):
        return self.username

我们自定义的用户模型继承自AbstractUser,事实上Django提供的User也是继承自AbstractUser。而AbstractUser还有一个父类AbstractBaseUser,区别在于前者已经定义了很多字段、实现了登录登出等基本功能,也就是说其实AbstractBaseUser才是真正的"抽象类"。因此,我们自定义的UserProfile其实已经继承了很多基本字段,我们只需添加头像字段即可。
而头像字段使用到了ImageField字段类型,在执行makemigrations前需要安装依赖包:pillow。在Pycharm的Terminal终端窗口执行安装命令:

pip install pillow

而想要真正使用自定义的认证模型UserProfile,还需要在setting.py中添加下面内容,才能替换默认的User模型。

AUTH_USER_MODEL = 'userprofile.UserProfile'

最后执行如下命令来生成数据表:

python manage.py makemigrations
python manage.py migrate

注意:使用这种方式创建自定义用户模型时,如果之前创建过用户或相应的数据表,在执行数据库迁移命令之前需要清空原数据,否则会报错。具体做法是删除所有应用下的migrations文件夹下除__init__.py外的所有文件,而博主在踩过坑后发现还需要删除db.sqlite3才能彻底清空原数据,一定要在删除干净后再执行数据库迁移命令!


模型创建成功后,接下来开始真正实现用户的登录与注册。
首先创建表单:

class LoginForm(forms.Form):
    username = forms.CharField()
    password = forms.CharField()

用户登录不需要对数据库进行任何改动,因此直接继承forms.Form就可以了。forms.Form需要手动配置每个字段,它适用于不与数据库进行直接交互的功能。
然后创建视图与模板:

def user_login(request):
    if request.method == "GET":
        login_form = LoginForm()
        context = {"login_form": login_form}
        return render(request, "userprofile/login.html", context)
    else:
        login_form = LoginForm(data=request.POST)
        if login_form.is_valid():
            data = login_form.cleaned_data
            user = authenticate(username=data['username'], password=data['password'])
            if user:
                login(request, user)
                return redirect("article:article-list")
            else:
                return HttpResponse('账号密码输入有误,请重新输入!')
        else:
            context = {'obj': login_form, 'error': login_form.errors}
            return render(request, 'userprofile/login.html', context)
  • Form对象的主要任务就是验证数据,is_valid()Form实例的一个方法,用来做字段验证,当输入字段值合法时,它将返回True,同时将表单的数据存放到cleaned_data属性中。
  • authenticate()方法验证用户名称和密码是否匹配,如果是,则将这个用户数据返回。
  • login()方法实现用户登录,将用户数据保存在session中。

注意:调用login()之前必须调用authenticate()成功认证登录用户。
之所以用这么几行代码就实现了用户登录功能,是因为我们自定义的用户模型继承自AbstractUser,所以在功能上其实和Django内置的User是一样的。
模板文件的核心代码如下:

<form class='p-5' action="." method="post">
     {% csrf_token %}
    <span class="text-danger">{{ error }}</span>
    <div class="mb-3">
        <label for="username" class="form-label">账号</label>
        <input type="text" class="form-control" id="username" name="username">
    </div>
    <div class="mb-3">
        <label for="password" class="form-label">密码</label>
        <input type="password" class="form-control" id="password" name="password">
    </div>
    <div class="pt-3 pb-5">
        <button type="submit" class="btn btn-primary float-start mb-5">立即登录</button>
        <a class="text-decoration-none float-end text-danger mb-5 py-2" href="{% url 'userprofile:register' %}">没有账号?立即注册</a>
    </div>
</form>

最后在urls.py文件中加入该视图的路由即可:

urlpatterns = [
    path('login/', user_login, name='login'),
]

登陆页面的最终效果如下图所示:

我们在header.html文件中加入登录的链接:

<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
    <div class="container">
        ...原内容省略...
        <div class="collapse navbar-collapse justify-content-end">
            <ul class="navbar-nav">
                {% if user.is_authenticated %}
                    <li class="nav-item dropdown">
                        <a class="nav-link dropdown-toggle p-0" href="#" id="navbarDropdownMenuLink" role="button"
                           data-bs-toggle="dropdown" aria-expanded="false">
                            <img src="{{ user.avatar.url }}" class="rounded-circle" style=" 40px;height: 40px;">
                        </a>
                        <ul class="dropdown-menu" aria-labelledby="navbarDropdownMenuLink">
                            <li><a class="dropdown-item" href="{% url 'userprofile:profile' %}"><i class="bi bi-person-fill"></i> 个人中心</a></li>
                            <li><a class="dropdown-item" href="{% url 'userprofile:logout' %}"><i class="bi bi-power"></i> 退出登录</a></li>
                        </ul>
                    </li>
                {% else %}
                    <li class="nav-item">
                        <a class="nav-link active" aria-current="page" href="{% url 'userprofile:login' %}">登录</a>
                    </li>
                {% endif %}
            </ul>
        </div>
    </div>
</nav>

user.is_authenticated用来判断用户是否登录,如果登录了则显示用户头像,并用下拉框显示其他功能,如果没有则显示登录链接。
登出功能的实现非常简单,只需定义视图:

def user_logout(request):
    logout(request)
    return redirect("article:article-list")

然后添加路由即可:

urlpatterns = [
    path('logout/', user_logout, name='logout'),
]

注册功能的实现方法其实和登录功能差不多,整体流程都是先写表单,然后写视图和模板,最后添加路由。
注册表单如下:

class RegisterForm(forms.ModelForm):
    password = forms.CharField()
    password2 = forms.CharField()

    class Meta:
        model = UserProfile
        fields = ('username', 'email')

    def clean_password2(self):
        data = self.cleaned_data
        if data.get('password') == data.get('password2'):
            return data.get('password')
        else:
            return forms.ValidationError('两次输入的密码不一致,请重新输入!')

注册表单需要对数据库进行操作,因此应该继承forms.ModelForm,可以自动生成模型中已有的字段。
这里我们覆写了password字段,因为通常在注册时需要重复输入password来确保用户没有将密码输入错误,所以覆写掉它以便我们自己进行数据的验证工作。def clean_password2()中的内容便是在验证密码是否一致了。def clean_[字段]这种写法Django会自动调用,来对单个字段的数据进行验证清洗。
覆写某字段之后,内部类class Meta中的定义对这个字段就没有效果了,所以fields不用包含password
需要注意:

  • 验证密码一致性方法不能写def clean_password(),因为如果你不定义def clean_password2()方法,会导致password2中的数据被Django判定为无效数据从而清洗掉,从而password2属性不存在。最终导致两次密码输入始终会不一致,并且很难判断出错误原因。
  • POST中取值用的data.get('password')是一种稳妥的写法,即使用户没有输入密码也不会导致程序错误而跳出。前面章节提取POST数据我们用了data['password'],这种取值方式如果data中不包含password,Django会报错。另一种防止用户不输入密码就提交的方式是在表单中插入required属性。

注册的视图函数如下:

def user_register(request):
    if request.method == 'GET':
        register_form = RegisterForm()
        context = {'register_form': register_form}
        return render(request, 'userprofile/register.html', context)
    else:
        register_form = RegisterForm(data=request.POST)
        if register_form.is_valid():
            new_user = register_form.save(commit=False)
            new_user.set_password(register_form.cleaned_data['password'])
            new_user.save()
            return redirect("userprofile:login")
        else:
            context = {'obj': register_form, 'error': register_form.errors}
            return render(request, 'userprofile/register.html', context)

注册的模板核心如下:

<form class='p-5' action="." method="post">
    {% csrf_token %}
    <span class="text-danger">{{ error }}</span>
    <div class="input-group mb-4">
        <span class="input-group-text" id="basic-addon1"><i class="bi bi-person"></i></span>
        <input type="text" class="form-control" placeholder="用户名" name="username"
               aria-describedby="basic-addon1" required="required">
    </div>
    <div class="input-group mb-4">
        <span class="input-group-text" id="basic-addon1"><i class="bi bi-envelope"></i></span>
        <input type="email" class="form-control" placeholder="邮箱" name="email"
               aria-describedby="basic-addon1" required="required">
    </div>
    <div class="input-group mb-4">
        <span class="input-group-text" id="basic-addon1"><i class="bi bi-key"></i></span>
        <input type="password" class="form-control" placeholder="密码" name="password"
               aria-describedby="basic-addon1" required="required">
    </div>
    <div class="input-group mb-4">
        <span class="input-group-text" id="basic-addon1"><i class="bi bi-key"></i></span>
        <input type="password" class="form-control" placeholder="确认密码" name="password2"
               aria-describedby="basic-addon1" required="required">
    </div>
    <div class="d-grid gap-3">
        <button type="submit" class="btn btn-primary">立即注册</button>
    </div>
    <div class="mt-3 row">
        <div class="col-6 justify-content-start"></div>
        <div class="col-6 justify-content-end">
            <a class="text-decoration-none float-end text-secondary" href="{% url 'userprofile:login' %}">已有账号?</a>
        </div>
    </div>
</form>

最后将注册的路由添加到urls.py中即可。最终效果如下:


忘记密码是很多用户经常遇到的问题,因此很多网站都会在登陆页面添加一个找回密码的功能,我们这里也实现通过邮件来找回密码的功能。Django内置其实已经实现了通过邮件来找回密码的功能,其主要步骤如下:

  • 向用户邮箱发送包含重置密码地址的邮件。邮件的地址需要动态生成,防止不怀好意的用户从中捣乱;
  • 向网站用户展示一条发送邮件成功的信息;
  • 用户点击邮箱中的地址后,转入重置密码的页面;
  • 向用户展示一条重置成功的信息。

其上四个流程分别由PasswordResetViewPasswordResetDoneViewPasswordResetConfirmViewPasswordResetCompleteView四个视图完成,因此我们要做的其实就是为它们配置路由罢了。在项目的urls.py中添加如下内容:

urlpatterns = [
    ...原内容省略...
    path('password_reset/', PasswordResetView.as_view(template_name='userprofile/password_reset_form.html',
                                                      email_template_name='userprofile/password_reset_email.html',),
         name='password_reset'),
    path('password_reset_done/', PasswordResetDoneView.as_view(template_name='userprofile/password_reset_done.html'),
         name='password_reset_done'),
    path('reset/<uidb64>/<token>/',
         PasswordResetConfirmView.as_view(template_name='userprofile/password_reset_confirm.html'),
         name='password_reset_confirm'),
    path('password_reset_complete/',
         PasswordResetCompleteView.as_view(template_name='userprofile/password_reset_complete.html'),
         name='password_reset_complete'),
]

为什么要在每个视图后都跟着as_view()?这是因为它们都是基于类的视图,也就是说它们的本质是class,而括号内的template_nameemail_template_name其实都是传递给class的参数,具体可查看源码。事实上每一个视图对应的模板其实都有自带的(查看路径venv/Lib/site-packages/django/contrib/admin/templates/registration/,我们自定义的模板其实是根据自带模板改编的),也就是说配置完路由其实这个功能就已经实现了。我们之所以要自己编写模板,其实是为了和自己网站的风格相适应,而且最不可忍受的是自带模板竟然还有Django标志。我们自己编写的模板放在templateuserprofile文件夹下,每个模板的内容如下:
password_reset_form.html

{% load static %}
<!DOCTYPE html>
<html lang="zh-cn">
<head>
    <meta charset="UTF-8">
    <title>找回密码</title>
    <link rel="stylesheet" href="{% static 'bootstrap/css/bootstrap.min.css' %}">
    <link rel="icon" href="{% static 'img/logo.png' %}">
</head>
<body>
    <div class="container m-5">
        <p>忘记密码?在下面输入你的电子邮箱地址,我们将会把设置新密码的操作步骤说明通过电子邮件发送给你。</p>
        <form method="post">
            {% csrf_token %}
            <fieldset>
                <div class="pb-3 mb-3 border-bottom">
                    {{ form.email.errors }}
                    <label for="id_email">电子邮件地址:</label>
                    {{ form.email }}
                </div>
                <input class="btn btn-primary" type="submit" value="重设我的密码">
            </fieldset>
        </form>
    </div>
</body>
</html>

password_reset_email.html

{% autoescape off %}
您收到这封邮件是因为您在请求重置您在网站{{ site_name }}上的用户帐户密码。

请访问该页面并设置一个新密码:
{% block reset_link %}
{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %}
{% endblock %}
提醒一下,您的用户名是:{{ user.get_username }}

感谢您使用我们的网站

{{ site_name }}团队

{% endautoescape %}

password_reset_done.html

{% load static %}
<!DOCTYPE html>
<html lang="zh-cn">
<head>
    <meta charset="UTF-8">
    <title>找回密码</title>
    <link rel="stylesheet" href="{% static 'bootstrap/css/bootstrap.min.css' %}">
    <link rel="icon" href="{% static 'img/logo.png' %}">
</head>
<body>
<div class="container m-5 text-center">
<p>如果你所输入的电子邮箱存在对应的用户,我们将通过电子邮件向你发送设置密码的操作步骤说明。你应该很快就会收到。</p>

<p>如果你没有收到电子邮件,请检查输入的是你注册的电子邮箱地址。另外,也请检查你的垃圾邮件文件夹。</p>
</div>
</body>
</html>

password_reset_confirm.html

{% load static %}
<!DOCTYPE html>
<html lang="zh-cn">
<head>
    <meta charset="UTF-8">
    <title>找回密码</title>
    <link rel="stylesheet" href="{% static 'bootstrap/css/bootstrap.min.css' %}">
    <link rel="icon" href="{% static 'img/logo.png' %}">
</head>
<body>
<div class="container">
    <p class="text-center mt-5">请输入新密码两次,以便我们验证您键入的密码是否正确。</p>
    <div class="col-md-4 col-sm-6 border offset-md-4 offset-sm-6 p-5 mt-5 bg-light">
        {% if validlink %}
            <form method="post">{% csrf_token %}
                <fieldset>
                    <input class="visually-hidden" autocomplete="username" value="{{ form.user.get_username }}">
                    <div class="mb-3">
                        {{ form.new_password1.errors }}
                        <label class="form-label" for="id_new_password1">新密码:</label>
                        {{ form.new_password1 }}
                    </div>
                    <div class="mb-3">
                        {{ form.new_password2.errors }}
                        <label class="form-label" for="id_new_password2">确认密码:</label>
                        {{ form.new_password2 }}
                    </div>
                    <div class="d-grid">
                        <input class="btn btn-primary mt-3" type="submit" value="重置密码">
                    </div>
                </fieldset>
            </form>

        {% else %}

            <p>密码重置链接无效,可能是因为它已被使用。请重新设置密码。</p>

        {% endif %}
    </div>
</div>
<script src="https://cdn.bootcss.com/jquery/3.3.1/jquery.min.js"></script>
<script>
    $('#id_new_password1').addClass('form-control');
    $('#id_new_password2').addClass('form-control');
</script>
</body>
</html>

password_reset_complete.html

{% load static %}
<!DOCTYPE html>
<html lang="zh-cn">
<head>
    <meta charset="UTF-8">
    <title>找回密码</title>
    <link rel="stylesheet" href="{% static 'bootstrap/css/bootstrap.min.css' %}">
    <link rel="icon" href="{% static 'img/logo.png' %}">
</head>
<body>
<div class="container m-5">
<p>你的密码己经重置完成,现在你可以继续进行登录。</p>

<p><a href="{% url 'userprofile:login' %}">登录</a></p>

</div>
</body>
</html>

至此,密码找回功能就算基本完成了。
注意:如果要使用Django内置的通过邮箱来找回密码的功能(如上文),则路由配置一定要写在项目目录的urls.py中,而模板文件则没有要求。如果路由写在app中则会报错,博主踩过这个坑并且试了很多办法都没解决(本来想放在userprofile中,最后屈服了)。因此,切记路由要放在项目目录下,则基本没有什么问题,只要改改模板文件就可以了。


7.文章增改与个人中心

在上一篇中,我们已经实现了文章列表和详情页面,但当时我们调试用的数据是直接从后台输入的,因此本节我们继续完善文章的创作、修改、删除等功能。
我们首先增加一个文章创作功能。到目前为止,想必我们对于增加功能的流程已经非常熟悉了,其实就是编写视图和模板(根据情况,有时需要编写表单和创建模型。一般来说,需要和数据库交互的都需要通过表单),然后添加路由就可以了。
文章创作视图函数如下:

@login_required(login_url='userprofile:login')
def article_create(request):
    if request.method == "GET":
        article_post_form = ArticlePostForm()
        context = {"article_post_form": article_post_form}
        return render(request, "article/create.html", context)
    else:
        article_post_form = ArticlePostForm(data=request.POST)
        if article_post_form.is_valid():
            new_article = article_post_form.save(commit=False)
            new_article.author = request.user
            new_article.save()
            return redirect("article:article-list")
        else:
            return HttpResponse("表单填写有误,请重新填写!")

首先,我们对于文章创作要求用户必须登录,参数login_url指明了登录链接,当用户未登录时会自动跳转到登录页面。其次,当文章发布成功后,我们将重定向到首页,即文章列表页面,展示在第一位的就是刚刚发布的文章,这是因为我们创建模型时定义的排序方式是按照创建时间倒序排列。
文章创作模板如下:

{% extends "base.html" %}
{% block title %}创作{% endblock %}
{% block content %}
    <div class="container">
        <form method="post" action="." class="mt-4">
            {% csrf_token %}
            <div class="mb-3">
                <label for="title" class="form-label">文章标题</label>
                <input type="text" class="form-control" id="title" name="title">
            </div>
            <div class="mb-3">
                <label for="body" class="form-label">文章正文</label>
                <textarea type="text" class="form-control" rows="12" id="body" name="body"></textarea>
            </div>
            <button type="submit" class="btn btn-primary">发布</button>
        </form>
    </div>
{% endblock %}

然后添加路由就实现了文章创作功能。
接下来我们实现文章修改、删除功能。我们可以在现有的文章详情页面添加文章修改、删除功能,我们先看修改后的文章详情模板文件:

<div class="pt-4">
    <h1>{{ article.title }}</h1>
    <small class="text-secondary"><i class="bi bi-person"></i> 作者 {{ article.author }}</small>
    <small class="text-secondary mx-4"><i class="bi bi-clock"></i> 发表于 {{ article.created }}</small>
    {% if article.author.username == user.username%}
    <a href="#" class="text-decoration-none float-end text-danger ms-3"
       onclick="if(confirm('确定要删除这篇文章吗?')) location.href='{% url "article:article-delete" article.id %}'">删除</a>
    <a href="{% url "article:article-update" article.id %}" class="text-decoration-none float-end">修改</a>
    {% endif %}
</div>
<div class="mt-2 border-top py-2">
    {{ article.body|safe }}
</div>

可以看到我们在模板中用if语句添加了几行代码,在if语句中我们判断的是文章作者与当前用户的username是否一致,从而决定用户是否有权修改这篇文章。只有当用户就是作者本人时,删除和修改链接才会显示出来。当删除文章时,我们会弹出一个确认框以提醒用户是否确认删除文章,从而防止用户手抖误删。
文章修改、删除的视图函数如下:

def article_delete(request, id):
    if request.method == "GET":
        article = ArticlePost.objects.get(id=id)
        if article.author.id == request.user.id:
            article.delete()
            return redirect("article:article-list")
        else:
            return HttpResponse('你没有权限删除这篇文章!')


def article_update(request, id):
    article = ArticlePost.objects.get(id=id)
    if request.method == "GET":
        context = {"article": article}
        return render(request, "article/update.html", context)
    else:
        article_post_form = ArticlePostForm(data=request.POST)
        if article_post_form.is_valid():
            if article.author.id == request.user.id:
                article.title = request.POST['title']
                article.body = request.POST['body']
                article.save()
                return redirect("article:article-detail", id=id)
            else:
                return HttpResponse('你无权修改这篇文章!')
        else:
            return HttpResponse("表单内容有误,请重新填写!")

可以看到,在视图中我们再次确认了用户是否有权修改或删除文章,虽然在模板中我们已经初步确认了用户权限,但是出于安全考虑在后端再次进行确认还是很有必要的。
文章修改的模板如下:

{% extends "base.html" %}
{% block title %}文章修改{% endblock %}
{% block content %}
    <div class="container">
        <form method="post" action="." class="mt-4">
            {% csrf_token %}
            <div class="mb-3">
                <label for="title" class="form-label">文章标题</label>
                <input type="text" class="form-control" id="title" name="title" value="{{ article.title }}">
            </div>
            <div class="mb-3">
                <label for="body" class="form-label">文章正文</label>
                <textarea type="text" class="form-control" rows="12" id="body" name="body">{{ article.body }}</textarea>
            </div>
            <button type="submit" class="btn btn-primary">提交修改</button>
        </form>
    </div>
{% endblock %}

不难看出,文章修改模板其实和文章创作模板差不多,区别就在于文章修改模板预填了原来的文章内容。
最后将以上功能添加到路由中即可:

app_name = 'article'

urlpatterns = [
    path('article-list/', article_list, name='article-list'),
    path('article-detail/<int:id>/', article_detail, name='article-detail'),
    path('article-create/', article_create, name='article-create'),
    path('article-delete/<int:id>/', article_delete, name='article-delete'),
    path('article-update/<int:id>/', article_update, name='article-update'),
]

至此,关于文章的基本功能就算完成了,更多功能(分页、搜索、点赞、评论等)我们后面慢慢完善。


在上一节中,我们已经实现了登录注册功能,这一节我们在此基础上添加个人中心功能。个人中心目前设计的主要功能就是展示一些信息以及更换头像。其中,主要功能我认为是更换头像,虽然用户注册时会使用默认头像,但是默认头像显然不能满足个性化需求。
由于需要修改用户头像,因此我们要先建一个表单,如下:

class ProfileForm(forms.ModelForm):
    class Meta:
        model = UserProfile
        fields = ('avatar',)

个人中心的视图函数如下:

def user_profile(request):
    if request.method == "GET":
        articles = ArticlePost.objects.filter(author_id=request.user.id)
        context = {'articles': articles}
        return render(request, 'userprofile/profile.html', context)
    else:
        profile = UserProfile.objects.get(id=request.user.id)
        profile_form = ProfileForm(request.POST, request.FILES)
        if profile_form.is_valid():
            profile_form_data = profile_form.cleaned_data
            if 'avatar' in request.FILES:
                profile.avatar = profile_form_data['avatar']
            profile.save()
            return redirect('userprofile:profile')
        else:
            return HttpResponse('表单有误,请重新填写!')

GET请求其实就是展示一些数据,主要是用户发表过的文章,而用户的一些基本信息其实不必传递,因为用户一旦登录这些基本信息就保存在session中了,模板页面中可以直接通过user访问。POST请求其实就是更换用户头像,表单上传的文件通过request.FILES进行访问。
个人中心的模板如下:

{% extends 'base.html' %}
{% block title %}个人中心{% endblock %}
{% block style %}
    .box{
        position: relative;
        overflow: hidden;
    }
    .box img{
         100%;
        height: auto;
    }
    .box .box-content{
         100%;
        height: 100%;
        position: absolute;
        top: 0;
        left: 0;
        color: #fff;
        text-align: center;
        padding: 40% 20px;
        background: rgba(0,0,0,0.6);
        transform: rotate(-90deg);
        transform-origin: left top 0;
        transition: all 0.50s ease 0s;
    }
    .box .read{
        font-size: 20px;
        font-weight: bold;
        color: #fff;
        display: block;
        letter-spacing:2px;
        transform: rotate(180deg);
        transform-origin: right top 0;
        transition: all 0.3s ease-in-out 0.2s;
    }
    .box .read:hover{
        color: #e8802e;
        text-decoration: none;
    }
    .box:hover .box-content,
    .box:hover .read {
        transform:rotate(0deg);
    }
    @media screen and (max- 990px){
        .box{ margin-bottom:20px; }
    }
    @media screen and (max- 359px){
        .box .box-content{ padding: 10% 20px; }
    }
{% endblock %}
{% block content %}
    <div class="container">
        <form action="." method="post" enctype="multipart/form-data" class="visually-hidden">
            {% csrf_token %}
            <input class="form-control" type="file" name="avatar" id="upload_avatar">
            <button type="submit" id="submit"></button>
        </form>
        <div class="row shadow mt-4 py-3">
            <div class="col-2">
                <div class="box">
                    <img src="{{ user.avatar.url }}" class="img-thumbnail mx-auto d-block" alt="头像">
                    <div class="box-content">
                        <span class="read" onclick="x()">更换头像</span>
                    </div>
                </div>
            </div>
            <div class="col-10">
                <h1>{{ user.username }}</h1>
                <p><i class="bi bi-calendar-check-fill"></i> 入园时间:{{ user.date_joined }}</p>
                <p><i class="bi bi-calendar-check"></i> 上次登录:{{ user.last_login }}</p>
                <p><i class="bi bi-envelope-fill"></i> 注册邮箱:{{ user.email }}</p>
            </div>
        </div>
        <div class="row mt-4">
            <div class="col-8 shadow">
                {% for article in articles %}
                    <div class="row">
                        <div class="card border-0 mt-3 h-250">
                            <div class="card-header">
                                <h5>{{ article.title }}</h5>
                                <small class="text-secondary"><i class="bi bi-clock"></i> 发表于 {{ article.created }}
                                </small>
                            </div>
                            <div class="card-body">
                                <p class="card-text">{{ article.body|slice:'100' }}</p>
                                <!-- slice:'100'是Django的过滤器语法,表示取出正文的前100个字符,避免摘要太长 -->
                                <a href="{% url 'article:article-detail' article.id %}" class="btn btn-primary">阅读本文</a>
                            </div>
                        </div>
                    </div>
                {% endfor %}
            </div>
            <div class="col-3 offset-1 shadow">

            </div>
        </div>
    </div>
    <script>
        function x() {
            const $input = $('#upload_avatar');
            $input.click();
            $input.change(function () {
                //如果value不为空,调用文件加载方法
                if($(this).val() !== ""){
                    $("#submit").click();
                }
            })
        }
    </script>
{% endblock content %}

内容看起来很多,其实一大部分都是css代码(我更改了base.html,增加了style块用于子模板添加独有的样式),用来实现鼠标移到头像上则显示遮罩层过渡动画与更换头像的链接。通过js代码不难看出,其实更换头像的本质是通过隐藏的表单来实现的。
注意:form表单要上传文件,必须设置enctype="multipart/form-data",否则文件无法上传且不会报错,难以察觉。
上面很多功能没有写添加到header.html中,当然这部分也不难,这里就不再赘述了。
个人中心的效果图如下:

8.文章分页与搜索排序

对于绝大多数网站而言,分页都是必须的操作,因为大量的结果展示在同一页面,不仅造成页面冗长不便于阅读,而且并不美观,博客网站同样如此。我们采用Django内置的分页模块——Paginator来实现博客文章分页功能(自己实现还是很困难的,emmm...)。
我们修改文章列表的视图:

from django.core.paginator import Paginator

def article_list(request):
    article_list = ArticlePost.objects.all()

    paginator = Paginator(article_list, 1)  # 每页显示 1 篇文章
    page = request.GET.get('page')  # 获取 url 中的页码
    articles = paginator.get_page(page)  # 将导航对象相应的页码内容返回给 articles

    context = { 'articles': articles }
    return render(request, 'article/list.html', context)

从视图函数中可以看出,要实现完整的分页功能,我们至少需要传递page参数以获取对应的页码。这里我们介绍一种通过url地址传递参数的方法:即在url的末尾附上?key=value的键值对,视图中就可以通过request.GET.get('key')来查询value的值。
我们在模板list.html中添加分页控制按钮:

<nav>
    <ul class="pagination">
        {% if articles.has_previous %}
            <li class="page-item">
                <a class="page-link" href="?page={{ articles.previous_page_number }}" aria-label="Previous">
                    <span aria-hidden="true">&laquo;</span>
                </a>
            </li>
        {% endif %}
        {% if articles.previous_page_number > 1 %}
            <li class="page-item">
                <a class="page-link" href="#" aria-label="Previous">
                    <span aria-hidden="true">...</span>
                </a>
            </li>
        {% endif %}
        {% if articles.has_previous %}
            <li class="page-item">
                <a class="page-link" href="?page={{ articles.previous_page_number }}" aria-label="Previous">
                    <span aria-hidden="true">{{ articles.previous_page_number }}</span>
                </a>
            </li>
        {% endif %}
        <li class="page-item active"><a class="page-link" href="#">{{ articles.number }}</a></li>
        {% if articles.has_next %}
            <li class="page-item">
                <a class="page-link" href="?page={{ articles.next_page_number }}" aria-label="Next">
                    <span aria-hidden="true">{{ articles.next_page_number }}</span>
                </a>
            </li>
        {% endif %}
        {% widthratio articles.number 1 -1 as num %}
        {% if articles.paginator.num_pages|add:num > 1 %}
            <li class="page-item">
                <a class="page-link" href="#" aria-label="Next">
                    <span aria-hidden="true">...</span>
                </a>
            </li>
        {% endif %}
        {% if articles.has_next %}
            <li class="page-item">
                <a class="page-link" href="?page={{ articles.next_page_number }}" aria-label="Next">
                    <span aria-hidden="true">&raquo;</span>
                </a>
            </li>
        {% endif %}
    </ul>
</nav>

在上述模板中,articles是视图函数传递过去的Paginator对象,has_previoushas_next等是对象的方法名,其含义不难理解。widthratio是Django模板中的一种用于运算的标签,它需要三个参数,其返回结果是参数1/参数2*参数3,利用它可以巧妙实现乘除法,文中就是利用它将articles.number变成负数,然后和articles.paginator.num_pages相加,从而获取当前页面后面的剩余页面数量。
接下来我们实现文章的搜索和排序(最新、最热)功能,最热文章的排序就是根据浏览量进行排序,为此我们需要先修改ArticlePost模型:

class ArticlePost(models.Model):
    ...
    total_views = models.PositiveIntegerField(default=0)
    ...

然后执行数据库迁移命令,这里就不多介绍数据库迁移命令的写法了,到现在为止想必各位已经熟悉了。有了浏览量字段后就需要在模板中展示出来,这也不多介绍了。
我们对浏览量计数的方法很简单,就是每调用一次article_detail方法就给对应文章的浏览量加一。

def article_detail(request, id):
    article = ArticlePost.objects.get(id=id)
    article.total_views += 1
    article.save(update_fields=['total_views'])
    ...

update_fields=[]指定了数据库只更新total_views字段,优化了执行效率。
文章的搜索、排序的实现方法其实和分页功能差不多,其实都是通过url地址传递参数到视图函数中以获取对应的内容,多个参数用&连接。修改后的视图函数如下:

def article_list(request):
    search = request.GET.get('search')
    order = request.GET.get('order')
    if search:
        all_articles = ArticlePost.objects.filter(Q(title__icontains=search) | Q(body__icontains=search))
    else:
        search = ''
        all_articles = ArticlePost.objects.all()
    if order == 'total_views':
        all_articles = all_articles.order_by('-total_views')
    paginator = Paginator(all_articles, 1)
    page_index = request.GET.get('page')
    articles = paginator.get_page(page_index)
    context = {'articles': articles, 'order': order, 'search': search}  # 传递给模板的上下文
    return render(request, "article/list.html", context)  # render函数的作用是结合模板和上下文,并返回渲染后的HttpResponse对象

文章搜索功能是通过Model.objects.filter(**kwargs)来实现的,它可以返回与给定参数匹配的部分对象。而需要联合查询时就要用到Q对象,例如Q(title__icontains=search)意思就是在查询模型的title字段时返回包含search(不区分大小写)的对象。多个Q对象用管道符|隔开,就达到了联合查询的目的。

注意:当用户没有搜索内容时要返回search = '',因为如果用户没有搜索操作,则search = request.GET.get('search')会使得search = None,而这个值传递到模板中会错误地转换成"None"字符串!等同于用户在搜索“None”关键字,这明显是错误的。

排序功能是通过order_by()实现的,该方法指定对象如何进行排序(我们创建的模型默认按照时间倒序排列,因此最新文章的排序不需要进行任何操作)。修改后的模型中有total_views这个整数字段,因此‘total_views’为正序,‘-total_views’为逆序。之所以把order也传递到模板中,是因为文章需要翻页,而order就是给模板一个标识,提醒模板下一页应该如何排序。
搜索功能的模板我们放在header.html中,更加醒目。

<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
    <div class="container">
        ...
        <form class="d-flex" action="{% url 'article:article-list' %}?">
            <div class="input-group">
                <input class="form-control" type="search" placeholder="搜索文章..." value="{{ search }}" name="search" aria-describedby="search" required>
                <button class="input-group-text" type="submit" id="search"><i class="bi bi-search"></i></button>
            </div>
        </form>
        ...
    </div>
</nav>

注意:我们是通过GET请求的url来传递参数,因此form中不能加method="POST",其默认方法为GET
最新、最热排序的模板我们加在list.html中,即文章列表页面。

{% extends "base.html" %}
{% block title %}首页{% endblock %}
{% block content %}
    <div class="container">
        <nav class="bg-light border pt-2 px-3 mt-4">
            <ol class="breadcrumb">
                <li class="breadcrumb-item"><a href="{% url 'article:article-list' %}?search={{ search }}">最新</a></li>
                <li class="breadcrumb-item"><a href="{% url 'article:article-list' %}?search={{ search }}&order=total_views">最热</a></li>
            </ol>
        </nav>
        {% if search %}
            <p class="mt-4"><span class="text-danger">“{{ search }}”</span>的搜索结果如下:</p>
        {% endif %}
        ...
        
    </div>
{% endblock %}

分页功能的href也需要修改,需要添加searchorder两个参数,如下例所示:

{% if articles.has_previous %}
    <li class="page-item">
        <a class="page-link" href="?page={{ articles.previous_page_number }}&search={{ search }}&order={{ order }}" aria-label="Previous">
            <span aria-hidden="true">&laquo;</span>
        </a>
    </li>
{% endif %}

最终效果图如下所示:

9.文章目录与发表评论

在上篇中,我们已经为博文支持了Markdown语法,现在我们为其添加目录功能。
修改文章详情视图:

def article_detail(request, id):
    ...
    md = markdown.Markdown(
        extensions=[
            'markdown.extensions.extra',  # 包含 缩写、表格等常用扩展
            'markdown.extensions.codehilite',  # 语法高亮扩展
            'markdown.extensions.toc',  # 目录扩展
        ])
    article.body = md.convert(article.body)
    context = {'article': article, 'toc': md.toc}
    return render(request, "article/detail.html", context)

我们仅仅是将markdown.extensions.toc扩展添加了进去。为了将目录插入到页面的任何一个位置,我们先将Markdown类赋值给一个临时变量md,然后用convert()方法将正文渲染为html页面,然后通过md.toc将目录传递给模板。
修改文章详情模板:

{% extends "base.html" %}
{% block title %}文章详情{% endblock %}
{% block content %}
    <div class="container">
        <div class="row">
            <div class="col-9 mt-4">
                ...
            </div>
            <div class="col-3 mt-4">
                <div class="shadow p-4">
                    <h1 class="text-center">目录</h1>
                    <div class="border-top pt-2">{{ toc|safe }}</div>
                </div>
            </div>
        </div>
    </div>
    ...
{% endblock %}

我们重新布局了页面内容,将博客正文放到col-9的容器中,将目录放到右侧col-3的容器中。
注意:toc需要|safe标签才能正确渲染,具体原因在上篇添加Markdown支持的时候阐述过。


评论功能是一个独立的模块,我们首先要为其新建一个应用:

python manage.py startapp comment

然后在settings.py中注册应用:

INSTALLED_APPS = [
    ...
    'comment',
]

最后注册到根路由中:

urlpatterns = [
    ...
    path('comment/', include('comment.urls', namespace='comment')),
]

以上就是新建一个app的流程。下面我们实现评论模块的核心功能。
首先编写评论的模型:

class Comment(models.Model):
    article = models.ForeignKey(ArticlePost, on_delete=models.CASCADE)
    user = models.ForeignKey(UserProfile, on_delete=models.CASCADE)
    body = models.TextField()
    created = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ('created',)

    def __str__(self):
        return self.body[:20]

该模型有两个外键,分别是ArticlePostUserProfile,这使我想起了学数据库时的学生选课表(emmm...)。
注意:每次新建、修改模型后,都必须执行数据库迁移才能生效。
用户提交评论需要用到表单,因此我们新建一个表单类:

class CommentForm(forms.ModelForm):
    class Meta:
        model = Comment
        fields = ['body']

然后我们新建路由文件

app_name = 'comment'

urlpatterns = [
    path('comment_post/<int:article_id>/', comment_post, name='comment_post'),
]

再编写视图:

@login_required(login_url='userprofile:login')
def comment_post(request, article_id):
    article = get_object_or_404(ArticlePost, id=article_id)
    if request.method == 'POST':
        comment_form = CommentForm(data=request.POST)
        if comment_form.is_valid():
            new_comment = comment_form.save(commit=False)
            new_comment.article = article
            new_comment.user = request.user
            new_comment.save()
            return redirect(article)
        else:
            return HttpResponse('表单内容有误,请重新填写!')
    else:
        return HttpResponse('发表评论仅接受POST请求!')
  • get_object_or_404()Model.objects.get()的功能基本是相同的,区别是在生产环境下,如果用户请求一个不存在的对象时,后者会返回Error 500(服务器内部错误),而前者会返回Error 404。相比之下,返回404错误更加的准确。
  • redirect()返回到一个适当的url中:即用户发送评论后,重新定向到文章详情页面。当其参数是一个Model对象时,会自动调用这个Model对象的get_absolute_url()方法。因此我们接下来马上修改 ArticlePost模型:
class ArticlePost(models.Model):
    ...
    # 通过reverse()方法返回文章详情页面的url,实现了路由重定向
    def get_absolute_url(self):
        return reverse('article:article-detail', args=[self.id])

评论模块需要在文章详情页面展示,因此接下来修改文章详情的视图和模板。
首先修改文章详情视图:

def article_detail(request, id):
    ...
    comments = Comment.objects.filter(article=id)
    context = {'article': article, 'toc': md.toc, 'comments': comments}
    return render(request, "article/detail.html", context)

filter()可以取出多个满足条件的对象,而get()只能取出1个,注意区分使用。
然后修改文章详情模板:

{% extends "base.html" %}
{% block title %}文章详情{% endblock %}
{% block content %}
    <div class="container">
        <div class="row">
            <div class="col-9 mt-4">
                <div class="shadow p-4">
                    ...
                    <p><span class="fw-bolder text-warning">{{ comments.count }}</span> 评论</p>
                    {% if user.is_authenticated %}
                        <form action="{% url 'comment:comment_post' article.id %}" method="POST">
                            {% csrf_token %}
                            <div class="input-group">
                                <textarea class="form-control me-3" name="body" aria-label="With textarea"
                                          required></textarea>
                                <button type="submit" class="input-group-text btn btn-primary">发表评论</button>
                            </div>
                        </form>
                    {% else %}
                        <h5 class="text-center">请<a href="{% url 'userprofile:login' %}" class="text-decoration-none">【登录】</a>后发表评论!
                        </h5>
                    {% endif %}
                    <div class="border p-4 mt-3 bg-light">
                        {% for comment in comments %}
                            <div class="row border-bottom mb-3">
                                <div class="col-1">
                                    <img src="{{ comment.user.avatar.url }}" alt="用户头像" class="img-fluid">
                                </div>
                                <div class="col-11">
                                    <span class="text-info fw-bolder">{{ comment.user }}</span>
                                    <span class="float-end text-secondary">{{ comment.created|date:"Y-m-d H:i:s" }}</span>
                                    <p>{{ comment.body }}</p>
                                </div>
                            </div>
                        {% endfor %}
                    </div>
                </div>
            </div>
            <div class="col-3 mt-4">
                ....
            </div>
        </div>
    </div>
    ...
{% endblock %}
  • comments.count是模板对象中内置的方法,对包含的元素进行计数。
  • |date:"Y-m-d H:i :s"管道符你已经很熟悉了,用于给对象“粘贴”某些属性或功能。这里用于格式化日期的显示方式。

最终效果如下图:

10.文章栏目标签标题图

文章栏目既方便博主对文章进行分类归档,也方便用户有针对性的阅读。要实现栏目功能其实不难,无非就是新建一个栏目模型,再以外键形式关联到文章模型。
文章标签其实和文章栏目差不多,不同点在于一篇文章可以有多个标签,但只能有一个栏目。这里我们采用一个实现了标签功能的优秀的三方库:django-taggit(具体安装不再赘述,安装完记得在settings.py中注册app——taggit),利用该库进行快速开发。
标题图的添加是考虑到有时一图胜千言,通过图片能够快速了解文章内容。前面我们已经介绍过用户头像了,标题图其实也差不多,只是我们增加了对图片进行缩放等处理。
首先修改article/modles.py文件:

class ArticleColumn(models.Model):
    title = models.CharField(max_length=50, blank=True)
    created = models.DateTimeField(default=timezone.now)

    def __str__(self):
        return self.title


class ArticlePost(models.Model):
    ...
    column = models.ForeignKey(ArticleColumn, on_delete=models.CASCADE, blank=True, null=True)
    tags = TaggableManager(blank=True)
    avatar = models.ImageField(upload_to='article/%Y%m%d/', default="article/20210716/default.jpeg", blank=True)
    ...
    def save(self, *args, **kwargs):
        super(ArticlePost, self).save(*args, **kwargs)
        if self.avatar and not kwargs.get('update_fields'):
            image = Image.open(self.avatar)
            image = image.resize((400, 225), Image.ANTIALIAS)
            image.save(self.avatar.path)

首先我们增加了一个栏目模型——ArticleColumn,该模型的字段很简单,因此不过多介绍。对于文章模型,我们不仅添加了三个字段(tags有点特殊:因为标签引用的不是内置字段,而是库中的TaggableManager,它是处理多对多关系的管理器),还定义了save()方法。

  • save()model内置的方法,它会在model实例每次保存时调用。我们这里改写它,将处理图片的逻辑加入进去。
  • super(ArticlePost, self).save(*args, **kwargs)的作用是调用父类中原有的save()方法,即将model中的字段数据保存到数据库中。因为图片处理是基于已经保存的图片的,所以这句一定要在处理图片之前执行,否则会得到找不到原始图片的错误。
  • not kwargs.get('update_fields')是为了排除掉统计浏览量调用的save(),免得每次用户进入文章详情页面都要处理标题图,因为我们在article_detail()视图中为了统计浏览量而调用了save(update_fields=['total_views'])
  • Pillow库负责处理图片,将新图片的宽高设置为(400,225),最后用新图片将原始图片覆盖掉。Image.ANTIALIAS表示缩放采用平滑滤波。

模型修改完毕,记住要执行数据迁移才能生效。
然后我们在article/admin.py中将栏目模型注册到后台,并在后台添加几个栏目,然后随机找几篇文章设置不同的栏目以便后续测试。

admin.site.register(ArticleColumn)

既然我们已经在文章模型中添加了新字段,那么接下来文章创作和文章修改这两个功能也要做些更改,要将这几个新字段添加进去。
由于新文章是通过表单上传到数据库中的,因此我们先修改文章创作的表单类:

class ArticlePostForm(forms.ModelForm):
    class Meta:
        model = ArticlePost  # 指明数据模型的来源
        fields = ('title', 'body', 'tags', 'avatar')  # 定义表单包含的字段

我们在表单中增加了tagsavatar两个字段。
然后我们修改文章创作视图:

def article_create(request):
    if request.method == "GET":
        article_post_form = ArticlePostForm()
        columns = ArticleColumn.objects.all()
        context = {"article_post_form": article_post_form, 'columns': columns}
        return render(request, "article/create.html", context)
    else:
        article_post_form = ArticlePostForm(request.POST, request.FILES)
        if article_post_form.is_valid():
            new_article = article_post_form.save(commit=False)
            new_article.author = request.user
            if request.POST['column'] != 'none':
                new_article.column = ArticleColumn.objects.get(id=request.POST['column'])
            if 'avatar' in request.FILES:
                new_article.avatar = request.FILES['avatar']
            new_article.save()
            article_post_form.save_m2m()
            return redirect("article:article-list")
        else:
            return HttpResponse("表单填写有误,请重新填写!")

修改之处主要有以下几点:

  • GET中增加了栏目的上下文,以便模板使用,用户只需在下框中选择即可。
  • 标题图是文件,应该在request.FILES里获取它,而不是request.POST
  • 对文章栏目和标题图进行判断,通过save_m2m()保存文章和标签的关系。

最后我们来看文章创作的模板:

<form method="post" action="." enctype="multipart/form-data" class="mt-4">
    ...
    <div class="mb-3">
        <label for="avatar">文章标题图</label>
        <input type="file" class="form-control" name="avatar" id="avatar">
    </div>
    <div class="mb-3">
        <label for="column" class="form-label">文章栏目</label>
        <select class="form-select" id="column" name="column">
            <option selected>请选择文章栏目...</option>
            {% for column in columns %}
                <option value="{{ column.id }}">{{ column.title }}</option>
            {% endfor %}
        </select>
    </div>
    <div class="mb-3">
        <label for="tags" class="form-label">文章标签</label>
        <input type="text" class="form-control" id="tags" name="tags" placeholder="文章标签请用英文逗号分隔">
    </div>
    ...
</form>
  • 为了上传标题图,我们需要对form添加enctype="multipart/form-data"属性,该属性的含义是表单提交时不对字符编码。
  • <select>是表单的下拉框选择组件,在这个组件中循环列出所有的栏目数据,我们将value属性设置为栏目的id值。

文章修改其实和文章创作差不多,主要就是需要将原数据返回到表单中方便修改。
其视图函数如下:

def article_update(request, id):
    article = ArticlePost.objects.get(id=id)
    if request.method == "GET":
        ...
        context = {"article": article, 'article_form': article_form, 'columns': columns}
        return render(request, "article/update.html", context)
    else:
        article_post_form = ArticlePostForm(request.POST, request.FILES)
        if article_post_form.is_valid():
            if article.author.id == request.user.id:
                ...
                article.tags.set(*request.POST.get('tags').split(','), clear=True)
                article.save()
        ...

tags.set()是库提供的接口,用于更新标签数据。
文章修改的模板文件如下:

<form method="post" action="." enctype="multipart/form-data" class="mt-4">
    ...
    <div class="mb-3">
        <label for="column" class="form-label">文章栏目</label>
        <select class="form-select" id="column" name="column">
            <option selected>请选择文章栏目...</option>
            {% for column in columns %}
                <option value="{{ column.id }}" {% if column.id == article.column.id %}selected{% endif %}>{{ column.title }}</option>
            {% endfor %}
        </select>
    </div>
    <div class="mb-3">
        <label for="tags" class="form-label">文章标签</label>
        <input type="text" class="form-control" id="tags" name="tags" value="{{ article.tags.all|join:"," }}">
    </div>
    ...
</form>

与之前不同的是,我们在表单中判断了column.idarticle.column.id是否相等,如果相等则将其设置为默认值。而对于tags,由于视图传递过来的是一个set,因此我们通过|join:","将元素用英文逗号连接成字符串。

至此,文章创作和文章修改的变更就差不多了。接下来就是展示文章标题图和栏目标签了。
对于标题图,我们将其展示在文章列表页面,修改后的模板如下:

<div class="card my-4 h-250">
    <div class="row g-0">
        <div class="col-4">
            <img src="{{ article.avatar.url }}" class="img-fluid rounded-start" alt="文章标题图">
        </div>
        <div class="col-8">
            <div class="card-header">
                <h4>{{ article.title }}</h4>
                <small class="text-secondary"><i class="bi bi-clock"></i> 发表于 {{ article.created }}</small>
                <small class="text-secondary"><i class="bi bi-eye"></i> 阅读: {{ article.total_views }}
                </small>
            </div>
            <div class="card-body">
                <p class="card-text">{{ article.body|truncatechars:300 }}</p>
                <!-- slice:'100'是Django的过滤器语法,表示取出正文的前100个字符,避免摘要太长 -->
                <a href="{% url 'article:article-detail' article.id %}" class="btn btn-primary">阅读本文</a>
            </div>
        </div>
    </div>
</div>

具体效果如图:

对于栏目和标签,我们将其展示在文章详情页面,修改后的模板如下:

...
<p>
    {% if article.column %}
        <a href="{% url 'article:article-list' %}?column={{ article.column.title }}"
           class="btn btn-warning text-white me-2">{{ article.column.title }}</a>
    {% endif %}
    {% for tag in article.tags.all %}
        <a href="{% url 'article:article-list' %}?tag={{ tag }}"
           class="btn btn-info text-white me-2">{{ tag }}</a>
    {% endfor %}
</p>
...

具体效果如图:


下面我们实现按照栏目和标签进行搜索的功能。
首先修改文章列表的视图:

def article_list(request):
    ...
    column = request.GET.get('column')
    tag = request.GET.get('tag')
    ...
    if column:
        all_articles = all_articles.filter(column__title=column)
    else:
        column = ''
    if tag:
        all_articles = all_articles.filter(tags__name__in=[tag])
    else:
        tag = ''
    ...

然后修改模板中的分页按钮链接:

<li class="page-item">
    <a class="page-link"
       href="?page={{ articles.previous_page_number }}&search={{ search }}&order={{ order }}&column={{ column }}&tag={{ tag }}"
       aria-label="Previous">
        <span aria-hidden="true">&laquo;</span>
    </a>
</li>

具体效果如下图:

原文地址:https://www.cnblogs.com/marvin-wen/p/14988744.html