Python学习之路18-用户账户

《Python编程:从入门到实践》笔记。
本篇记录如何创建用户注册系统,如何实现用户输入自己的数据。

1. 前言

在本篇中,我们将:

  • 创建一些表单,让用户能够添加主题和条目,以及编辑既有的条目;
  • 实现一个身份验证系统。

2. 让用户能够输入数据

先添加几个页面,让用户能够添加新主题,新条目以及编辑条目。

2.1 添加新主题

和之前创建网页的步骤一样:定义URL,编写视图函数,编写模板。主要区别是,这里需要一个包含表单的模块forms.py

2.1.1 创建forms.py模块

用户输入信息时,需要进行验证,确保提交的信息是正确的数据类型,且不是恶意信息,如中断服务器的代码。然后再处理信息,并保存到数据库中。当然,这些工作很多都由Django自动完成。

models.py所在的目录中新建forms.py模块。创建表单的最简单方法是继承Django的ModelForm类:

1
2
3
4
5
6
7
8
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中添加如下代码:

1
2
3
4
5
urlpatterns = [
-- snip --
# 用于添加新主题的网站
path("new_topic/", views.new_topic, name="new_topic"),
]

2.1.3 视图函数new_topic()

该函数需要处理两种情形:①刚进入new_topic网页,显示一个空表单;②对提交的表单数据进行处理,并将用户重定向到网页topics。修改views.py文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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自动进行的。如果表单有效,在通过formsave()方法存储到数据库,然后通过reverse()函数获取页面topics的URL,并将其传递给HTTPResponseRedirect()以重定向到topics页面。如果表单无效,把这些数据重新传回给用户。

2.1.5 模板new_topic.html

1
2
3
4
5
6
7
8
9
10
11
12
{% 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的链接:

1
2
3
4
5
6
{% 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中添加:

1
2
3
4
5
6
7
8
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

1
2
3
4
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()很像:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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模式的名称以及列表argsargs包含要包含在URL中的所有参数。

2.2.4 模板new_entry.html

类似于new_topic

1
2
3
4
5
6
7
8
9
10
11
12
13
{% 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

1
2
3
4
5
6
7
8
9
10
11
12
13
{% 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

1
2
3
4
urlpatterns = [
-- snip --
path("edit_entry/<int:entry_id>/", views.edit_entry, name="edit_entry"),
]

2.3.2 视图函数edit_entry()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{% 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

1
2
3
4
5
6
7
8
9
10
-- 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:

1
python manage.py startapp users

将APP添加到settings.py中

1
2
3
4
INSTALLED_APPS = [
-- snip --
"users.apps.UsersConfig",
]

在APP根目录下创建urls.py文件,并添加命名空间:

1
app_name = "users"

为APP定义URL,修改项目根目录中的urls.py

1
2
3
4
5
urlpatterns = [
path('admin/', admin.site.urls),
path("", include("learning_logs.urls")),
path("users/", include("users.urls")),
]

3.2 登陆页面

使用Django提供的默认登陆视图,URL模式会有所不同。在users中的urls.py中添加如下代码:

1
2
3
4
5
6
7
8
9
10
"""为应用程序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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{% 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标签中,用户已登录时隐藏掉该链接:

1
2
3
4
5
6
7
8
9
10
11
<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模式:

1
2
3
4
5
-- snip --
urlpatterns = [
-- snip --
path("logout/", views.logout_view, name="logout"),
]

编写视图函数logout_view(),其中,直接调用Django自带的logout()函数,该函数要求request作为参数:

1
2
3
4
5
6
7
8
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中添加注销链接:

1
2
3
4
5
6
7
8
-- 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

1
2
3
4
5
-- snip --
urlpatterns = [
-- snip --
path("register/", views.register, name="register"),
]

其次,创建视图register()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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

1
2
3
4
5
6
7
8
9
10
11
12
{% 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,在用户没有登录时显示注册链接:

1
2
3
4
5
6
7
8
9
-- 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

1
2
3
4
5
6
7
8
9
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文件,在该文件中添加这样一个常量(其实也是变量),一般在文件末尾添加:

1
2
-- snip --
LOGIN_URL = '/users/login/'

全面限制对项目“学习笔记”的访问

Django能轻松地限制对页面的访问,但得自己设计需要限制哪些页面。一般先确定哪些页面不需要保护,再限制对其他页面的访问。在该项目中,我们不限制对主页、注册页面和注销链接的访问,其他页面均限制。在learning_logs/views.py中,除了index()外,每个视图函数都加上@login_required

4.2 将数据关联到用户

为了禁止用户访问其他用户的数据,需要将用户与数据关联。只需要将最高层的数据关联到用户,这样更低层的数据将自动关联到用户。下面修改Topic模型和相关视图:

1
2
3
4
5
6
7
8
9
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。现在我们执行数据迁移:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
(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

现在执行迁移命令:

1
2
3
4
5
(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()做如下修改:

1
2
3
4
5
6
7
8
9
-- 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()函数来加以限制:

1
2
3
4
5
6
7
8
9
10
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,现在我们对这个页面也加以限制:

1
2
3
4
5
6
7
8
9
@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()函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@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. 小结

本篇主要讲述了如何使用表单来让用户添加新主题、添加新条目和编辑既有条目;如何实现注册,登录与注销,如何使用装饰器来限制访问,如何对用户数据进行保护等。下一篇中,我们将使这个项目更漂亮,并且部署到服务器上。

VPointer wechat
欢迎大家关注我的微信公众号"代码港"~~
您的慷慨将鼓励我继续创作~~