Django2 和 Channel2 实践教程(二)
四、用于在线接收订单的结账系统
在第三章为我们的项目打下了数据库基础,在本章中,我们将在此基础上构建剩余的模型,以存储用户生成的数据。在我们的例子中,它是一个结账系统,这对电子商务网站非常重要。
我们将讨论以下主题:
-
定制
User模型 -
注册和登录流程
-
Django 中的 CRUD 1 视图
-
中间件组件
-
决哥小部件
-
在管理界面中显示复杂数据
用户模型
无论我们要创建什么样的模型来存储用户生成的数据,都很可能与User模型相关联。User模型包含网站用户登录所需的所有数据。这个模型也是通常在注册阶段创建的模型。
Django 有一个内置的User模型,位于django.contrib.auth.models.User。有几个字段可用:
-
username和password作为登录系统的凭证。 -
first_name、last_name、email均为可选项,为描述性字段。 -
groups是与用户所属的所有Group模型的关系。 -
user_permissions是与用户拥有的所有Permission模型的关系。 -
is_staff是表示用户是否可以使用 Django admin 的标志。 -
is_superuser是一个标志,如果为True,则允许用户做任何事情。 -
is_active代表,在最简单的情况下,用户是否可以登录。
User模型也有一些常用的方法:
-
set_password()和check_password()用于设置和检查密码。 -
has_perm()和has_perms()用于检查权限。 -
objects.create_user()创建用户。
这个模型还有许多其他的方法、属性和特性,但是这些对于最常见的操作来说已经足够了。在 Django 文档中你会找到所有的细节。
Django 还提供了用您在 Django 项目中指定的模型覆盖内置的User模型的选项,只要它继承自基类User并实现了一些必需的功能。我们现在要做的是在继续之前,因为
-
我们产品的数据可以很容易地重新导入。
-
我们现有的模型还没有一个与
User模型相关联。 -
Django 默认拥有一个
username字段并不是我们所需要的。我们希望email字段成为每个人的用户标识符,没有例外。
鉴于我们的意图是移除username字段,除了重新定义User模型,我们还需要重新定义内置的UserManager。下面是我们将要使用的代码:
from django.contrib.auth.models import (
AbstractUser,
BaseUserManager,
)
...
...
class UserManager(BaseUserManager):
use_in_migrations = True
def _create_user(self, email, password,**extra_fields):
if not email:
raise ValueError("The given email must be set")
email = self.normalize_email(email)
user = self.model(email=email, **extra_fields)
user.set_password(password)
user.save(using=self._db)
return user
def create_user(self, email, password=None, **extra_fields):
extra_fields.setdefault("is_staff", False)
extra_fields.setdefault("is_superuser", False)
return self._create_user(email, password, **extra_fields)
def create_superuser(self, email, password, **extra_fields):
extra_fields.setdefault("is_staff", True)
extra_fields.setdefault("is_superuser", True)
if extra_fields.get("is_staff") is not True:
raise ValueError(
"Superuser must have is_staff=True."
)
if extra_fields.get("is_superuser") is not True:
raise ValueError(
"Superuser must have is_superuser=True."
)
return self._create_user(email, password, **extra_fields)
class User(AbstractUser):
username = None
email = models.EmailField('email address', unique=True)
USERNAME_FIELD = 'email'
REQUIRED_FIELDS = []
objects = UserManager()
通过将它添加到models.py文件中,我们能够使用我们自己版本的User模型,并确保 Django 可以对它进行操作。最后,我们需要给settings.py添加一个配置指令:
AUTH_USER_MODEL = "main.User"
这足够所有的基本系统工作了。然而,Django admin 将不起作用,因为标准配置要求存在username字段。为了解决这个问题,我们需要为自定义用户定义一个 Django 管理处理程序。将此添加到您的main/admin.py文件中:
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
...
...
@admin.register(models.User)
class UserAdmin(DjangoUserAdmin):
fieldsets = (
(None, {"fields": ("email", "password")}),
(
"Personal info",
{"fields": ("first_name", "last_name")},
),
(
"Permissions",
{
"fields": (
"is_active",
"is_staff",
"is_superuser",
"groups",
"user_permissions",
)
},
),
(
"Important dates",
{"fields": ("last_login", "date_joined")},
),
)
add_fieldsets = (
(
None,
{
"classes": ("wide",),
"fields": ("email", "password1", "password2"),
},
),
)
list_display = (
"email",
"first_name",
"last_name",
"is_staff",
)
search_fields = ("email", "first_name", "last_name")
ordering = ("email",)
这里,我们重新定义了 Django admin 的配置,以适应我们的定制User模型。具体来说,我们修改了一些类变量的内容。在前一章中,我们已经熟悉了其中的一些变量,但是fieldsets和add_fieldsets是新的。
这两个元组指定了在“change model”页面和“add model”页面中显示哪些字段,以及页面部分的名称。如果这些都不存在,对于任何其他模型,Django 将使每个字段都可变。然而,内置的DjangoUserAdmin引入了一些需要撤销的对默认行为的定制。
最后,我们需要重置我们的数据库和迁移。这是一个破坏性的操作,但是,不幸的是,改变User模型需要我们应用这些不可逆的改变。如果你在一个团队中工作,这将影响到每个人,因此在这样做之前找到一致意见是很重要的。
我们要做的是删除所有迁移:
$ rm main/migrations/0*
rm 'booktime/main/migrations/0001_initial.py'
rm 'booktime/main/migrations/0002_productimage.py'
rm 'booktime/main/migrations/0003_producttag.py'
rm 'booktime/main/migrations/0004_productimage_thumbnail.py'
rm 'booktime/main/migrations/0005_productname_40.py'
rm 'booktime/main/migrations/0005_producttagname_40.py'
rm 'booktime/main/migrations/0006_merge_40.py'
rm 'booktime/main/migrations/0007_productname_capitalize.py'
rm 'booktime/main/migrations/0008_move_m2m.py'
直接使用 psql 客户端重置数据库,如下所示。如果您正在使用另一个数据库,请使用适当的命令行客户端。如果您使用的是 SQLite,删除数据库文件就可以了。在运行以下命令之前,用您的数据库名称替换单词booktime。
$ psql postgres
psql (9.6.8)
Type "help" for help.
postgres=# DROP DATABASE booktime;
DROP DATABASE
postgres=# CREATE DATABASE booktime;
CREATE DATABASE
postgres=# \q
重新创建初始迁移,然后应用它:
$ ./manage.py makemigrations
Migrations for 'main':
main/migrations/0001_initial.py
- Create model User
- Create model Product
- Create model ProductImage
- Create model ProductTag
- Add field tags to product
$ ./manage.py migrate
Operations to perform:
Apply all migrations: admin, auth, contenttypes, main, sessions
Running migrations:
Applying contenttypes.0001_initial... OK
Applying contenttypes.0002_remove_content_type_name... OK
Applying auth.0001_initial... OK
Applying auth.0002_alter_permission_name_max_length... OK
Applying auth.0003_alter_user_email_max_length... OK
Applying auth.0004_alter_user_username_opts... OK
Applying auth.0005_alter_user_last_login_null... OK
Applying auth.0006_require_contenttypes_0002... OK
Applying auth.0007_alter_validators_add_error_messages... OK
Applying auth.0008_alter_user_username_max_length... OK
Applying auth.0009_alter_user_last_name_max_length... OK
Applying main.0001_initial... OK
Applying admin.0001_initial... OK
Applying admin.0002_logentry_remove_auto_add... OK
Applying sessions.0001_initial... OK
为了测试前面所有的步骤都工作了,并且能够再次使用 Django admin,您需要再次创建一个超级用户。请注意,与第三章不同,不需要用户名。
$ ./manage.py createsuperuser
Email address: admin@admin.com
Password:
Password (again):
Superuser created successfully.
创建注册页面
现在我们已经有了我们想要的用户模型类型,是时候向世界展示它了。从外部来看,我们想要构建的功能类似于我们在第二章中看到的联系人表单:一堆字段将被填充并提交,一封电子邮件将被发送。还有更多的内容:
-
验证提交
-
在我们的用户表中创建新用户
-
发送“欢迎”电子邮件
-
登录用户
-
在页面上显示成功消息
对于列表中的前三个动作,我们将创建一个表单。这三个动作不需要在视图级别执行,因为它们不与请求对象交互。仅使用表单层将使该功能更容易被其他视图重用。
一般来说,拥有可重用的代码是一件好事。请记住,Django 存在于更广泛的 Python 环境中。有时候,使用普通的 Python 函数是封装代码时唯一需要做的事情。
将以下代码添加到main/forms.py:
...
from django.contrib.auth.forms import (
UserCreationForm as DjangoUserCreationForm
)
from django.contrib.auth.forms import UsernameField
from . import models
...
class UserCreationForm(DjangoUserCreationForm):
class Meta(DjangoUserCreationForm.Meta):
model = models.User
fields = ("email",)
field_classes = {"email": UsernameField}
def send_mail(self):
logger.info(
"Sending signup email for email=%s",
self.cleaned_data["email"],
)
message = "Welcome{}".format(self.cleaned_data["email"])
send_mail(
"Welcome to BookTime",
message,
"site@booktime.domain",
[self.cleaned_data["email"]],
fail_silently=True,
)
我们刚刚向这个表单添加了一个发送电子邮件的方法。这将与我们在第二章中看到的ContactForm表单中已经使用的方法非常相似。
对于注册,我们可以重用一个名为UserCreationForm的 Django 类。它为我们提供了一个基本的注册表单,其中密码被询问两次(第二次用于确认)。在最新的 Django 2.0 版本中,当有一个定制的User模型时,内部的Meta类需要被覆盖。Django 2.1 将改变这一点。
在我们创建表单之后,需要一些更高级的功能。让我们在main/views.py文件中创建一个基于类的视图:
...
import logging
from django.contrib.auth import login, authenticate
from django.contrib import messages
...
logger = logging.getLogger(__name__)
class SignupView(FormView):
template_name = "signup.html"
form_class = forms.UserCreationForm
def get_success_url(self):
redirect_to = self.request.GET.get("next", "/")
return redirect_to
def form_valid(self, form):
response = super().form_valid(form)
form.save()
email = form.cleaned_data.get("email")
raw_password = form.cleaned_data.get("password1")
logger.info(
"New signup for email=%s through SignupView", email
)
user = authenticate(email=email, password=raw_password)
login(self.request, user)
form.send_mail()
messages.info(
self.request, "You signed up successfully."
)
return response
这个视图重用了FormView,一个我们已经用于 contact 表单的类。不同之处在于,我们在方法form_valid()中做了更多的事情。
首先,我们创建的注册表单是一种特殊类型的表单,叫做ModelForm。按照 Django 的说法,这是对Form的专门化,提交的数据可以自动存储在模型中,表单的字段默认为相关模型中的字段。当窗体上的方法save()被调用时,存储动作发生。
后来有几个函数叫做:authenticate()和login()。这些方法用于与 Django 认证系统交互。如果用户信息存储在应用数据库中,Django 身份验证可以基于数据库,或者以其他方式(例如 LDAP/Active Directory)进行。无论在幕后使用什么抽象,这两种方法总是以相同的方式工作。
authenticate()根据认证后端,确保传递的凭证有效。如果是,它返回一个User模型的实例。如果不是,则不返回任何值。然后将结果传递给login(),后者通过一个会话将用户与当前请求和未来请求相关联。
在方法form_valid()结束之前,有一个对messages.info()的调用,一个来自 Django 消息框架的方法。这对于在浏览器的下一次 HTTP 请求时向用户显示“flash”消息非常有用。
这个方法返回一个从超类form_valid()方法获得的响应对象,它通常是一个到 URL 的重定向,要么在类变量success_url中指定,要么由方法get_success_url()返回。我们在这里实现了get_success_url()来反映内置的 Django 登录视图所做的事情:如果 GET 请求中传递了一个名为next的参数,它将使用该参数进行重定向。
这个视图现在需要一个模板(main/templates/signup.html):
{% extends"base.html" %}
{% block content %}
<h2>Sign up</h2>
<p>Please fill the form below.</p>
<form method="POST">
{% csrf_token %}
<div class="form-group">
{{ form.email.label_tag }}
<input
type="email"
class="form-control {% if form.email.errors %}is-invalid{% endif %}"
id="id_email"
name="email"
placeholder="Your email"
value="{{ form.email.value|default:"" }}" >
{% if form.email.errors %}
<div class="invalid-feedback">
{{ form.email.errors }}
</div>
{% endif %}
</div>
<div class="form-group">
{{ form.password1.label_tag }}
<input
type="password"
class="form-control {% if form.password1.errors %}is-invalid{% endif %}"
id="id_password1"
name="password1"
value="{{ form.password1.value|default:"" }}">
{% if form.password1.errors %}
<div class="invalid-feedback">
{{ form.password1.errors }}
</div>
{% endif %}
</div>
<div class="form-group">
{{ form.password2.label_tag }}
<input
type="password"
class="form-control{% if form.password2.errors %}is-invalid{% endif %}"
id="id_password2"
name="password2"
value="{{ form.password2.value|default:"" }}">
{% if form.password2.errors %}
<div class="invalid-feedback">
{{ form.password2.errors }}
</div>
{% endif %}
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
{% endblock content %}
同样,该模板类似于“联系我们”页面的模板。由于表单字段不同,它会呈现一组不同的字段。
将视图映射到 URL 后,我们将能够看到结果:
urlpatterns = [
path('signup/', views.SignupView.as_view(), name="signup"),
...
]
我们目前所做的已经足够得到一个工作页面,如图 4-1 所示。为了完成这段代码,我们需要测试视图和表单。
图 4-1
注册页面
class TestForm(TestCase):
...
def test_valid_signup_form_sends_email(self):
form = forms.UserCreationForm(
{
"email": "user@domain.com",
"password1": "abcabcabc",
"password2": "abcabcabc",
}
)
self.assertTrue(form.is_valid())
with self.assertLogs("main.forms", level="INFO") as cm:
form.send_mail()
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(
mail.outbox[0].subject, "Welcome to BookTime"
)
self.assertGreaterEqual(len(cm.output), 1)
上面的代码是要去test_forms.py,而下面的是在test_views.py。
from unittest.mock import patch
from django.contrib import auth
...
class TestPage(TestCase):
...
def test_user_signup_page_loads_correctly(self):
response = self.client.get(reverse("signup"))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "signup.html")
self.assertContains(response, "BookTime")
self.assertIsInstance(
response.context["form"], forms.UserCreationForm
)
def test_user_signup_page_submission_works(self):
post_data = {
"email": "user@domain.com",
"password1": "abcabcabc",
"password2": "abcabcabc",
}
with patch.object(
forms.UserCreationForm, "send_mail"
) as mock_send:
response = self.client.post(
reverse("signup"), post_data
)
self.assertEqual(response.status_code, 302)
self.assertTrue(
models.User.objects.filter(
email="user@domain.com"
).exists()
)
self.assertTrue(
auth.get_user(self.client).is_authenticated
)
mock_send.assert_called_once()
对于视图,我们测试两种情况,第一种呈现和成功提交。对于失败的提交,Django 不会触发form_valid()函数,也不会重定向到success_url;这只是一个页面重新加载,第一个测试已经涉及了一点。
成功的提交触发了一些值得测试的东西。我们使用mock模块来测试是否调用了send_mail()函数,而不是检查是否有电子邮件发出。对于实际的发送,我们已经在前面的文件中进行了测试。
我们正在测试的其他东西是
-
响应是 HTTP 302 重定向
-
已经为该电子邮件创建了一个新的
User模型 -
新用户已经登录
更详细的模型表单
如前所述,ModelForm 是一种特殊类型的表单。它是通过将一个模型与其相关联来创建的,因此,表单中所有相应的字段都是自动创建和映射的。您还需要指定想要包括(或排除)的字段。
class Lead(models.Model):
name = models.CharField(max_length=32)
class LeadForm(forms.ModelForm):
class Meta:
model = Lead
fields = ('name', )
# Creating a form to add a lead.
>>> form = LeadForm()
# Create a form instance with POST data for a new lead
>>> form = LeadForm(request.POST)
# Creating a form to change an existing lead.
>>> lead = Lead.objects.get(pk=1)
>>> form = LeadForm(instance=lead)
# Create a form instance with POST data for an existing lead
>>> form = LeadForm(request.POST, instance=lead)
# Creates the lead entry in the database, or
# triggers an update if an instance was passed in.
>>> new_lead = form.save()
前面的代码包括一些交互的例子。通过在从forms.ModelForm继承的类的Meta类中指定模型和字段,为您生成一个管理加载、验证和保存的表单。你可以将这个ModelForm模式和任何一个FormView一起使用。
我们上面看到的表单UserCreationForm,只是ModelForm的一个子类,有一些额外的功能。
消息框架和消息级别
如前所示,messages 框架用于向用户显示消息,而不是持久化它们。下次 Django 返回 HTTP 响应时,该消息将不再存在。创建邮件很容易:
messages.debug(request, '%s SQL statements were executed.' % count)
messages.info(request, 'You signed up successfully.')
messages.success(request, 'Profile details updated.')
messages.warning(request, 'Your account expires in three days.')
messages.error(request, 'Product does not exist.')
要显示这些消息,我们所要做的就是将它们显示在呈现给用户的模板中的某个位置。在我们的例子中,我们将在基本模板main/templates/base.html中这样做,就在内容块之前:
...
</nav>
{% for message in messages %}
<div class="alert alert-{{ message.tags }}">{{ message }}</div>
{% endfor %}
{% block content %}
...
这将足以始终在任何页面视图中呈现消息。在我们的例子中,消息标记将对应于 debug、info、success 等,这些标记可用于以不同的方式设置该块的样式。
何时使用表单或视图
尽管模型、视图和模板之间的界限被很好地标记出来,但是视图和表单之间的界限却是模糊的。视图可以做表单可以做的所有事情,但是表单巧妙地包装了一个非常常见的模式:表单呈现和验证。
表单不直接与请求对象交互,因此它们的功能有限。它们通常与request.POST交互,或者在某些特定情况下,与请求的其他属性交互。
在某些情况下,您可能会看到 request 对象被传递到 forms 并在一些 form 方法中使用。如果我们决定这样做,在创建从多个地方修改请求状态的代码时,我们必须小心,因为代码的可读性可能会降低。
创建登录页面
创建注册页面后的下一步是创建登录页面。我们的页面将要求输入电子邮件地址和密码,并尝试验证用户提交的数据。Django 提供了一个名为LoginView的视图,我们可以重用它,但是我们需要为它创建一个定制的表单,因为我们有自己的User模型。
让我们把这个加到main/forms.py:
from django.contrib.auth import authenticate
...
class AuthenticationForm(forms.Form):
email = forms.EmailField()
password = forms.CharField(
strip=False, widget=forms.PasswordInput
)
def __init__(self, request=None, *args, **kwargs):
self.request = request
self.user = None
super().__init__(*args, **kwargs)
def clean(self):
email = self.cleaned_data.get("email")
password = self.cleaned_data.get("password")
if email is not None and password:
self.user = authenticate(
self.request, email=email, password=password
)
if self.user is None:
raise forms.ValidationError(
"Invalid email/password combination."
)
logger.info(
"Authentication successful for email=%s", email
)
return self.cleaned_data
def get_user(self):
return self.user
在这个表单中,与前一个不同,我们需要覆盖__init__()方法来接受请求对象。因为authenticate()函数需要,request对象将被用于验证。
假设 Django 中已经包含了一个视图,我们将在main/urls.py文件中直接使用它,传递一些参数来覆盖默认值:
from django.contrib.auth import views as auth_views
from main import forms
...
urlpatterns = [
path(
"login/",
auth_views.LoginView.as_view(
template_name="login.html",
form_class=forms.AuthenticationForm,
),
name="login",
),
...
]
我们需要创建前面提到的模板。这是我们的版本,结果如图 4-2 所示:
图 4-2
登录页面
{% extends "base.html" %}
{% block content %}
<h2>Login</h2>
<p>Please fill the form below.</p>
<form method="POST">
{% csrf_token %}
{{ form.non_field_errors }}
<div class="form-group">
{{ form.email.label_tag }}
<input
type="email"
class="form-control {% if form.email.errors %}is-invalid{% endif %}"
id="id_email"
name="email"
placeholder="Your email"
value="{{ form.email.value|default:"" }}">
{% if form.email.errors %}
<div class="invalid-feedback">
{{ form.email.errors }}
</div>
{% endif %}
</div>
<div class="form-group">
{{ form.password.label_tag }}
<input
type="password"
class="form-control{% if form.password.errors %}is-invalid{% endif %}"
id="id_password"
name="password"
value="{{ form.password.value|default:"" }}">
{% if form.password.errors %}
<div class="invalid-feedback">
{{ form.password.errors }}
</div>
{% endif %}
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
{% endblock content %}
这类似于我们已经看到的标准表单模板。注意form.non_field_errors,在渲染我们的无效用户/密码错误时使用。我们使用它是因为错误不是特定于字段的。
为了完成这个,我们将在 Django 设置中定义一个名为LOGIN_REDIRECT_URL的额外设置。我们将把它设置为一个定义好的 URL,比如/。如果我们没有定义这个,默认的 URL 将是/accounts/profile/,它并不存在。
管理用户的地址
现在系统中有了用户,我们需要开始管理一些用户信息,从地址开始。我们将在稍后构建结账系统时使用这些信息。这是我们将要研究的模型:
class Address(models.Model):
SUPPORTED_COUNTRIES = (
("uk", "United Kingdom"),
("us", "United States of America"),
)
user = models.ForeignKey(User, on_delete=models.CASCADE)
name = models.CharField(max_length=60)
address1 = models.CharField("Address line 1", max_length=60)
address2 = models.CharField(
"Address line 2", max_length=60, blank=True
)
zip_code = models.CharField(
"ZIP / Postal code", max_length=12
)
city = models.CharField(max_length=60)
country = models.CharField(
max_length=3, choices=SUPPORTED_COUNTRIES
)
def __str__(self):
return ", ".join(
[
self.name,
self.address1,
self.address2,
self.zip_code,
self.city,
self.country,
]
)
我们的Address模型包含描述地址的最常见字段。这里唯一值得注意的是country字段上的choices属性:它用于将字段的内容限制为一组给定的条目。
这个修饰符接受一个对列表(或元组)。这些对的结构是,第一个条目是存储的值,第二个条目是显示的值。这样,我们将所需的存储量限制为三个字符,同时保持显示更长字符串的能力。
此时,运行./manage.py makemigrations,如果生成的迁移对于前面的模型是正确的,则运行./manage.py migrate。
下一步是为用户提供添加、更改和删除地址的方法。幸运的是,Django 通过这些基于类的视图:ListView、CreateView、UpdateView和DeleteView,使得这个高级操作变得简单。
from django.urls import reverse_lazy
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic.edit import FormView, CreateView, UpdateView, DeleteView
from django.views.generic.edit import (
FormView,
CreateView,
UpdateView,
DeleteView,
)
...
class AddressListView(LoginRequiredMixin, ListView):
model = models.Address
def get_queryset(self):
return self.model.objects.filter(user=self.request.user)
class AddressCreateView(LoginRequiredMixin, CreateView):
model = models.Address
fields = [
"name",
"address1",
"address2",
"zip_code",
"city",
"country",
]
success_url = reverse_lazy("address_list")
def form_valid(self, form):
obj = form.save(commit=False)
obj.user = self.request.user
obj.save()
return super().form_valid(form)
class AddressUpdateView(LoginRequiredMixin, UpdateView):
model = models.Address
fields = [
"name",
"address1",
"address2",
"zip_code",
"city",
"country",
]
success_url = reverse_lazy("address_list")
def get_queryset(self):
return self.model.objects.filter(user=self.request.user)
class AddressDeleteView(LoginRequiredMixin, DeleteView):
model = models.Address
success_url = reverse_lazy("address_list")
def get_queryset(self):
return self.model.objects.filter(user=self.request.user)
将这些视图添加到我们的main/views.py文件中,我们就有足够的功能为用户提供我们计划的特性。这里有很多要描述的。列出的所有视图都带有一个model参数,它指定了这些视图正在创建、更新、列出或删除的模型。对于创建和更新操作,视图使用的模型字段列表需要是明确的。
然而,每个用户必须只能操作他们自己的地址,这就是为什么前面代码中的get_queryset()方法基于用户所有权对它们进行预过滤。地址创建视图不需要预过滤,但是需要用户在内部设置正确的值,这就是为什么我们在form_valid()方法中使用了self.request.user。
所有这些视图只有在登录时才能访问,因为它们需要用户,这就是 mixin LoginRequiredMixin的功能。
前面代码中的最后三个视图正在更改数据库,它们是包含两个步骤的操作:
-
创建流程包括数据输入步骤和数据提交步骤。
-
更新流程包括数据编辑步骤和数据提交步骤。
-
删除流程有确认步骤和删除步骤。
在第二步结束时,所有这些视图都重定向到在success_url中指定的 URL。这里使用的reverse_lazy()函数类似于url模板标签:它查找指定的命名 URL 并返回它的 URL。
我们将继续为目前为止在main/urls.py中所写的内容分配以下 URL 和名称:
urlpatterns = [
path(
"address/",
views.AddressListView.as_view(),
name="address_list",
),
path(
"address/create/",
views.AddressCreateView.as_view(),
name="address_create",
),
path(
"address/<int:pk>/",
views.AddressUpdateView.as_view(),
name="address_update",
),
path(
"address/<int:pk>/delete/",
views.AddressDeleteView.as_view(),
name="address_delete",
),
...
由于更新和删除操作总是在现有模型上操作,这些视图接受通过 URL 传递的pk参数。如果您想为模型使用 slug,对它的支持已经内置到这个类正在使用的SingleObjectMixin中。
这些视图现在需要模板。如果没有指定模板名称,就像在我们的例子中一样,这些名称是通过使用模型名称和视图类型自动生成的。他们遵循模式<app_name>/<model_name>_<operation_name>.html。
对于ListView,模板为main/templates/main/address_list.html:
{% extends "base.html" %}
{% block content %}
<h1>List of your addresses:</h1>
{% for address in object_list %}
<p>
{{ address.name }}<br>
{{ address.address1 }}<br>
{{ address.address2 }}<br>
{{ address.city }}<br>
{{ address.get_country_display }}<br>
</p>
<p>
<a href="{% url "address_update" address.id %}">Update address</a>
</p>
<p>
<a href="{% url "address_delete" address.id %}">Delete address</a>
</p>
{% if not forloop.last %}
<hr>
{% endif %}
{% endfor %}
<p>
<a href="{% url "address_create" %}">Add new address</a>
</p>
{% endblock content %}
这个非常基本的布局足以让我们可视化所有用户的地址。
下一个是创造。对于 CreateView,模板放入main/templates/main/address_form.html:
{% extends "base.html" %}
{% block content %}
<h2>Add a new address</h2>
<form method="POST">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn btn-primary">Submit</button>
</form>
{% endblock content %}
与我们目前看到的表单模板不同,我们在这里使用了一个快捷方式。我们使用表单的as_p方法自动呈现所有字段,使用<p>作为分隔符。这对生产站点没有太大的帮助,但是现在已经足够了。
我们下一个操作是更新。UpdateView 将加载main/templates/main/address_update.html。该模板与前一个几乎完全相同:
{% extends "base.html" %}
{% block content %}
<h2>Add a new address</h2>
<form method="POST">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn btn-primary">Submit</button>
</form>
{% endblock content %}
最后一个模板用于删除操作。这里没有表格,只有一个要求确认的页面。
删除视图的模板是main/templates/main/address_confirm_delete.html:
{% extends "base.html" %}
{% block content %}
<h2>Delete address</h2>
<form method="POST">
{% csrf_token %}
<p>Are you sure you want to delete it?</p>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
{% endblock content %}
这包括了我们在 Web 上使用该功能所需的所有视图。为了完成这一点,值得为我们对 cbv 所做的覆盖编写测试。我们将测试用户是否只能看到自己的地址,以及地址创建是否与当前用户相关联。我们将这些测试添加到现有的test_views.py文件中:
...
def test_address_list_page_returns_only_owned(self):
user1 = models.User.objects.create_user(
"user1", "pw432joij"
)
user2 = models.User.objects.create_user(
"user2", "pw432joij"
)
models.Address.objects.create(
user=user1,
name="john kimball",
address1="flat 2",
address2="12 Stralz avenue",
city="London",
country="uk",
)
models.Address.objects.create(
user=user2,
name="marc kimball",
address1="123 Deacon road",
city="London",
country="uk",
)
self.client.force_login(user2)
response = self.client.get(reverse("address_list"))
self.assertEqual(response.status_code, 200)
address_list = models.Address.objects.filter(user=user2)
self.assertEqual(
list(response.context["object_list"]),
list(address_list),
)
def test_address_create_stores_user(self):
user1 = models.User.objects.create_user(
"user1", "pw432joij"
)
post_data = {
"name": "john kercher",
"address1": "1 av st",
"address2": "",
"zip_code": "MA12GS",
"city": "Manchester",
"country": "uk",
}
self.client.force_login(user1)
self.client.post(
reverse("address_create"), post_data
)
self.assertTrue(
models.Address.objects.filter(user=user1).exists()
)
创建购物篮功能
购物篮或购物车的概念是任何电子商务网站的基石。如果没有这个功能,用户将无法购买产品,网站也无法在线赚钱。为了构建这个篮子,我们将首先向我们的main/models.py文件添加以下内容:
from django.core.validators import MinValueValidator
...
class Basket(models.Model):
OPEN = 10
SUBMITTED = 20
STATUSES = ((OPEN, "Open"), (SUBMITTED, "Submitted"))
user = models.ForeignKey(
User, on_delete=models.CASCADE, blank=True, null=True
)
status = models.IntegerField(choices=STATUSES, default=OPEN)
def is_empty(self):
return self.basketline_set.all().count() == 0
def count(self):
return sum(i.quantity for i in self.basketline_set.all())
class BasketLine(models.Model):
basket = models.ForeignKey(Basket, on_delete=models.CASCADE)
product = models.ForeignKey(
Product, on_delete=models.CASCADE
)
quantity = models.PositiveIntegerField(
default=1, validators=[MinValueValidator(1)]
)
我们将从这个非常简单的篮子开始。请生成迁移并将其应用到您的数据库。
我们对篮子本身有一个模型,还有许多链接回它的BasketLine模型。然后,每个BasketLine模型将连接到一个特定的产品,并有一个名为quantity的额外字段来存储篮子中有多少这种产品。
我已经在前一节解释了choices参数,但是这里我们将它用于一个整数字段。这个参数可以应用于任何类型的字段,但是整数是最节省空间的,因此我选择了它们。
在第二个模型中,对于 quantity 字段,我们将传递一个名为validators的额外参数。验证器对数据添加额外的检查以防止保存。在这种情况下,我们希望确保 quantity 字段的值永远不会小于 1。将其设置为零的唯一方法是删除模型。
现在我们有了模型,我们将介绍 Django 的一个我们还没有见过的特性,中间件。在 Django 中,中间件是一个函数(更准确地说,是一个可调用的函数),它包装并向视图提供额外的功能。他们能够在请求进入视图时修改请求,在请求离开视图时修改响应。
我们将使用它来自动将网篮连接到 HTTP 请求。我们在中间件中这样做,因为我们将在几个视图和模板中使用篮子,这有助于我们避免对特定代码重复相同的调用。
将此内容写入main/middlewares.py:
from . import models
def basket_middleware(get_response):
def middleware(request):
if 'basket_id' in request.session:
basket_id = request.session['basket_id']
basket = models.Basket.objects.get(id=basket_id)
request.basket = basket
else:
request.basket=None
response = get_response(request)
return response
return middleware
我们可以清楚地看到,每次视图激活都有一些代码在进行,这发生在调用get_response()方法时。在此之前,代码检查来自 Django 提供的另一个中间件的会话是否包含一个basket_id。如果是,它将载入篮子并把它分配给request.basket。
中间件可以依赖于其他中间件,这就是这里正在发生的事情:我们依赖于SessionMiddleware。要使用basket_middleware,我们需要将它添加到 Django settings.py文件中的常量MIDDLEWARES中,在会话中间件之后:
MIDDLEWARE = [
'django.contrib.sessions.middleware.SessionMiddleware',
...
'main.middlewares.basket_middleware',
]
添加了这个中间件之后,我们现在可以在每个模板中呈现篮子了。为了演示这一点,我们将把这个代码片段添加到基本模板main/templates/base.html,就在{% block content %}之前:
...
{% if request.basket %}
<div>
{{ request.basket.count }}
items in basket
</div>
{% endif %}
...
添加到购物篮视图
现在我们准备开始处理视图。我们将创建的第一个视图是将产品添加到购物篮中。我们将使用基于函数的视图,而不是基于类的视图,因此它与您目前看到的语法不同:
from django.http import HttpResponseRedirect
from django.urls import reverse
...
def add_to_basket(request):
product = get_object_or_404(
models.Product, pk=request.GET.get("product_id")
)
basket = request.basket
if not request.basket:
if request.user.is_authenticated:
user = request.user
else:
user = None
basket = models.Basket.objects.create(user=user)
request.session["basket_id"] = basket.id
basketline, created = models.BasketLine.objects.get_or_create(
basket=basket, product=product
)
if not created:
basketline.quantity += 1
basketline.save()
return HttpResponseRedirect(
reverse("product", args=(product.slug,))
)
在这个视图中,我们可以依靠中间件将现有的购物篮放置在request.basket属性中。只有当购物篮存在,并且其 id 已经存在于会话中时,这才会起作用。这个视图还将负责创建一个篮子(如果它还不存在的话),并为中间件处理任何后续请求做必要的步骤。
将它实现为基于类的视图是可能的,但是就内置的 cbv 而言,我们没有多少可以重用的。作为一个类这样做将会导致更多的代码而没有更多的功能,这就是为什么我把它写成一个函数。
要从模板中使用它,需要一个 URL:
urlpatterns = [
...
path(
"add_to_basket/",
views.add_to_basket,
name="add_to_basket",
),
...
]
现在可以从产品详细信息模板中引用该视图。在</table>后增加以下几行:
...
<a
href="{% url "add_to_basket" %}?product_id={{ object.id }}">
Add to basket
</a>
为了最终确定并测试这一点,我们可以使用以下测试:
...
class TestPage(TestCase):
...
def test_add_to_basket_loggedin_works(self):
user1 = models.User.objects.create_user(
"user1@a.com", "pw432joij"
)
cb = models.Product.objects.create(
name="The cathedral and the bazaar",
slug="cathedral-bazaar",
price=Decimal("10.00"),
)
w = models.Product.objects.create(
name="Microsoft Windows guide",
slug="microsoft-windows-guide",
price=Decimal("12.00"),
)
self.client.force_login(user1)
response = self.client.get(
reverse("add_to_basket"), {"product_id": cb.id}
)
response = self.client.get(
reverse("add_to_basket"), {"product_id": cb.id}
)
self.assertTrue(
models.Basket.objects.filter(user=user1).exists()
)
self.assertEquals(
models.BasketLine.objects.filter(
basket__user=user1
).count(),
1,
)
response = self.client.get(
reverse("add_to_basket"), {"product_id": w.id}
)
self.assertEquals(
models.BasketLine.objects.filter(
basket user=user1
).count(),
2,
)
这个测试覆盖了前面视图中的所有代码:购物篮创建、重用和BasketLine操作。
管理购物篮视图
我们网站需要的第二个视图是一个页面,用于更改数量和删除购物篮中的行。我们将使用 Django 的另一个我们还没有用过的功能:表单集。
表单集是处理同一类型的多个表单的一种方式。当在同一个页面上修改一个表单的多个条目时,这非常方便。要创建表单集,有几个“工厂”函数:
-
最简单的方法,最适合正常的形式
-
modelformset_factory():相当于模型表单,但应用于表单集 -
inlineformset_factory():与上述类似,但更具体地说是与基本对象相关的对象(通过外键)
在我们的项目中,我们希望构建一个页面来修改购物篮的内容。我们需要一个连接到篮子的每个篮子线的表单。在我们的例子中,我们可以使用inlineformset_factory()。我们将把它添加到我们的main/forms.py文件中:
from django.forms import inlineformset_factory
...
BasketLineFormSet = inlineformset_factory(
models.Basket,
models.BasketLine,
fields=("quantity",),
extra=0,
)
此表单集将自动为连接到指定购物篮的所有购物篮行构建表单;唯一可编辑的字段将是数量,没有额外的表单来添加新条目,因为我们是通过add_to_basket视图来添加的。
Django 不包含任何基于类的视图和表单集。我们必须自己为它编写整个基于函数的视图。
这是需要放入main/views.py中的视图:
from django.shortcuts import get_object_or_404, render
...
def manage_basket(request):
if not request.basket:
return render(request, "basket.html", {"formset": None})
if request.method == "POST":
formset = forms.BasketLineFormSet(
request.POST, instance=request.basket
)
if formset.is_valid():
formset.save()
else:
formset = forms.BasketLineFormSet(
instance=request.basket
)
if request.basket.is_empty():
return render(request, "basket.html", {"formset": None})
return render(request, "basket.html", {"formset": formset})
如果用户还没有购物篮,或者有一个购物篮但它是空的,前面的视图将不会呈现任何表单集。如果购物篮不为空,将为GET请求呈现表单集,当表单通过POST请求提交回来时,将处理提交。
与上述视图相匹配的模板放入main/templates/basket.html:
{% extends "base.html" %}
{% block content %}
<h2>Basket</h2>
{% if formset %}
<p>You can adjust the quantities below.</p>
<form method="POST">
{% csrf_token %}
{{ formset.management_form }}
{% for form in formset %}
<p>
{{ form.instance.product.name }}
{{ form }}
</p>
{% endfor %}
<button type="submit" class="btn btn-default">Update basket</button>
</form>
{% else %}
<p>You have no items in the basket.</p>
{% endif %}
{% endblock content %}
该模板通过直接通过instance访问模型来呈现被表单排除的产品名称。表单集工作的一个必要条件是在模板中呈现management_form属性。这用于表单集在表单之上提供的额外功能。
为了结束此功能,让我们添加 URL:
图 4-3
管理购物篮视图
urlpatterns = [
path('basket/', views.manage_basket, name="basket"),
...
]
假设视图相当简单,并且大部分功能都是由表单集管理的,那么就不做测试了。
登录时合并篮子
我们的核心篮子功能几乎已经完成,但仍有一些边缘情况可以处理得更好。其中之一是处理用户将书放入购物篮,然后登录,却发现所选的书已经不在购物篮中的情况。出现这种明显奇怪的行为是因为登录用户连接到了另一个 Django 会话。
为了解决这个问题,我们需要实现一些合并篮子的代码。一旦用户登录,我们就会这样做,为此我们可以利用另一个名为user_logged_in的内置信号。下面的代码向您展示了如何(main/signals.py):
from django.contrib.auth.signals import user_logged_in
from .models import Basket
...
@receiver(user_logged_in)
def merge_baskets_if_found(sender, user, request,**kwargs):
anonymous_basket = getattr(request,"basket",None)
if anonymous_basket:
try:
loggedin_basket = Basket.objects.get(
user=user, status=Basket.OPEN
)
for line in anonymous_basket.basketline_set.all():
line.basket = loggedin_basket
line.save()
anonymous_basket.delete()
request.basket = loggedin_basket
logger.info(
"Merged basket to id %d", loggedin_basket.id
)
except Basket.DoesNotExist:
anonymous_basket.user = user
anonymous_basket.save()
logger.info(
"Assigned user to basket id %d",
anonymous_basket.id,
)
这段代码实现了所描述的行为。总之,测试是可取的。
我们将用一个测试来测试这个流,在main/tests/test_views.py:
from django.contrib import auth
from django.urls import reverse
...
def test_add_to_basket_login_merge_works(self):
user1 = models.User.objects.create_user(
"user1@a.com", "pw432joij"
)
cb = models.Product.objects.create(
name="The cathedral and the bazaar",
slug="cathedral-bazaar",
price=Decimal("10.00"),
)
w = models.Product.objects.create(
name="Microsoft Windows guide",
slug="microsoft-windows-guide",
price=Decimal("12.00"),
)
basket = models.Basket.objects.create(user=user1)
models.BasketLine.objects.create(
basket=basket, product=cb, quantity=2
)
response = self.client.get(
reverse("add_to_basket"), {"product_id": w.id}
)
response = self.client.post(
reverse("login"),
{"email": "user1@a.com", "password": "pw432joij"},
)
self.assertTrue(
auth.get_user(self.client).is_authenticated
)
self.assertTrue(
models.Basket.objects.filter(user=user1).exists()
)
basket = models.Basket.objects.get(user=user1)
self.assertEquals(basket.count(),3)
一个更好的篮子数量小部件
我们将应用于购物篮的最后一点是一个更好的小部件,用于管理产品数量的变化。我们希望呈现带有加号和减号的单独的更大的按钮,以方便用户更改数字。
为此,我们需要改变表单中呈现quantity字段的方式。因为该字段继承自IntegerField,Django 默认情况下将使用内置的NumberInput小部件。
让我们从创建main/widgets.py开始,并包含我们第一个小部件的代码:
from django.forms.widgets import Widget
class PlusMinusNumberInput(Widget):
template_name = 'widgets/plusminusnumber.html'
class Media:
css = {
'all': ('css/plusminusnumber.css',)
}
js = ('js/plusminusnumber.js',)
小部件引用其 HTML 的外部模板。这里我们还定义了一个Media子类,包含一些额外的 CSS 和 JavaScript 来输出。虽然template_name是一个特定的小部件功能,但是定义一个Media子类是一个可以应用于小部件和表单的功能。
我们将把这个 HTML 用于小部件(main/templates/widgets/plusminusnumber.html):
<input
type="number"
name="{{ widget.name }}"
class="form-control quantity-number"
value="{{ widget.value }}"
min="1"
max="10"
{% include "django/forms/widgets/attrs.html" %} />
<button
type="button"
class="btn btn-default btn-number btn-minus"
data-type="minus"
data-field="{{ widget.name }}">
-
</button>
<button
type="button"
class="btn btn-default btn-number btn-plus"
data-type="plus"
data-field="{{ widget.name }}">
+
</button>
我们还将使用一些 CSS 自定义输入框,并使用 JavaScript 添加一些交互性。这些变化是非常基本的,但是对于您来说,这是一个很好的起点,您可以吸取这些经验教训,并将它们应用到更复杂的场景中。
这是我们需要的 CSS,放在main/static/css/plusminusnumber.css中:
.quantity-number {
-moz-appearance:textfield;
}
.quantity-number:: -webkit-inner-spin-button,
.quantity-number:: -webkit-outer-spin-button {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
margin: 0;
}
和一些 JavaScript 来添加交互(main/static/js/plusminusnumber.js):
$(function () {
$('.btn-number').click(function (e) {
e.preventDefault();
fieldName = $(this).attr('data-field');
type = $(this).attr('data-type');
var input = $("input[name='" + fieldName + "']");
var currentVal = parseInt(input.val());
if (type == 'minus') {
if (currentVal > input.attr('min')) {
input
.val(currentVal - 1)
.change();
}
if (parseInt(input.val()) == input.attr('min')) {
$(this).attr('disabled', true);
}
} else if (type == 'plus') {
if (currentVal < input.attr('max')) {
input
.val(currentVal + 1)
.change();
}
if (parseInt(input.val()) == input.attr('max')) {
$(this).attr('disabled', true);
}
}
});
});
特别是对于 JavaScript,我们需要确保它的加载发生在任何依赖项的加载之后。在我们的例子中,假设我们在基本模板中包含了 jQuery,我们需要确保它出现在之前。为此,我们将在基础模板main/templates/base.html中添加一个空块。该块将在需要注入额外 JavaScript 的模板中被覆盖。
{% load static %}
<!doctype html>
<html lang="en">
...
<script src="{% static "js/jquery.min.js" %}"></script>
<script src="{% static "js/popper.min.js" %}"></script>
<script src="{% static "js/bootstrap.min.js" %}"></script>
{% block js %}
{% endblock js %}
</body>
</html>
最后,我们将在main/forms.py中指示 formset 工厂使用这个小部件:
from . import widgets
...
BasketLineFormSet = inlineformset_factory(
models.Basket,
models.BasketLine,
fields=("quantity",),
extra=0,
widgets={"quantity": widgets.PlusMinusNumberInput()},
)
我们将从我们的basket.html模板中覆盖这个块的内容:
{% extends "base.html" %}
{% block content %}
...
{% endblock content %}
{% block js %}
{% if formset %}
{{ formset.media }}
{% endif %}
{% endblock js %}
订单和结账
对于任何电子商务网站来说,关键的一步是从一个装满的篮子到系统中有一个订单。用户需要遍历的流程称为“结帐”
我们将创建一个简单的流程,其结构如图 4-4 所示。
图 4-4
结账漏斗
基础模型
从这些特性开始,我们将首先用一系列模型来奠定基础。就像我们对Basket和BasketLine模型所做的那样,我们将添加一个Order模型和一个OrderLine model。虽然这两个模型的基本原理是相似的(将订单/购物篮链接到许多产品),但我们的OrderLine模型将有一个重要的不同。
虽然BasketLine可以包含许多产品,但是对于每个订购的产品,OrderLine只有一个条目。这样做的原因是,我们希望状态字段具有单个订购项目的粒度。
class Order(models.Model):
NEW = 10
PAID = 20
DONE = 30
STATUSES = ((NEW, "New"), (PAID, "Paid"), (DONE, "Done"))
user = models.ForeignKey(User, on_delete=models.CASCADE)
status = models.IntegerField(choices=STATUSES, default=NEW)
billing_name = models.CharField(max_length=60)
billing_address1 = models.CharField(max_length=60)
billing_address2 = models.CharField(
max_length=60, blank=True
)
billing_zip_code = models.CharField(max_length=12)
billing_city = models.CharField(max_length=60)
billing_country = models.CharField(max_length=3)
shipping_name = models.CharField(max_length=60)
shipping_address1 = models.CharField(max_length=60)
shipping_address2 = models.CharField(
max_length=60, blank=True
)
shipping_zip_code = models.CharField(max_length=12)
shipping_city = models.CharField(max_length=60)
shipping_country = models.CharField(max_length=3)
date_updated = models.DateTimeField(auto_now=True)
date_added = models.DateTimeField(auto_now_add=True)
class OrderLine(models.Model):
NEW = 10
PROCESSING = 20
SENT = 30
CANCELLED = 40
STATUSES = (
(NEW, "New"),
(PROCESSING, "Processing"),
(SENT, "Sent"),
(CANCELLED, "Cancelled"),
)
order = models.ForeignKey(
Order, on_delete=models.CASCADE, related_name="lines"
)
product = models.ForeignKey(
Product, on_delete=models.PROTECT
)
status = models.IntegerField(choices=STATUSES, default=NEW)
对于任何main/models.py文件更改,请生成并应用迁移。
就像我们对Basket模型所做的一样,我们将使用状态字段来管理我们的电子商务商店中的工作流。为了简单起见,我们将不构建在线支付系统。客服会将订单标记为已支付,配送经理会将订单行标记为相关状态。
每个订单都将关联一个用户和一个帐单/送货地址。这里我们没有使用任何外键;相反,我们复制了Address模型的内容。这将及时生成订单快照,用户地址的任何后续更改都不会影响现有订单。
订单还有两个与之相关的时间戳:注意auto_now和auto_now_add属性。这些由 Django 管理,并在模型更新和创建时自动更新。
line 模型也有一些新的东西值得解释。参数related_name用于在从Order实例访问订单行时指定一个更好的名称。通过指定这一点,我们可以用order.lines.all()而不是更冗长的缺省值order.orderline_set.all()来访问订单的所有行。
产品线模型中产品的ForeignKey字段有说明符models.PROTECT。这与我们通常的models.CASCADE不同。我们在数据库级别实施的行为阻碍了任何有序产品的删除。
为了生成订单,我们将编写一个附加到购物篮模型的创建方法:
import logging
logger = logging.getLogger(__name__)
...
def create_order(self, billing_address, shipping_address):
if not self.user:
raise exceptions.BasketException(
"Cannot create order without user"
)
logger.info(
"Creating order for basket_id=%d"
", shipping_address_id=%d, billing_address_id=%d",
self.id,
shipping_address.id,
billing_address.id,
)
order_data = {
"user":self.user,
"billing_name": billing_address.name,
"billing_address1": billing_address.address1,
"billing_address2": billing_address.address2,
"billing_zip_code": billing_address.zip_code,
"billing_city": billing_address.city,
"billing_country": billing_address.country,
"shipping_name": shipping_address.name,
"shipping_address1": shipping_address.address1,
"shipping_address2": shipping_address.address2,
"shipping_zip_code": shipping_address.zip_code,
"shipping_city": shipping_address.city,
"shipping_country": shipping_address.country,
}
order = Order.objects.create(**order_data)
c=0
for line in self.basketline_set.all():
for item in range(line.quantity):
order_line_data = {
"order": order,
"product": line.product,
}
order_line = OrderLine.objects.create(
**order_line_data
)
c += 1
logger.info(
"Created order with id=%d and lines_count=%d",
order.id,
c,
)
self.status = Basket.SUBMITTED
self.save()
return order
考虑到这段代码的重要性,我们将为test_models.py添加一个测试:
...
def test_create_order_works(self):
p1 = models.Product.objects.create(
name="The cathedral and the bazaar",
price=Decimal("10.00"),
)
p2 = models.Product.objects.create(
name="Pride and Prejudice", price=Decimal("2.00")
)
user1 = models.User.objects.create_user(
"user1", "pw432joij"
)
billing = models.Address.objects.create(
user=user1,
name="John Kimball",
address1="127 Strudel road",
city="London",
country="uk",
)
shipping = models.Address.objects.create(
user=user1,
name="John Kimball",
address1="123 Deacon road",
city="London",
country="uk",
)
basket = models.Basket.objects.create(user=user1)
models.BasketLine.objects.create(
basket=basket, product=p1
)
models.BasketLine.objects.create(
basket=basket, product=p2
)
with self.assertLogs("main.models", level="INFO") as cm:
order = basket.create_order(billing, shipping)
self.assertGreaterEqual(len(cm.output), 1)
order.refresh_from_db()
self.assertEquals(order.user, user1)
self.assertEquals(
order.billing_address1, "127 Strudel road"
)
self.assertEquals(
order.shipping_address1, "123 Deacon road"
)
# add more checks here
self.assertEquals(order.lines.all().count(), 2)
lines = order.lines.all()
self.assertEquals(lines[0].product, p1)
self.assertEquals(lines[1].product, p2)
你会注意到我们需要建立多少数据。在接下来的章节中,我将介绍一种减轻这种情况的方法,但这只是暂时的。调用create_order()方法将返回一个订单,但是为了确保我们正在处理一个干净的副本,调用了refresh_from_db()方法。
结账流程
为了实现本节开始时描述的结帐流程,我们需要对现有代码进行一些更改,并增加一些新的东西。在连接页面之前,我们将首先构建页面。新页面是地址选择页面,它需要一个表单:
class AddressSelectionForm(forms.Form):
billing_address = forms.ModelChoiceField(
queryset=None)
shipping_address = forms.ModelChoiceField(
queryset=None)
def __init__(self, user, *args, **kwargs):
super(). __init__(*args, **kwargs)
queryset = models.Address.objects.filter(user=user)
self.fields['billing_address'].queryset = queryset
self.fields['shipping_address'].queryset = queryset
这种形式与目前看到的形式不同,它在声明的字段中动态地指定参数。在这种情况下,我们将地址限制为连接到当前用户的地址。原因很简单:我们不希望用户能够选择系统中任何可用的地址。
为了管理这个表单,我们可以创建一个继承自FormView的相应视图,并用我们想要的定制来填充这个类:
class AddressSelectionView(LoginRequiredMixin, FormView):
template_name = "address_select.html"
form_class = forms.AddressSelectionForm
success_url = reverse_lazy('checkout_done')
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['user'] = self.request.user
return kwargs
def form_valid(self, form):
del self.request.session['basket_id']
basket = self.request.basket
basket.create_order(
form.cleaned_data['billing_address'],
form.cleaned_data['shipping_address']
)
return super().form_valid(form)
这里指定的方法完成了大部分工作:get_form_kwargs()从请求中提取用户并将其返回到字典中。然后这个字典被超类传递给表单。FormView超类自动调用这个函数,因为这是一个非常常见的模式,用于向表单传递额外的变量。
在form_valid()方法中,我们从会话中删除购物篮,并用提交的地址数据对其调用create_order()方法。
我们现在需要填补缺失的部分,从main/templates/address_select.html模板开始:
{% extends "base.html" %}
{% block content %}
<h2>Select the billing/shipping addresses</h2>
<form method="POST">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn btn-primary">Submit</button>
<a class="btn btn-primary" href="{% url "address_create" %}">Add a new one</a>
</form>
{% endblock content %}
这是一个非常标准的表单模板。唯一增加的是到我们在本章开始时创建的地址创建页面的链接。此页面需要从篮子模板链接。这是main/templates/basket.html的修改版:
{% extends "base.html" %}
{% block content %}
<h2>Basket</h2>
{% if formset %}
<p>You can adjust the quantities below.</p>
<form method="POST">
{% csrf_token %}
{{ formset.management_form }}
{% for form in formset %}
<p>
{{ form.instance.product.name }}
{{ form }}
</p>
{% endfor %}
<button type="submit" class="btn btn-default">Update basket</button>
{% if user.is_authenticated %}
<a href="{% url "address_select" %}" class="btn btn-primary">Place order</a>
{% else %}
<a
href="{% url "signup" %}?next={% url"address_select" %}"
class="btn btn-primary">Signup</a>
<a
href="{% url "login" %}?next={% url "address_select" %}"
class="btn btn-primary">Login</a>
{% endif %}
</form>
{% else %}
<p>You have no items in the basket.</p>
{% endif %}
{% endblock content %}
{% block js %}
{% if formset %}
{{ formset.media }}
{% endif %}
{% endblock js %}
记下额外的链接以进入下一步。这个漏斗中的下一步是注册或登录(在用户未被认证的情况下),或者是地址选择(在用户已经登录的情况下)(如图 4-5 所示)。
图 4-5
地址选择页面
在本章的前面,我们创建了注册和登录页面,如果指定了名为next的 GET 参数,那么如果表单提交成功,该参数的值将是用户被重定向到的 URL。我们将使用此功能来构建漏斗。
在我们定义 URL(并且有一个功能性的漏斗)之前,我们将定义漏斗的最后一页,也就是在本节前面被命名为checkout_done的 URL。我们将在main/templates/order_done.html中为它添加一个非常简单的模板:
{% extends "base.html" %}
{% block content %}
<p>Thanks for your order.</p>
{% endblock content %}
最后一步是为我们之前创建的两个视图添加 URL:
...
urlpatterns =
path(
"order/done/",
TemplateView.as_view(template_name="order_done.html"),
name="checkout_done",
),
path(
"order/address_select/",
views.AddressSelectionView.as_view(),
name="address_select",
),
...
我们的结帐流程现在完成了。这个漏斗现在需要的是一点造型。我们在第 [2 章中说过我们正在使用 Bootstrap,HTML 中有几个 CSS 类是其中的一部分,但是剩下的就看你自己了。
在这一章的最后,我们将讨论 Django Admin 以及如何在其中可视化结帐数据。
在 Django 管理中显示签出数据
Basket模型和Order模型都有一个与之链接的相应的“lines”模型。当显示一个购物篮或一个订单时,如果 Django 管理员能够显示所有这些链接的模型和原始模型,将会很有帮助。Django 通过使用“内联”来支持这一点。
首先,将这些添加到您的main/admin.py文件中:
class BasketLineInline(admin.TabularInline):
model = models.BasketLine
raw_id_fields = ("product",)
@admin.register(models.Basket)
class BasketAdmin(admin.ModelAdmin):
list_display = ("id", "user", "status", "count")
list_editable = ("status",)
list_filter = ("status",)
inlines = (BasketLineInline,)
class OrderLineInline(admin.TabularInline):
model = models.OrderLine
raw_id_fields = ("product",)
@admin.register(models.Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ("id", "user", "status")
list_editable = ("status",)
list_filter = ("status", "shipping_country", "date_added")
inlines = (OrderLineInline,)
fieldsets = (
(None, {"fields": ("user", "status")}),
(
"Billing info",
{
"fields": (
"billing_name",
"billing_address1",
"billing_address2",
"billing_zip_code",
"billing_city",
"billing_country",
)
},
),
(
"Shipping info",
{
"fields": (
"shipping_name",
"shipping_address1",
"shipping_address2",
"shipping_zip_code",
"shipping_city",
"shipping_country",
)
},
),
)
上面我们声明了嵌套在其他实例中的ModelAdmin的实例(通过inlines属性)。对于像Basket和Order这样的模型,它们的外键指向它们相关的line模型,我们需要使用内联来显示相关数据。
属性raw_id_fields用于改变外键的呈现方式。默认情况下,外键小部件用选择框呈现,所有可能的关系都在选择选项中呈现。如果像在我们的例子中,有许多可能的关系,列表会变得很长,这会对性能产生影响,甚至触发超时,给定足够的记录。
其他大多数属性已经在本章或上一章中介绍过了。如果你不记得了,我鼓励你回去找到它们的用途。显然,所有这些信息都可以在 Django 的官方文档中找到。
我们使用@admin.register()装饰器而不是直接调用admin.register()来注册这些新类。两种方式都有效。
摘要
在这一章中,我们首先重新定义了User模型,删除用户名并强制发送电子邮件。然后,我们创建了结账所需的模型,包括购物篮和订单。
我们还为用户添加了站点登录和注册页面,以及添加和管理用户购物篮中的项目的视图。
中间件用于管理模板中的购物篮渲染和相关视图中的使用。Django widgets 功能也引入了购物篮管理页面。
我们在本章的某些部分使用了 ModelForms 和 Django 消息,作为自动生成表单和管理短暂消息的一种方式,这些消息向用户展示了在被执行的视图中发生了什么。
这一章是书中最长的一章,介绍了许多功能。在下一章中,我们将从数据库的深度退一步,讨论静态资产。
Footnotes 1https://en.wikipedia.org/wiki/Create,_read,_update_and_delete