本系列是对入门书籍《Python编程:从入门到实践》的笔记整理,属于初级内容。标题顺序采用书中标题。
本篇记录如何创建用户注册系统,如何实现用户输入自己的数据。
1. 前言
在本篇中,我们将:
- 创建一些表单,让用户能够添加主题和条目,以及编辑既有的条目;
- 实现一个身份验证系统。
2. 让用户能够输入数据
先添加几个页面,让用户能够添加新主题,新条目以及编辑条目。
2.1 添加新主题
和之前创建网页的步骤一样:定义URL,编写视图函数,编写模板。主要区别是,这里需要一个包含表单的模块forms.py
2.1.1 创建forms.py模块
用户输入信息时,需要进行验证,确保提交的信息是正确的数据类型,且不是恶意信息,如中断服务器的代码。然后再处理信息,并保存到数据库中。当然,这些工作很多都由Django自动完成。
在models.py
所在的目录中新建forms.py
模块。创建表单的最简单方法是继承Django的ModelForm
类:
from django import forms
from .models import Topic
class TopicForm(forms.ModelForm):
class Meta:
model = Topic
fields = ["text"]
labels = {"text": ""}
最简单的ModelForm
版本只包含一个内嵌的Meta
类,它告诉Django根据哪个模型创建表单,以及在表单中包含哪些字段。第6行,我们根据Topic
创建一个表单,该表单只包含字段text
(第7行),并不为该字段生成标签(第8行)。
2.1.2 URL模式new_topic
当用户要添加新主题时,将切换到http://localhost:8000/new_topic/ 。在learning_logs/urls.py
中添加如下代码:
urlpatterns = [
-- snip --
# 用于添加新主题的网站
path("new_topic/", views.new_topic, name="new_topic"),
]
2.1.3 视图函数new_topic()
该函数需要处理两种情形:①刚进入new_topic
网页,显示一个空表单;②对提交的表单数据进行处理,并将用户重定向到网页topics
。修改views.py
文件:
from django.http import HttpResponseRedirect
from django.urls import reverse
from .forms import TopicForm
def new_topic(request):
"""添加新主题"""
if request.method != "POST":
# 为提价数据:创建一个新表单
form = TopicForm()
else:
# POST提交的数据,对数据进行处理
form = TopicForm(request.POST)
if form.is_valid():
form.save()
# 该类将用户重定向到网页topics,函数reverse()根据指定的URL模型确定URL
return HttpResponseRedirect(reverse("learning_logs:topics"))
context = {"form": form}
return render(request, "learning_logs/new_topic.html", context)
2.1.4 GET请求和POST请求
创建Web应用程序时,将用到两种主要数据请求类型:GET
请求和POST
请求。从这俩英文单词可以看出,如果只从服务器读取数据页面,则使用GET
请求;如果要提交用户填写的表单,通常使用POST
请求。当然还有一些其他的请求类型,但这个项目中没有使用。本项目中处理表单都使用POST
方法。
request.method
存储了请求的类型(第7行代码)。
当不是POST
请求时,我们生成一个空表单传递给模板new_topic.html
,然后返回给用户;当请求是POST
时,我们从request.POST
这个变量中获取用户提交的数据,并暂存到form
变量中。
通过is_valid()
方法验证表单数据是否满足要求:用户是否填写了所有必不可少的字段(表单字段默认都是必填的),且输入的数据与字段类型是否一致。当然这些验证都是Django自动进行的。如果表单有效,在通过form
的save()
方法存储到数据库,然后通过reverse()
函数获取页面topics
的URL,并将其传递给HTTPResponseRedirect()
以重定向到topics
页面。如果表单无效,把这些数据重新传回给用户。
2.1.5 模板new_topic.html
{% extends "learning_logs/base.html" %}
{% block content %}
<p>Add a new topic:</p>
<form action="{% url 'learning_logs:new_topic' %}" method="post">
{% csrf_token %}
{{ form.as_p }}
<button name="submit">add topic</button>
</form>
{% endblock content %}
模板继承了base.html
,因此其基本结构和项目中的其他页面相同。第6行中,参数action
告诉服务器将提交的表单数据送到什么位置去处理,参数method
让浏览器以POST
请求的方式提交数据。
Django使用模板标签csrf_token
(第7行)来防止攻击者利用表单获得对服务器未经授权的访问(跨站请求伪造)。
Django显示表单非常方便:只需要使用模板变量form.as_p
,修饰符as_p
让Django以段落格式渲染所有表单元素,这是一种整洁地显示表单的简单方法。
Django不自动创建提交表单的按钮,需自行创建。
2.1.6 链接到页面new_topic
在页面topics.html
中添加一个到页面new_topic
的链接:
{% extends "learning_logs/base.html" %}
{% block content %}
-- snip --
<a href="{% url 'learning_logs:new_topic' %}">Add a new topic:</a>
{% endblock content %}
2.1.7 效果
以下是实际效果图:
通过这个页面,随意添加几个主题,如下:
2.2 添加新条目
和前面的步骤相似:创建条目表单,添加URL,添加视图,添加模板,链接到页面
2.2.1 创建条目表单
创建一个与模型Entry相关联的表单,但这个表单的自定义程度比TopicForm
要高些,依然是在刚才创建的forms.py
中添加:
from .models import Topic, Entry
class EntryForm(forms.ModelForm):
class Meta:
model = Entry
fields = ["text"]
labels = {"text": ""}
widgets = {"text": forms.Textarea(attrs={"cols": 80})}
代码中定义了属性widgets
。小部件(widget)是一个HTML表单元素,如单行文本框、多行文本框或下拉列表。通过设置属性widgets可以覆盖Django选择的默认小部件。通过Django的forms.Textarea
定制字段“text"
的输入小部件,将文本框的宽度设置为80列,而不是默认的40列。
2.2.2 添加URL模式new_entry
修改learning_logs/urls.py
:
urlpatterns = [
-- snip --
path("new_entry/<int:topic_id>/", views.new_entry, name="new_entry"),
]
该URL模式与形式为http://localhost:8000/new_entry/topi_id/ 的URL匹配,其中topic_id
是主题的ID。
2.2.3 视图函数new_entry()
与函数new_topic()
很像:
from .forms import TopicForm, EntryForm
def new_entry(request, topic_id):
"""在特定的主题中添加新条目"""
topic = Topic.objects.get(id=topic_id)
if request.method != "POST":
# 未提交数据,创建一个空表单
form = EntryForm()
else:
# POST提交的数据,对数据进行处理
form = EntryForm(data=request.POST)
if form.is_valid():
new_entry = form.save(commit=False)
new_entry.topic = topic
new_entry.save()
return HttpResponseRedirect(reverse("learning_logs:topic", args=[topic_id]))
context = {"topic": topic, "form": form}
return render(request, "learning_logs/new_entry.html", context)
new_entry()
的定义包含形参topic_id
,用于存储从URL中获得的值。
在调用save()
时传递了参数commit=False
(第14行),它让Django创建一个新的条目对象,但并不立刻提交数据库,而是暂时存储在变量new_entry
中,待为这个新条目对象添加了属性topic
之后再提交数据库。
在重定向时,reverse()
函数中传递了两个参数,URL模式的名称以及列表args
,args
包含要包含在URL中的所有参数。
2.2.4 模板new_entry.html
类似于new_topic
:
{% extends "learning_logs/base.html" %}
{% block content %}
<p><a href="{% url 'learning_logs:topic' topic.id %}">{{ topic }}</a></p>
<p>Add a new entry:</p>
<form action="{% url 'learning_logs:new_entry' topic.id %}" method="post">
{% csrf_token %}
{{ form.as_p }}
<button name="submit">add entry</button>
</form>
{% endblock content %}
注意第4行代码,改行代码返回到特定主题页面。
2.2.5 链接到页面new_entry
在显示特定主题的页面中添加到页面new_entry
的链接,修改topic.html
:
{% extends "learning_logs/base.html" %}
{% block content %}
<p>Topic: {{ topic }}</p>
<p>Entries:</p>
<p>
<a href="{% url 'learning_logs:new_entry' topic.id %}">add new entry</a>
</p>
-- snip --
{% endblock content %}
2.2.6 效果
下图是实际效果,请随意添加一些条目:
2.3 编辑条目
创建一个页面,让用户能编辑既有条目。顺序是:添加URL,添加视图,添加模板,链接到页面。
2.3.1 URL模式edit_entry
修改learning_logs/urls.py
:
urlpatterns = [
-- snip --
path("edit_entry/<int:entry_id>/", views.edit_entry, name="edit_entry"),
]
2.3.2 视图函数edit_entry()
from .models import Topic, Entry
def edit_entry(request, entry_id):
"""编辑既有条目"""
entry = Entry.objects.get(id=entry_id)
topic = entry.topic
if request.method != "POST":
# 初次请求,使用当前条目填充表单
form = EntryForm(instance=entry)
else:
# POST提交的数据,对数据进行处理
form = EntryForm(instance=entry, data=request.POST)
if form.is_valid():
form.save()
return HttpResponseRedirect(reverse('learning_logs:topic', args=[topic.id]))
context = {"entry": entry, "topic": topic, "form": form}
return render(request, "learning_logs/edit_entry.html", context)
首先获取要被修改的entry
以及与该条目相关的主题。处理GET
请求时,通过参数instance=entry
创建EntryForm
实例,该参数让Django创建一个表单,并使用既有条目对象中的信息填充它。处理POST
请求时,还传入了data=request.POST
参数,Django根据POST中的相关数据对entry
进行修改。
2.3.3 模板edit_entry.html
{% extends "learning_logs/base.html" %}
{% block content %}
<p><a href="{% url 'learning_logs:topic' topic.id %}">{{ topic }}</a></p>
<p>Edit entry:</p>
<form action="{% url 'learning_logs:edit_entry' entry.id %}">
{% csrf_token %}
{{ form.as_p }}
<button name="submit">save changes</button>
</form>
{% endblock content %}
2.3.4 链接到页面edit_entry.html
在显示特定主题的页面中,需要给每个条目添加到页面edit_entry.html
的链接,为此,修改topic.html
:
-- snip --
{% for entry in entries %}
<li>
<p>{{ entry.date_added|date:'M d, Y H:i' }}</p>
<p>{{ entry.text|linebreaks }}</p>
<p>
<a href="{% url 'learning_logs:edit_entry' entry.id %}">edit entry</a>
</p>
</li>
-- snip --
2.3.5 效果
以下是实际效果图:
3. 创建用户账户
现在开始建立一个用户注册和身份验证系统。为此将创建一个新的应用程序,其中包含处理用户账户相关的所有功能。对Topic
模型也要做稍许修改,让每个主题都归属于特定用户。
3.1 创建应用程序users
希望大家还记得如何使用startapp
命令还创建APP:
python manage.py startapp users
将APP添加到settings.py中
INSTALLED_APPS = [
-- snip --
"users.apps.UsersConfig",
]
在APP根目录下创建urls.py
文件,并添加命名空间:
app_name = "users"
为APP定义URL,修改项目根目录中的urls.py
:
urlpatterns = [
path('admin/', admin.site.urls),
path("", include("learning_logs.urls")),
path("users/", include("users.urls")),
]
3.2 登陆页面
使用Django提供的默认登陆视图,URL模式会有所不同。在users
中的urls.py
中添加如下代码:
"""为应用程序users定义URL模式"""
from django.contrib.auth.views import login
from django.urls import path
app_name = "users"
urlpatterns = [
# 登陆页面
path("login/", login, {"template_name": "users/login.html"}, name="login"),
]
代码中,我们使用Django自带的login视图函数(注意,参数是login
,而不是views.login
)。从之前的例子可以看出,我们渲染模板的代码都是在自己写的视图函数中。但这里使用了自带的视图函数,无法自行编写进行渲染的代码。所以,我们还传了一个字典给path
,告诉Django到哪里查找我们要用到的模板。注意,该模板在users
中,而不是在learning_logs
中。
3.2.1 新建模板login.html
在learning_log/users/templates/users
中创建login.html
:
{% extends "learning_logs/base.html" %}
{% block content %}
{% if form.errors %}
<P>Your username and password didn't match. Please try again.</P>
{% endif %}
<form method="post" action="{% url 'users:login' %}">
{% csrf_token %}
{{ form.as_p }}
<button name="submit">log in</button>
<input type="hidden" name="next" value="{% url 'learning_logs:index' %}"/>
</form>
{% endblock content %}
如果表单的errors
属性被设置,则显示一条提示账号密码错误的信息。
3.2.2 链接到登陆页面
在base.html
中添加到登陆页面的链接,让所有页面都包含它。将这个链接嵌套在一个if
标签中,用户已登录时隐藏掉该链接:
<p>
<a href="{% url 'learning_logs:index' %}">Learning Log</a> -
<a href="{% url 'learning_logs:topics' %}">Topics</a>
{% if user.is_authenticated %}
Hello, {{ user.username }}
{% else %}
<a href="{% url 'users:login' %}">log in</a>
{% endif %}
</p>
{% block content %}{% endblock content %}
在Django身份验证系统中,每个模板都可使用变量user
,这个变量有一个is_authenticated
属性:如果用户已登录,该属性将为True
,否则为False
。
3.2.3 使用登陆页面
首先访问localhost:8000/admin
注销超级用户,再访问localhost:8000/users/login/
,得到如下页面:
3.3 注销
并不为注销创建单独的页面,而是让用户单击一个连接用于注销并返回主页。因此,需要做如下工作:注销URL模式,新建视图,链接到注销视图。
在users/urls.py
中添加与http://localhost:8000/users/logout/ 匹配的URL模式:
-- snip --
urlpatterns = [
-- snip --
path("logout/", views.logout_view, name="logout"),
]
编写视图函数logout_view()
,其中,直接调用Django自带的logout()
函数,该函数要求request
作为参数:
from django.contrib.auth import logout
from django.http import HttpResponseRedirect
from django.urls import reverse
def logout_view(request):
"""注销用户"""
logout(request)
return HttpResponseRedirect(reverse("learning_logs:index"))
在base.html
中添加注销链接:
-- snip --
{% if user.is_authenticated %}
Hello, {{ user.username }}
<a href="{% url 'users:logout' %}">log out</a>
{% else %}
<a href="{% url 'users:login' %}">log in</a>
{% endif %}
-- snip --
3.4 注册页面
使用Django提供的表单UserCreationFrom
,但编写自己的视图函数和模板。URL->view->template->link
。
首先,创建注册页面的URL模式,修改users/urls.py
:
-- snip --
urlpatterns = [
-- snip --
path("register/", views.register, name="register"),
]
其次,创建视图register()
:
from django.contrib.auth import logout, authenticate, login
from django.contrib.auth.forms import UserCreationForm
from django.shortcuts import render
-- snip --
def register(request):
"""注册新用户"""
if request.method != "POST":
# 显示空的注册表单
form = UserCreationForm()
else:
# 处理填写好的表单
form = UserCreationForm(data=request.POST)
if form.is_valid():
new_user = form.save()
# 让用户自动登陆,再重定向到主页
# 注册是要求输入两次密码,所以有password1和password2
authenticated_user = authenticate(username=new_user.username,
password=request.POST["password1"])
login(request, authenticated_user)
return HttpResponseRedirect(reverse("learning_logs:index"))
context = {"form": form}
return render(request, "users/register.html", context)
以上代码在用户成功创建了用户后会自动登陆,该功能由login()
函数实现。该函数将会为通过了身份验证的用户对象创建会话(session)。最后上述代码重定向到主页。
然后,编写注册页面的模板register.html:
{% extends "learning_logs/base.html" %}
{% block content %}
<form method="post" action="{% url 'users:register' %}">
{% csrf_token %}
{{ form.as_p }}
<button name="sumbit">register</button>
<input type="hidden" name="next" value="{% url 'learning_logs:index' %}"/>
</form>
{% endblock content %}
最后,在页面中显示注册链接,修改base.html
,在用户没有登录时显示注册链接:
-- snip --
{% if user.is_authenticated %}
Hello, {{ user.username }}
<a href="{% url 'users:logout' %}">log out</a>
{% else %}
<a href="{% url 'users:register' %}">register</a> -
<a href="{% url 'users:login' %}">log in</a>
{% endif %}
-- snip --
下面是实际效果:
这是直接点register按钮时的反馈,不过这里有点疑惑,从上面的register.html中看到,其实代码很简单,但这里有个浮动效果,而且在注册模板中并没有像前面那样的form.errors模板标签,但依然有未注册成功时的反应,而且注册的视图函数也是自己写的,并不是用的自带的注册函数,所以不知道是不是和form.as_p有关。之后再慢慢研究吧,
4. 让用户拥有自己的数据
用户应该能够输入其专有的数据,所以应该创建一个系统,确定各项数据所属的用户,再限制对页面的访问,使得用户只能使用自己的数据,即访问控制。
4.1 使用@login_required限制访问
Django提供了装饰器@login_required
,使得能轻松实现用户只能访问自己能访问的页面。
限制对topics.html的访问:
每个主题都归特定用户所有,所以需要加限制,修改learning_logs/views.py
:
from django.contrib.auth.decorators import login_required
@login_required
def topics(request):
"""显示所有的主题"""
topics = Topic.objects.order_by("date_added")
# 一个上下文字典,传递给模板
context = {"topics": topics}
return render(request, "learning_logs/topics.html", context)
装饰器也是一个函数,python在运行topics()
前会先运行login_required()
的代码。
login_required()
函数检查用户是否登录,仅当用户已登录时,Django才运行topics()
函数,若未登录,就重定向到登陆界面。而为了实现这个重定向,还需要修改项目的settings.py
文件,在该文件中添加这样一个常量(其实也是变量),一般在文件末尾添加:
-- snip --
LOGIN_URL = '/users/login/'
全面限制对项目“学习笔记”的访问:
Django能轻松地限制对页面的访问,但得自己设计需要限制哪些页面。一般先确定哪些页面不需要保护,再限制对其他页面的访问。在该项目中,我们不限制对主页、注册页面和注销链接的访问,其他页面均限制。在learning_logs/views.py
中,除了index()
外,每个视图函数都加上@login_required
。
4.2 将数据关联到用户
为了禁止用户访问其他用户的数据,需要将用户与数据关联。只需要将最高层的数据关联到用户,这样更低层的数据将自动关联到用户。下面修改Topic
模型和相关视图:
from django.contrib.auth.models import User
from django.db import models
class Topic(models.Model):
"""用户学习的主题"""
text = models.CharField(max_length=200)
date_added = models.DateTimeField(auto_now_add=True)
owner = models.ForeignKey(User, on_delete=models.CASCADE)
-- snip --
修改模型后,还需要迁移数据库。此时,需要将主题与用户关联。这里并没有通过代码进行关联,我们在迁移数据库时手动进行关联。为此,我们需要先知道有哪些用户,以及这些用户的ID。我们通过Django shell
查询用户信息,当然也可以直接查看数据库,这里不再演示。我们将主题都关联到超级用户ll_admin
上,它的ID是1。现在我们执行数据迁移:
(venv)learning_logs$ python manage.py makeimgrations learning_logs
You are trying to add a non-nullable field 'owner' to topic without a default;
we can't do that (the database needs something to populate existing rows).
Please select a fix:
1) Provide a one-off default now (will be set on all existing rows with a null value for
this column)
2) Quit, and let me add a default in models.py
Select an option: 1
Please enter the default value now, as valid Python
The datetime and django.utils.timezone modules are available, so you can do e.g. timezone.now
Type 'exit' to exit this prompt
>>> 1
Migrations for 'learning_logs':
learning_logs\migrations\0003_topic_owner.py
- Add field owner to topic
Django指出试图给既有模型Topic
添加一个必不可少(不可为空)的字段,而该字段没有默认值,需要我们采取措施:要么现在提供默认值,要么退出并在models.py
中添加默认值。我们选择了直接输入默认值。接下来,Django使用这个值来迁移数据库,并生成了迁移文件0003_topic_owner.py
,它在模型Topic中添加字段owner
。
现在执行迁移命令:
(venv)learning_logs$ python manage.py migrate
Operations to perform:
Apply all migrations: admin, auth, contenttypes, learning_logs, sessions
Running migrations:
Applying learning_logs.0003_topic_owner... OK
执行后可以在Django shell
中验证是否迁移成功,这里不再验证。
4.3 只允许用户访问自己的主题
目前不管以哪个用户身份登录,都能看到所有主题。现在我们添加限制,让用户只能看到自己的主题。在views.py
中,对topics()
做如下修改:
-- snip --
@login_required
def topics(request):
"""显示所有的主题"""
topics = Topic.objects.filter(owner=request.user).order_by("date_added")
# 一个上下文字典,传递给模板
context = {"topics": topics}
return render(request, "learning_logs/topics.html", context)
-- snip --
用户登录后,request
对象将有一个user
属性,这个属性存储了有关该用户的信息。第5行代码让Django只从数据库中读取特定用户的数据。
4.4 保护用户的主题
上述代码做到了登录后只显示相应用户的数据,但是,如果登录后直接通过URL访问,如直接输入http://localhost:8000/topics/1/ ,依然可以访问不属于自己的特定主题页面。下面修改views.py
中的topic()
函数来加以限制:
from django.http import HttpResponseRedirect, Http404
@login_required
def topic(request, topic_id):
"""显示单个主题及其所有的条目"""
topic = Topic.objects.get(id=topic_id)
# 确认请求的主题属于当前用户
if topic.owner != request.user:
raise Http404
-- snip --
4.5 保护页面edit_entry
此时用户也可以像上面一样,登陆后直接通过URL来访问edit_entry.html
,现在我们对这个页面也加以限制:
@login_required
def edit_entry(request, entry_id):
"""编辑既有条目"""
entry = Entry.objects.get(id=entry_id)
topic = entry.topic
if topic.owner != request.user:
raise Http404
-- snip --
4.6 最后一步:将新主题关联到当前用户
当前用于添加新主题的页面存在问题,因为它没有将新主题关联到特定用户。如果此时尝试添加新主题,将看到错误信息IntegrityError
,指出learning_logs_topic.user_id
不能为NULL
,下面修改new_topic()
函数:
@login_required
def new_topic(request):
"""添加新主题"""
if request.method != "POST":
# 为提价数据:创建一个新表单
form = TopicForm()
else:
# POST提交的数据,对数据进行处理
form = TopicForm(request.POST)
if form.is_valid():
# 添加新主题时关联到特定用户
new_topic = form.save(commit=False)
new_topic.owner = request.user
new_topic.save()
# 该类将用户重定向到网页topics,函数reverse()根据指定的URL模型确定URL
return HttpResponseRedirect(reverse("learning_logs:topics"))
context = {"form": form}
return render(request, "learning_logs/new_topic.html", context)
现在,这个项目允许任何用户注册,而每个用户想添加多少新主题都可以,每个用户只能访问自己的数据,无论是查看数据、输入新数据还是修改旧数据时都是如此。
5. 小结
本篇主要讲述了如何使用表单来让用户添加新主题、添加新条目和编辑既有条目;如何实现注册,登录与注销,如何使用装饰器来限制访问,如何对用户数据进行保护等。下一篇中,我们将使这个项目更漂亮,并且部署到服务器上。