十九、用户账户

1. 让用户输入数据

我们先来添加几个页面,让用户添加新主题,添加新条目以及编辑既有条目

(1) 添加新主题

创建基于表单的页面需要导入包含表单的模块 forms.py

1) 用于添加主题的表单

  • 用户输入信息时,我们需要进行验证,确认提供的信息是正确的数据类型,而不是恶意的信息,如中断服务器的代码;然后,对这些有效信息进行处理,并将其保存到数据库的合适地方(这些工作很多都是由 Django 自动完成的)
  • 在 Diango 中,创建表单的最简单方式是使用 ModelForm,它根据我们在第18章定义的模型中的信息自动创建表单

在 models.py 所在的目录创建文件 forms.py:

# 导入模块 forms 以及要使用的模型 Topic
from django import forms

from .models import Topic


# 定义一个名为 TopicForm 的类,它继承了 forms.ModelForm
class TopicForm(forms.ModelForm):
    # 最简单的 ModelForm 版本只包含一个内嵌的 Meta 类,让 Django 根据哪个模型创建表单以及在表单中包含哪些字段
    class Meta:
        # 根据模型 Topic 创建表单
        model = Topic
        # 创建的表单中只包含字段 text
        fields = ['text']
        # 让 Django 不要为字段 text 生成标签
        labels = {'text': ''}

2) URL 模式 new_topic

添加页面 new_topic 的 URL 模式到 learning_logs/ urls.py 中:

--snip--
urlpatterns = [
    --snip--
    # 用于添加新主题的页面
    path('new_topic/', views.new_topic, name='new_topic'),
]

3) 视图函数 new_topic()

函数 new_topic() 需要处理两种情形:

  1. 刚进入 new_topic 页面(在这种情况下应显示空表单)
  2. 对提交的表单数据进行处理,并将用户重定向到页面 topics
GET 请求和 POST 请求

创建 Web 应用程序时,将用到的两种主要请求类型是 GET 请求和 POST 请求(还有一些其他类型的请求,但本项目没有使用)

  • 对于只是从服务器读取数据的页面,使用 GET 请求
  • 在用户需要通过表单提交信息时,通常使用POST请求(处理所有表单时,都将指定使用 POST 方法)

views.py

# 导入了函数 redirect(),用户提交主题后将使用这个函数重定向到页面 topics
from django.shortcuts import render, redirect

from .models import Topic
# 导入了刚创建的表单 TopicForm
from .forms import TopicForm

--snip--
# 函数 new_topic() 将请求对象作为参数
# 用户初次请求该页面时,其浏览器将发送 GET 请求;用户填写并提交表单时,其浏览器将发送 POST 请求
# 根据请求的类型,可确定用户请求的是空表单(GET 请求)还是要求对填写好的表单进行处理(POST 请求)
def new_topic(request):
    """添加新主题"""
    # 此处的测试确定请求方法是 GET 还是 POST
    # - 如果请求方法不是 POST,请求就可能是 GET,因此需要返回一个空表单(即便请求是其他类型的,返回空表单也不会有任何问题)
    if request.method != 'POST':
        # 未提交数据,创建一个新表单
        # 创建一个 TopicForm 实例,将其赋给变量 form
        # 由于实例化 TopicForm 时没有指定任何实参,Django 将创建一个空表单,供用户填写
        form = TopicForm()
    # 如果请求方法为 POST,将执行 else 代码块,对提交的表单数据进行处理
    else:
        # POST 提交的数据:对数据进行处理
        # 我们使用用户输入的数据(存储在 request.POST 中)创建一个 TopicForm 实例,这样对象form将包含用户提交的信息
        form = TopicForm(data=request.POST)
        # 要将提交的信息保存到数据库,必须先通过检查确定它们是有效的
        # 方法 is_valid() 核实用户填写了所有必不可少的字段(表单字段默认都是必不可少的),且输入的数据与要求的字段类型一致(例如,字段 text 少于200字符,这是第18章在 models.py 中指定的)
        # 这种自动验证避免了我们去做大量的工作
        if form.is_valid():
            # 如果所有字段都有效,就可调用 save(),将表单中的数据写入数据库
            form.save()
            # 函数 redirect 将视图名作为参数,并将用户重定向到这个视图
            # 保存数据后,就可离开这个页面了,故使用 redirect() 将用户的浏览器重定向到页面 topics
            # - 在页面 topics 中,用户将在主题列表中看到他刚输入的主题
            return redirect('learning_logs:topics')

    # 显示空表单或指出表单数据无效
    # 通过上下文字典将 form 表单发送给模板
    # 我们在这个视图函数的末尾定义了变量 context,并使用稍后将创建的模板 new_topic.html 来渲染页面
    # - 这些代码不在 if 代码块内,因此无论是用户刚进入 new_topic 页面还是提交的表单数据无效,这些代码都将执行
    # - 用户提交的表单数据无效时,将显示一些默认的错误消息,帮助用户提供有效的数据
    context = {'form': form}
    return render(request, 'learning_logs/new_topic.html', context)

4) 模板 new_topic

new_topic.html

<!--这个模板继承了 base.html,因此其基本结构与项目“学习笔记”的其他页面相同-->
{% extends "learning_logs/base.html" %}

{% block content %}

  <p>Add a new topic:</p>

<!--定义了一个 HTML 表单-->
<!--实参 action 告诉服务器将提交的表单数据发送到哪里(这里将它发回给视图函数 new_topic() )-->
<!--实参 method 让浏览器以 POST 请求的方式提交数据-->
  <form action="{% url 'learning_logs:new_topic' %}" method='post'>
<!--Django 使用模板标签 {% csrf_token %} 来防止攻击者利用表单来获得对服务器未经授权的访问(这种攻击称为跨站请求伪造)-->
    {% csrf_token %}
<!--此处显示表单,从中可知 Django 使得完成显示表单等任务有多简单:只需包含模板变量 {{ form.as_p }},就可让 Django 自动创建显示表单所需的全部字段-->
<!--修饰符 as_p 让 Django 以段落格式渲染所有表单元素,这是一种整洁地显示表单的简单方式-->
    {{ form.as_p }}
<!--Django 不会为表单创建提交按钮,因此我们定义了一个-->
    <button name="submit">Add topic</button>
  </form>

{% endblock content %}

5) 链接到页面 new_topic

topics.html

{% extends "learning_logs/base.html" %}

{% block content %}

  <p>Topics</p>

  <ul>
    --snip--
  </ul>
<!--在页面 topics 中添加到页面 new_topic 的链接-->
  <a href="{% url 'learning_logs:new_topic' %}">Add a new topic</a>

{% endblock content %}

请添加图片描述

(2) 添加新条目

我们将再次定义 URL,编写视图函数和模板,并且链接到添加新条目的页面,但在此之前,需要在 forms.py 中再添加一个类

1) 用于添加新条目的表单

forms.py

# 导入模块 forms 以及要使用的模型 Topic 和 Entry
from django import forms

from .models import Topic, Entry


class TopicForm(forms.ModelForm):
    --snip--

# 新类 EntryForm 继承了 forms.ModelForm
class EntryForm(forms.ModelForm):
    # Meta 类指出了表单基于的模型以及要在表单中包含哪些字段
    class Meta:
        model = Entry
        fields = ['text']
        # 给字段 'text' 指定了标签 'Entry:'
        labels = {'text': 'Entry:'}
        # 定义了属性 widgets
        # - 小部件(widget)是一个 HTML 表单元素,如单行文本框、多行文本区域或下拉列表
        # - 通过设置属性 widgets,可覆盖 Django 选择的默认小部件
        # - 通过让 Django 使用 forms.Textarea,我们定制了字段 'text' 的输入小部件,将文本区域的宽度设置为80列,而不是默认的40列(这给用户提供了足够的空间来编写有意义的条目)
        widgets = {'text': forms.Textarea(attrs={'cols': 80})}

2) URL 模式 new_entry

在用于添加新条目的页面的 URL 模式中,需要包含实参 topic_id,因为条目必须与特定的主题相关联

urls.py

--snip--
urlpatterns = [
    --snip--
    # 用于添加新条目的页面
    # 这个 URL 模式与形如 http://localhost:8000/new_entry/id/ 的 URL 匹配,其中的 id 是一个与主题 ID 匹配的数
    # - 代码 <int:topic_id> 捕获一个数值,并将其赋给变量 topic_id
    # - 请求的 URL 与这个模式匹配时,Django 将请求和主题 ID 发送给函数new_entry()
    path('new_entry/<int:topic_id>', views.new_entry, name='new_entry'),
]

3) 视图函数 new_entry()

views.py

from django.shortcuts import render, redirect

from .models import Topic
# 导入了创建的表单 TopicForm 和 EntryForm
from .forms import TopicForm, EntryForm

--snip--
# new_entry() 的定义包含形参 topic_id,用于存储从 URL 中获得的值
def new_entry(request, topic_id):
    """在特定主题中添加新条目"""
    # 渲染页面和处理表单数据时,都需要知道针对的是哪个主题,因此使用 topic_id 来获得正确的主题
    topic = Topic.objects.get(id=topic_id)

    # 检查请求方法是 POST 还是 GET
    if request.method != 'POST':
        # 未提交数据:创建一个新表单
        # 如果是 GET 请求,就执行 if 代码块,创建一个空的 EntryForm 实例
        form = EntryForm()
    else:
        # POST 提交的数据:对数据进行处理
        # 如果请求方法为 POST,就对数据进行处理:创建一个 EntryForm 实例,使用 request 对象中的 POST 数据来填充它
        form = EntryForm(data=request.POST)
        # 检查表单是否有效
        if form.is_valid():
            # 如果有效,就设置条目对象的属性 topic,再将条目对象保存到数据库
            # 调用 save() 时,传递实参 commit=False,让 Django 创建一个新的条目对象,并将其赋给 new_entry,但不保存到数据库中
            new_entry = form.save(commit=False)
            # 将 new_entry 的属性 topic 设置为在这个函数开头从数据库中获取的主题
            new_entry.topic = topic
            # 调用 save() 且不指定任何实参
            # - 这将把条目保存到数据库,并将其与正确的主题相关联
            new_entry.save()
            # 调用 redirect(),它要求提供两个参数:要重定向到的视图和要给视图函数提供的参数
            # - 这里重定向到 topic(),而这个视图函数需要参数 topic_id
            # - 视图函数 topic() 渲染新增条目所属主题的页面,其中的条目列表包含新增的条目
            return redirect('learning_logs:topic', topic_id=topic_id)

    # 显示空表单或指出表单数据无效
    # 创建了一个上下文字典,并使用模板 new_entry.html 渲染页面
    # - 这些代码将在用户刚进入页面或提交的表单数据无效时执行
    context = {'topic': topic, 'form': form}
    return render(request, 'learning_logs/new_entry.html', context=context)

4) 模板 new_entry

new_entry.html

{% 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>
<!--表单的实参 action 包含 URL 中的 topic_id 值,让视图函数能够将新条目关联到正确的主题-->
    <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 %}

5) 链接到页面 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_topic' topic.id%}">Add new entry</a>
  </p>

  <ul>
  --snip--
  </ul>

{% endblock content %}

请添加图片描述

(3) 编辑条目

1) URL 模式 edit_entry

这个页面的 URL 需要传递要编辑的条目的 ID

urls.py

--snip--
urlpatterns = [
    --snip--
    # 用于编辑条目的页面
    # 在 URL(如http://localhost:8000/edit_entry/1/)中传递的 ID 存储在形参 entry_id 中
    # 这个 URL 模式将与其匹配的请求发送给视图函数 edit_entry()
    path('edit_entry/<int:entry_id>', views.edit_entry, name='edit_entry'),
]

2) 视图函数 edit_entry()

页面 edit_entry 收到 GET 请求时,edit_entry() 将返回一个表单,让用户能够对条目进行编辑;收到 POST 请求(条目文本经过修订)时,则将修改后的文本保存到数据库:

views.py

from django.shortcuts import render, redirect

# 导入与所需数据相关联的模型 Topic 和 Entry
from .models import Topic, Entry
from .forms import TopicForm, EntryForm
--snip--

def edit_entry(request, entry_id):
    """编辑既有条目"""
    # 获取用户要修改的条目对象以及与其相关联的主题
    entry = Entry.objects.get(id=entry_id)
    topic = entry.topic

    if request.method != 'POST':
        # 初次请求,使用当前条目填充表单
        # 使用实参 instance=entry 创建一个 EntryForm 实例
        # - 这个实参让 Django 创建一个表单,并使用既有条目对象中的信息填充它
        # - 用户将看到既有的数据,并且能够编辑
        form = EntryForm(instance=entry)
    else:
        # POST 提交的数据:对数据进行处理
        # 传递实参 instance=entry 和 data=request.POST
        # 让 Django 根据既有条目对象创建一个表单实例,并根据 request.POST 中的相关数据对其进行修改
        form = EntryForm(instance=entry, data=request.POST)
        # 检查表单是否有效
        if form.is_valid():
            # 调用 save() 且不指定任何实参,因为条目已关联到特定的主题
            form.save()
            # 重定向到显示条目所属主题的页面,用户将在其中看到其编辑的条目的新版本
            return redirect('learning_logs:topic', topic_id=topic.id)

    # 如果要显示表单让用户编辑条目或者用户提交的表单无效,就创建上下文字典并使用模板 edit_entry.html 渲染页面
    context = {'entry': entry, 'topic': topic, 'form': form}
    return render(request, 'learning_logs/edit_entry.html', context=context)

3) 模板 edit_entry

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>

<!--实参 action 将表单发送给函数 edit_entry() 处理-->
<!--在标签 {% url %} 中,将条目 ID 作为一个实参,让视图函数 edit_entry() 能够修改正确的条目对象-->
    <form action="{% url 'learning_logs:edit_entry' entry.id %}" method="post">
      {% csrf_token %}
      {{ form.as_p }}
<!--将提交按钮的标签设置成 Save changes,旨在提醒用户:单击该按钮将保存所做的编辑,而不是创建一个新条目-->
      <button name="submit">Save changes</button>
    </form>

{% endblock content %}

4) 链接到页面 edit_entry

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>
<!--在循环中,使用模板标签 {%url %} 根据 URL 模式 edit_entry 和当前条目的 ID 属性(entry.id)来确定 URL-->
<!--链接文本为 Edit entry,它出现在页面中每个条目的后面-->
        <a href="{% url 'learning_logs:edit_entry' entry.id %}">Edit entry</a>
      </p>
    </li>
--snip--

每个条目都有一个用于编辑的链接

2. 创建用户账户

我们将新建一个应用程序,其中包含与处理用户账户相关的所有功能

  • 这个应用程序将尽可能使用 Django 自带的用户身份验证系统来完成工作
  • 本节还将对模型 Topic 稍做修改,让每个主题都归属于特定用户

(1) 应用程序 users

首先使用命令 startapp 创建一个名为 users 的应用程序:

python manage.py startapp users

请添加图片描述

(2) 将 users 添加到 settings.py 中

在 settings.py 中,需要将这个新的应用程序添加到 INSTALLED_APPS 中:

settings.py

--snip--
INSTALLED_APPS = [
    # My apps
    'learning_logs',
    # 这样,Django 将把应用程序 users 包含到项目中
    'users',

    # Default django apps
    --snip--
]
--snip--

(3) 包含 users 的 URL

修改项目根目录中的 urls.py,使其包含将为应用程序 users 定义的 URL:

urls.py

from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    # 包含应用程序 users 中的文件 urls.py
    # - 这行代码与任何以单词 users 打头的 URL(如 http://localhost:8000/users/login/)都匹配
    path('users/', include('users.urls')),
    path('', include('learning_logs.urls')),
]

(4) 登录页面

我们将使用 Django 提供的默认视图 login,因此这个应用程序的 URL 模式稍有不同

在目录 learning_log/users/ 中,新建一个名为 urls.py 的文件,并在其中添加如下代码:

urls.py

"""为应用程序 users 定义 URL 模式"""

# 导入函数 path 和 include,以便包含 Django 定义的一些默认的身份验证 URL
# - 这些默认的 URL 包含具名的 URL 模式,如 'login' 和 'logout'
from django.urls import path, include

# 我们将变量 app_name 设置成 'users',让 Django 能够将这些 URL 与其他应用程序的 URL 区分开来
# - 即便是 Django 提供的默认 URL,将其包含在应用程序 users 的文件中后,也可通过命名空间 users 进行访问
app_name = 'users'
urlpatterns = [
    # 包含默认的身份验证 URL
    # 登录页面的 URL 模式与 URL http://localhost:8000/users/login/ 匹配
    # - 这个 URL 中的单词 users 让 Django 在 users/urls.py 中查找,而单词 login 让它将请求发送给 Django的 默认视图 login
    path('', include('django.contrib.auth.urls')),
]

1) 模板 login.html

用户请求登录页面时,Django 将使用一个默认的视图函数,但我们依然需要为这个页面提供模板

默认的身份验证视图在文件夹 registration 中查找模板,因此我们需要创建这个文件夹

  • 为此,在目录 learning_log/users/ 中新建一个名为 templates 的目录,再在这个目录中新建一个名为 registration 的目录,再在其中新建模板 login.html

login.html

<!--这个模板继承了 base.html,旨在确保登录页面的外观与网站的其他页面相同-->
<!--请注意,一个应用程序中的模板可继承另一个应用程序中的模板-->
{% extends "learning_logs/base.html" %}

{% block content %}

<!--如果设置表单的 errors 属性,就显示一条错误消息,指出输入的用户名密码对与数据库中存储的任何用户名密码对都不匹配-->
  {% if form.errors %}
    <p>Your username and password didn't match. Please try again.</p>
  {% endif %}

<!--我们要让登录视图对表单进行处理,因此将实参 action 设置为登录页面的 URL-->
  <form method="post" action="{% url 'users:login' %}">
    {% csrf_token %}
<!--登录视图将一个表单发送给模板,在模板中,我们显示这个表单并添加一个提交按钮-->
    {{ form.as_p }}

    <button name="submit">Log in</button>
<!--包含了一个隐藏的表单元素 'next',其中的实参 value 告诉 Django 在用户成功登录后将其重定向到什么地方(在本例中,用户将返回主页)-->
    <input type="hidden" name="next" value="{% url 'learning_logs:index' %}" />
  </form>

{% endblock content %}

2) 链接到登录页面

在 base.html 中添加到登录页面的链接,让所有页面都包含它

用户已登录时,我们不想显示这个链接,因此将它嵌套在一个{% if %}标签中

在 Django 身份验证系统中,每个模板都可使用变量 user

  • 该变量有一个 is_authenticated 属性:如果用户已登录,该属性将为True,否则为False
  • 这让你能够向已通过身份验证的用户显示一条消息,而向未通过身份验证的用户显示另一条消息

base.html

<p>
    <a href="{% url 'learning_logs:index'%}">Learning Log</a> -
    <a href="{% url 'learning_logs:topics'%}">Topics</a> -
<!--向已登录的用户显示问候语-->
    {% if user.is_authenticated %}
<!--对于已通过身份验证的用户,还设置了属性 username,这里使用该属性来个性化问候语,让用户知道自己已登录-->
      Hello, {{ user.username }}.
    {% else %}
<!--对于尚未通过身份验证的用户,显示到登录页面的链接-->
      <a href="{% url 'users:login' %}">Log in</a>
    {% endif %}
</p>

{% block content %}{% endblock content %}

请添加图片描述

(5) 注销

1) 在 base.html 中添加注销链接

在 base.html 中添加注销链接,让每个页面都包含它

base.html

--snip--
    {% if user.is_authenticated %}
      Hello, {{ user.username }}.
<!--将注销链接放在 {%if user.is_authenticated %} 部分中,这样只有已登录的用户才能看到它-->
<!--默认的具名注销 URL 模式为 'logout'-->
      <a href="{% url 'users:logout' %}">Log out</a>
    {% else %}
      --snip--

2) 注销确认页面

默认的注销视图使用模板 templates/registration/ logged_out.html 渲染注销确认页面

logged_out.html

{% extends "learning_logs/base.html" %}

{% block content %}
  <p>You have been logged out. Thank you for visiting!</p>
{% endblock content %}

请添加图片描述

(6) 注册页面

我们将使用 Django 提供的表单 UserCreationForm,但编写自己的视图函数和模板

1) 注册页面的 URL 模式

users/urls.py

"""为应用程序 users 定义 URL 模式"""

from django.urls import path, include

# 从 users 中导入模块 views,因为我们将为注册页面编写视图函数
from . import views

app_name = 'users'
urlpatterns = [
    # 包含默认的身份验证 URL
    path('', include('django.contrib.auth.urls')),
    # 注册页面
    # 注册页面的 URL 模式与 URL http://localhost:8000/users/register/ 匹配,并将请求发送给即将编写的函数 register()
    path('register/', views.register, name='register'),
]

2) 视图函数 register()

在注册页面首次被请求时,视图函数 register() 需要显示一个空的注册表单,并在用户提交填写好的注册表单时对其进行处理

如果注册成功,这个函数还需让用户自动登录

users/views.py

# 首先导入函数 render() 和 redirect(),然后导入函数 login(),以便在用户正确填写了注册信息时让其自动登录
from django.shortcuts import render, redirect
from django.contrib.auth import login
# 导入了默认表单 UserCreationForm
from django.contrib.auth.forms import UserCreationForm

def register(request):
    """注册新用户"""
    # 导入了默认表单 UserCreationForm
    if request.method != 'POST':
        # 显示空的注册表单
        # 创建一个 UserCreationForm 实例,且不给它提供任何初始数据
        form = UserCreationForm()
    else:
        # 处理填写好的表单
        # 如果响应的是 POST 请求,就根据提交的数据创建一个 UserCreationForm 实例
        # - 有效是指用户名未包含非法字符,输入的两个密码相同,以及用户没有试图做恶意的事情
        form = UserCreationForm(data=request.POST)

        # 检查表单数据是否有效
        if form.is_valid():
            # 如果提交的数据有效,就调用表单的方法 save(),将用户名和密码的散列值保存到数据库中
            # 方法 save() 返回新创建的用户对象,我们将它赋给了 new_user
            new_user = form.save()
            # 让用户自动登录,再重定向到主页
            # 保存用户的信息后,调用函数 login() 并传入对象 request 和 new_user,为用户创建有效的会话,从而让其自动登录
            login(request, new_user)
            # 最后,将用户重定向到主页,而主页的页眉中显示了一条个性化的问候语,让用户知道注册成功了
            return redirect('learning_logs:index')

    # 显示空表单或指出表单无效
    context = {'form': form}
    # 我们渲染了注册页面:它要么显示一个空表单,要么显示提交的无效表单
    return render(request, 'registration/register.html', context)

3) 注册模板

learning_log/users/templates/registration/register.html

{% extends "learning_logs/base.html" %}

{% block content %}

  <form method="post" action="{% url 'users:register' %}">
    {% csrf_token %}
<!--这里也使用了方法 as_p,让 Django 在表单中正确地显示所有的字段,包括错误消息——如果用户没有正确地填写表单-->
    {{ form.as_p }}
    <button name="submit">Register</button>
    <input type="hidden" name="next" value="{% url 'learning_logs:index' %}">
  </form>

{% endblock content %}

4) 链接到注册页面

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--

请添加图片描述
请添加图片描述

3. 让用户拥有自己的数据

用户应该能够输入其专有的数据,因此我们将创建一个系统,确定各项数据所属的用户,再限制对页面的访问,让用户只能使用自己的数据

本节将修改模型 Topic,让每个主题都归属于特定用户,这也将影响条目,因为每个条目都属于特定的主题

(1) 使用 @login_required 限制访问

Django 提供了装饰器 @login_required,让你能够轻松地只允许已登录用户访问某些页面

装饰器(decorator)是放在函数定义前面的指令,Python 在函数运行前根据它来修改函数代码的行为

1) 限制访问显示所有主题的页面

每个主题都归特定用户所有,因此应只允许已登录的用户请求显示所有主题的页面

learning_logs/views.py

from django.shortcuts import render, redirect
# 导入函数 login_required()
from django.contrib.auth.decorators import login_required

from .models import Topic, Entry
--snip--

# 将 login_required() 作为装饰器应用于视图函数 topics() —— 在它前面加上符号 @ 和 login_required,让 Python 在运行 topics() 的代码之前运行 login_required() 的代码
# login_required() 的代码检查用户是否已登录。仅当用户已登录时,Django 才运行 topics() 的代码;如果用户未登录,就重定向到登录页面
@login_required
def topics(request):
    """显示所有的主题"""
    --snip--

为实现这种重定向,需要在 settings.py 末尾添加如下代码,让 Django 知道到哪里去查找登录页面

settings.py

--snip--

# My settings
LOGIN_URL = 'users:login'

现在,如果未登录的用户请求装饰器 @login_required 保护的页面,Django 将重定向到 settings.py 中的 LOGIN_URL 指定的 URL
请添加图片描述

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

在项目“学习笔记”中,将不限制对主页和注册页面的访问,并限制对其他所有页面的访问

learning_logs/views.py

--snip--
# 对除 index() 外的每个视图都应用了装饰器 @login_required
# - 如果你在未登录的情况下尝试访问这些页面,将被重定向到登录页面
# - 另外,你还不能单击到 new_topic 等页面的链接
# - 如果你输入 URL http://localhost:8000/new_topic/,将被重定向到登录页面
# - 对于所有与私有用户数据相关的 URL,都应限制访问
@login_required
def topics(request):
    --snip--

@login_required
def topic(request, topic_id):
    --snip--

@login_required
def new_topic(request):
    --snip--

@login_required
def new_entry(request, topic_id):
    --snip--

@login_required
def edit_entry(request, entry_id):
    --snip--

(2) 将数据关联到用户

现在,需要将数据关联到提交它们的用户,只需将最高层的数据关联到用户,更低层的数据就会自动关联到用户

  • 例如,在项目“学习笔记”中,应用程序的最高层数据是主题,而所有条目都与特定主题相关联,只要每个主题都归属于特定用户,就能确定数据库中每个条目的所有者
  1. 下面来修改模型 Topic,在其中添加一个关联到用户的外键
  2. 这样做之后,必须对数据库进行迁移
  3. 最后,必须修改某些视图,使其只显示与当前登录的用户相关联的数据

1) 修改模型 Topic

models.py

from django.db import models
# 导入 django.contrib.auth 中的模型 User
from django.contrib.auth.models import User

class Topic(models.Model):
    """用户学习的主题"""
    text = models.CharField(max_length=200)
    date_added = models.DateTimeField(auto_now_add=True)
    # 在 Topic 中添加字段 owner,它建立到模型 User 的外键关系
    # 用户被删除时,所有与之相关联的主题也会被删除
    owner = models.ForeignKey(User, on_delete=models.CASCADE())
   
    def __str__(self):
        """返回模型的字符串表示"""
        return self.text

class Entry(models.Model):
	--snip--

2) 确定当前有哪些用户

迁移数据库时,Django 将对数据库进行修改,使其能够存储主题和用户之间的关联

为执行迁移,Django 需要知道该将各个既有主题关联到哪个用户

  • 最简单的办法是,将既有主题都关联到同一个用户,如超级用户
  • 为此,需要知道该用户的 ID

启动一个 Django shell 会话,查看已创建的所有用户的 ID:

(ll_env) chengrui@chengruis-MacBook-Air learning_log % python manage.py shell
Python 3.10.2 (v3.10.2:a58ebcc701, Jan 13 2022, 14:50:16) [Clang 13.0.0 (clang-1300.0.29.30)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
(InteractiveConsole)
# 在 shell 会话中导入模型 User
>>> from django.contrib.auth.models import User
# 查看到目前为止都创建了哪些用户
>>> User.objects.all()
<QuerySet [<User: ll_admin>, <User: Sally>]>
# 遍历用户列表并打印每位用户的用户名和 ID
# Django 询问要将既有主题关联到哪个用户时,我们将指定其中一个 ID 值
>>> for user in User.objects.all():
...     print(user.username, user.id)
... 
# 输出中列出了两个用户:ll_admin 和 Sally
ll_admin 1
Sally 2
>>>

3) 迁移数据库

知道用户 ID 后,就可迁移数据库了,Python 将询问你是要暂时将模型 Topic 关联到特定用户,还是在文件 models.py 中指定默认用户,请选择第一个选项

# 首先执行命令 makemigrations
(ll_env) chengrui@chengruis-MacBook-Air learning_log % python manage.py makemigrations learning_logs
# Django 指出你试图给既有模型 Topic 添加一个必不可少(不可为空)的字段,而该字段没有默认值
It is impossible to add a non-nullable field 'owner' to topic without specifying a default. This is because the database needs something to populate existing rows.
# Django 提供了两种选择:要么现在提供默认值,要么退出并在 models.py 中添加默认值
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 manually define a default value in models.py.
# 我们选择了第一个选项
Select an option: 1
# Django 让我们输入默认值
Please enter the default value as valid Python.
The datetime and django.utils.timezone modules are available, so it is possible to provide e.g. timezone.now as a value.
Type 'exit' to exit this prompt
# 为将所有既有主题都关联到管理用户 ll_admin,我们输入用户 ID 值:1
# - 可以使用已创建的任何用户的 ID,而非必须是超级用户
>>> 1
# Django 使用用户 ID 值来迁移数据库,并生成了迁移文件0003_topic_owner.py,它在模型Topic中添加字段owner
Migrations for 'learning_logs':
  learning_logs/migrations/0003_topic_owner.py
    - Add field owner to topic

现在可以执行迁移了

(ll_env) chengrui@chengruis-MacBook-Air learning_log % python manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, learning_logs, sessions
Running migrations:
# Django应用新的迁移,结果一切顺利
  Applying learning_logs.0003_topic_owner... OK

在 shell 会话中验证迁移符合预期

# 从 learning_logs.models 中导入 Topic

>>> from learning_logs.models import Topic
# 遍历所有的既有主题,并打印每个主题及其所属的用户
>>> for topic in Topic.objects.all():
...     print(topic, topic.owner)
... 
# 现在每个主题都属于用户 ll_admin
Chess ll_admin
Rock Climbing ll_admin
Test Topic 030700 ll_admin
Test Topic 030701 ll_admin
Test Topic 030702 ll_admin
>>> 

你可以重置数据库而不是迁移它,但如果这样做,既有的数据都将丢失

  • 一种不错的做法是,学习如何在迁移数据库的同时确保用户数据的完整性
  • 如果你确实想要一个全新的数据库,可执行命令 python manage.py flush,这将重建数据库的结构
  • 如果这样做,就必须重新创建超级用户,且原来的所有数据都将丢失

(3) 只允许用户访问自己的主题

views.py

--snip--
@login_required
def topics(request):
    """显示所有的主题"""
    # 用户登录后,request 对象将有一个 user 属性,其中存储了有关该用户的信息
    # 查询 Topic.objects.filter(owner=request.user) 让 Django 只从数据库中获取 owner 属性为当前用户的 Topic 对象
    topics = Topic.objects.filter(owner=request.user).order_by('date_added')
    context = {'topics': topics}
    return render(request, 'learning_logs/topics.html', context)
--snip--

请添加图片描述

(4) 保护用户的主题

我们还没有限制对显示单个主题的页面的访问,因此任何已登录的用户都可输入类似于 http://localhost:8000/topics/1/ 的 URL,来访问显示相应主题的页面

为修复这种问题,我们在视图函数 topic() 获取请求的条目前执行检查

views.py

from django.shortcuts import render, redirect
from django.contrib.auth.decorators import login_required
# 服务器上没有请求的资源时,标准的做法是返回404响应
# 这里导入了异常 Http404,并在用户请求其不应查看的主题时引发这个异常
from django.http import Http404

--snip--
@login_required
def topic(request, topic_id):
    """显示单个主题及其所有的条目"""
    topic = Topic.objects.get(id=topic_id)
    # 确认请求的主题属于当前用户
    # 收到主题请求后,在渲染页面前检查该主题是否属于当前登录的用户
    # 如果请求的主题不归当前用户所有,就引发 Http404 异常,让Django返回一个404错误页面
    if topic.owner != request.user:
        raise Http404

    entries = topic.entry_set.order_by('-date_added')
    context = {'topic': topic, 'entries': entries}
    return render(request, 'learning_logs/topic.html', context)

请添加图片描述

(5) 保护页面 edit_entry

页面 edit_entry 的 URL 形式为 http://localhost:8000/edit_entry/entry_id/,其中 entry_id 是一个数

下面来保护这种页面,禁止用户通过输入类似于前面的 URL 来访问其他用户的条目

views.py

--snip--
@login_required
def edit_entry(request, entry_id):
    """编辑既有条目"""
    # 获取用户要修改的条目对象以及与其相关联的主题
    entry = Entry.objects.get(id=entry_id)
    topic = entry.topic
    # 检查主题的所有者是否是当前登录的用户,如果不是,就引发 Http404 异常
    if topic.owner != request.user:
        raise Http404

    if request.method != 'POST':
    	--snip--

(6) 将新主题关联到当前用户

当前,用于添加新主题的页面存在问题——没有将新主题关联到特定用户

  • 如果你尝试添加新主题,将看到错误消息 IntegrityError,指出 learning_logs_topic.user_id 不能为 NULL(NOT NULL constraint failed:learning_logs_topic.owner_id),即创建新主题时,必须给 owner 字段指定值

我们可通过 request 对象获悉当前用户,然后将新主题关联到当前用户

views.py

--snip--
@login_required
def new_topic(request):
    """添加新主题"""
    if request.method != 'POST':
        # 未提交数据,创建一个新表单
        form = TopicForm()
    else:
        # POST 提交的数据:对数据进行处理
        form = TopicForm(data=request.POST)
        if form.is_valid():
            # 首先调用 form.save() 并传递实参 commit=False,因为要先修改新主题,再将其保存到数据库
            new_topic = form.save(commit=False)
            # 接下来,将新主题的 owner 属性设置为当前用户
            new_topic.owner = request.user
            # 最后,对刚定义的主题实例调用 save()
            new_topic.save()
            return redirect('learning_logs:topics')

    # 显示空表单或指出表单数据无效
    context = {'form': form}
    return render(request, 'learning_logs/new_topic.html', context)
--snip--

请添加图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值