Django2-和-Channel2-实践教程-二-

88 阅读13分钟

Django2 和 Channel2 实践教程(二)

原文:Practical Django 2 and Channels 2

协议:CC BY-NC-SA 4.0

四、用于在线接收订单的结账系统

在第三章为我们的项目打下了数据库基础,在本章中,我们将在此基础上构建剩余的模型,以存储用户生成的数据。在我们的例子中,它是一个结账系统,这对电子商务网站非常重要。

我们将讨论以下主题:

  • 定制User模型

  • 注册和登录流程

  • Django 中的 CRUD 1 视图

  • 中间件组件

  • 决哥小部件

  • 在管理界面中显示复杂数据

用户模型

无论我们要创建什么样的模型来存储用户生成的数据,都很可能与User模型相关联。User模型包含网站用户登录所需的所有数据。这个模型也是通常在注册阶段创建的模型。

Django 有一个内置的User模型,位于django.contrib.auth.models.User。有几个字段可用:

  • usernamepassword作为登录系统的凭证。

  • first_namelast_nameemail均为可选项,为描述性字段。

  • 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模型。具体来说,我们修改了一些类变量的内容。在前一章中,我们已经熟悉了其中的一些变量,但是fieldsetsadd_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 所示。为了完成这段代码,我们需要测试视图和表单。

img/466106_1_En_4_Fig1_HTML.jpg

图 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 所示:

img/466106_1_En_4_Fig2_HTML.jpg

图 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 通过这些基于类的视图:ListViewCreateViewUpdateViewDeleteView,使得这个高级操作变得简单。

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:

img/466106_1_En_4_Fig3_HTML.jpg

图 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 所示。

img/466106_1_En_4_Fig4_HTML.jpg

图 4-4

结账漏斗

基础模型

从这些特性开始,我们将首先用一系列模型来奠定基础。就像我们对BasketBasketLine模型所做的那样,我们将添加一个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_nowauto_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 所示)。

img/466106_1_En_4_Fig5_HTML.jpg

图 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属性)。对于像BasketOrder这样的模型,它们的外键指向它们相关的line模型,我们需要使用内联来显示相关数据。

属性raw_id_fields用于改变外键的呈现方式。默认情况下,外键小部件用选择框呈现,所有可能的关系都在选择选项中呈现。如果像在我们的例子中,有许多可能的关系,列表会变得很长,这会对性能产生影响,甚至触发超时,给定足够的记录。

其他大多数属性已经在本章或上一章中介绍过了。如果你不记得了,我鼓励你回去找到它们的用途。显然,所有这些信息都可以在 Django 的官方文档中找到。

我们使用@admin.register()装饰器而不是直接调用admin.register()来注册这些新类。两种方式都有效。

摘要

在这一章中,我们首先重新定义了User模型,删除用户名并强制发送电子邮件。然后,我们创建了结账所需的模型,包括购物篮和订单。

我们还为用户添加了站点登录和注册页面,以及添加和管理用户购物篮中的项目的视图。

中间件用于管理模板中的购物篮渲染和相关视图中的使用。Django widgets 功能也引入了购物篮管理页面。

我们在本章的某些部分使用了 ModelForms 和 Django 消息,作为自动生成表单和管理短暂消息的一种方式,这些消息向用户展示了在被执行的视图中发生了什么。

这一章是书中最长的一章,介绍了许多功能。在下一章中,我们将从数据库的深度退一步,讨论静态资产。

Footnotes 1

https://en.wikipedia.org/wiki/Create,_read,_update_and_delete