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="width: 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内置其实已经实现了通过邮件来找回密码的功能,其主要步骤如下:
- 向用户邮箱发送包含重置密码地址的邮件。邮件的地址需要动态生成,防止不怀好意的用户从中捣乱;
- 向网站用户展示一条发送邮件成功的信息;
- 用户点击邮箱中的地址后,转入重置密码的页面;
- 向用户展示一条重置成功的信息。
其上四个流程分别由PasswordResetView
、PasswordResetDoneView
、PasswordResetConfirmView
、PasswordResetCompleteView
四个视图完成,因此我们要做的其实就是为它们配置路由罢了。在项目的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_name
、email_template_name
其实都是传递给class
的参数,具体可查看源码。事实上每一个视图对应的模板其实都有自带的(查看路径venv/Lib/site-packages/django/contrib/admin/templates/registration/
,我们自定义的模板其实是根据自带模板改编的),也就是说配置完路由其实这个功能就已经实现了。我们之所以要自己编写模板,其实是为了和自己网站的风格相适应,而且最不可忍受的是自带模板竟然还有Django标志。我们自己编写的模板放在template
的userprofile
文件夹下,每个模板的内容如下:
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{
width: 100%;
height: auto;
}
.box .box-content{
width: 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-width: 990px){
.box{ margin-bottom:20px; }
}
@media screen and (max-width: 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">«</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">»</span>
</a>
</li>
{% endif %}
</ul>
</nav>
在上述模板中,articles
是视图函数传递过去的Paginator
对象,has_previous
、has_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
也需要修改,需要添加search
和order
两个参数,如下例所示:
{% 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">«</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]
该模型有两个外键,分别是ArticlePost
、UserProfile
,这使我想起了学数据库时的学生选课表(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') # 定义表单包含的字段
我们在表单中增加了tags
和avatar
两个字段。
然后我们修改文章创作视图:
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.id
与article.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">«</span>
</a>
</li>
具体效果如下图: