在Django中创建和使用装饰器

26 阅读1分钟

在Django中创建和使用装饰器

在Python中,装饰器是一个函数,它以另一个函数为参数,在不改变该函数的情况下增加功能或增强功能。Django,作为一个Python网络框架,带有大量的内置装饰器。

这些内置的装饰器在装饰基于函数的视图时被使用。然而,你正在开发的真实世界的应用程序可能需要自定义的检查或验证,而Django并没有提供开箱即用的功能。

简介

在软件开发中,像上面提到的那种情况是不可避免的。在开发Django应用程序时,当这种情况发生时,一般程序员的默认选择是在视图中进行检查或验证。

这种方法很直接,但是,它的效率很低,而且违背了软件开发的最佳实践和原则,比如DRY(Don't Repeat Yourself)。这是因为,随着程序规模的扩大,同样的检查或验证必须在许多视图中执行。因此,相同的代码在整个应用程序中被重复。

在这篇文章中,你将学习如何开发类似于真实世界应用中使用的内置装饰器(如login_required、require_http_methods、csrf_exempt)的自定义装饰器。

主要收获

我们将建立一个类似Quora的网站来进行说明。以下是将创建的自定义装饰器。

  • authentication_not_required。登录的用户会因为这个装饰器而无法访问一个视图。这对登录和注册视图很有用。
  • verification_required。没有验证过他们的电子邮件地址或电话号码的用户将无法访问这个视图。
  • xhr_request_only。这确保只允许通过fetch、XHR(XMLHttpRequest)或AJAX(Asynchronously Javascript and XML)请求。

前提条件

要想继续学习,重要的是:

  • 你有PythonDjangoWeb框架的基本知识
  • 你对Python中的装饰器也有基本了解。
  • 你安装了Pipenv

构建Django应用程序

初始设置

要想开始,请导航到你喜欢的目录,并创建一个新的文件夹,用于这个项目。在继续安装项目的依赖性之前,你必须先创建一个虚拟环境。

运行这个命令来创建一个虚拟环境。

pipenv shell 

Pipenv shell terminal

Pipenv是一个Python包,它以一种确定的方式使创建和管理虚拟环境和项目依赖性变得更容易。它就像Node.js中使用的npm和yarn。

为了防止项目依赖关系之间的冲突,建议你首先创建一个虚拟环境。

安装项目的依赖性。

pipenv install django==4.0

在虚拟环境中成功安装Django后,你会在项目目录中看到一个Pipfile.lock。

启动Django项目。

django-admin startproject config .

我更喜欢把我的Django项目称为config ,因为它所包含的都是配置文件,用于设置我们随后要创建的应用程序。

config 后面添加的点确保了一个额外的文件夹不会被创建,而是在根目录下创建config文件夹和manage.py文件。

由于django的默认用户模型没有is_verified字段,我们将使用is_active字段。我们还将使电子邮件地址成为创建账户时的必要条件。

实现这一目标的简单方法是创建一个自定义的用户模型,将AbstractUser模型子类化,然后使电子邮件字段成为必填项。创建一个名为accounts的新应用程序来处理用户的账户。

python manage.py startapp accounts

在 accounts/models.py 文件中,粘贴下面的代码。

from django.db import models
from django.contrib.auth.models import AbstractUser

# Create your models here.
class User(AbstractUser):
    email = models.EmailField(verbose_name='Email Address' ,blank=False, unique=True)

转到settings.py并包括这个。

AUTH_USER_MODEL = ‘accounts.User’

在这里,上面包含的代码强制要求这个项目中的活动用户模型是我们创建的自定义用户模型。建议你在项目的早期建立一个自定义的用户模型,因为在数据库表被创建和关系被建立后再这样做,会更有挑战性。

创建一个新的应用程序来处理quora帖子。

python manage.py startapp posts

打开config文件夹中的settings.py,将新创建的应用(post和account)列入已安装的应用列表中,这样django就能知道这些应用。

# Application definition
INSTALLED_APPS = [
    'posts',
    'accounts',
     … 
   ]

在post/models.py里面,粘贴以下代码。

from django.db import models
from django.contrib.auth import get_user_model

User = get_user_model()
class Post(models.Model):
    post = models.CharField(max_length=1000)
    user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='posts')
    upvote_users = models.ManyToManyField(User, related_name='upvoted_posts', blank=True)
    downvote_users = models.ManyToManyField(User, related_name='downvoted_posts', blank=True)

    def __str__(self):
        return f'{self.post} upvoted by {self.get_no_of_upvote()} and \
            downvoted by {self.get_no_of_downvote()}'

    def get_no_of_upvote(self):
        return self.upvote_users.count()

    def get_no_of_downvote(self):
        return self.downvote_users.count()

在上面的代码中。

  • 我们导入get_user_model来获取项目中当前的活动用户模型,并将其作为外键字段的模型。这意味着一个用户可以创建多个帖子,但每个帖子只能关联一个用户(多对一关系)。
  • 帖子模型有upvote_users和downvote_users字段,与用户模型有多对多的关系。这意味着一个帖子可能会收到许多用户的加注和减注,反之亦然。
  • get_no_of_upvote和get_no_of_downvote方法分别返回对一个帖子投了上票和下票的用户数量。我们将在下一节的模板中使用这两个方法。

运行这些命令来创建迁移文件,并将这些变化同步到数据库中,从而在数据库中创建表。

python manage.py makemigrations
python manage.py migrate

创建自定义装饰器

现在我们已经建立了我们的项目,让我们深入地创建我们的自定义装饰器。

Authentication_not_required装饰器

导航到account文件夹并创建一个新文件,名为decorators.py 。这个decorators.py文件将包含所有与认证相关的装饰器。

在该文件中复制并粘贴下面的代码。

import functools
from django.shortcuts import redirect
from django.contrib import messages

def authentication_not_required(view_func, redirect_url="accounts:profile"):
    """
        this decorator ensures that a user is not logged in,
        if a user is logged in, the user will get redirected to 
        the url whose view name was passed to the redirect_url parameter
    """
    @functools.wraps(view_func)
    def wrapper(request, *args, **kwargs):
        if not request.user.is_authenticated:
            return view_func(request,*args, **kwargs)
        messages.info(request, "You need to be logged out")
        print("You need to be logged out")
        return redirect(redirect_url)
    return wrapper

在上面的代码中。

  • 一个条件语句检查提出请求的用户是否已经登录,如果用户没有登录,我们将调用视图函数,否则将用户重定向到redirect_url。
  • 消息框架用于通知用户,如果用户的预期操作没有被批准。该消息也会在终端/cmd中使用print语句显示。
  • @functools.wraps(view_func) 将view_func的元数据(包括docname)复制到包装函数中。

需要验证。

def verification_required(view_func, verification_url="accounts:activate_email"):
    """
        this decorator restricts users who have not been verified
        from accessing the view function passed as it argument and
        redirect the user to page where their account can be activated
    """
    @functools.wraps(view_func)
    def wrapper(request, *args, **kwargs):
        if request.user.is_active:
            return view_func(request, *args, **kwargs)
        messages.info(request, "Email verification required")
        print("You need to be logged out")
        return redirect(verification_url)  
    return wrapper

在上面的代码中。

  • 一个条件语句检查用户是否处于活动状态。如果用户处于活动状态,装饰器所使用的视图函数将被调用。如果不是这样,这将把用户重定向到verify_url,同时使用模板上的消息框架和终端或cmd中的print语句显示一个消息。

xhr_request_only

所需的xhr_request_only装饰器将在post文件夹中创建,因为它与post应用程序有关。我们将使用它来装饰视图,该视图将在下一节中处理帖子的向上和向下投票。

导航到post目录,创建一个名为decorators.py的新文件。

import functools
from django.shortcuts import redirect
from django.http import HttpResponseBadRequest

def xhr_request_only(view_func):
    """
    this decorators ensures that the view func accepts only 
    XML HTTP Request i.e request done via fetch or ajax
    """
    @functools.wraps(view_func)
    def wrapper(request, *args, **kwargs):
        if request.headers.get('x-requested-with') == 'XMLHttpRequest':
            return view_func(request, *args, **kwargs)
        print("Can't Process this Request")
        return HttpResponseBadRequest("Can't Process this Request")
    return wrapper

在上面的代码中。

  • 我们访问请求头信息,以了解用户提出的请求是用什么来生成的。如果请求是用XMLHttpRequest发出的,我们就调用view函数,否则就会向用户返回一个坏的请求响应。

坏的请求响应的状态代码是400。

如果你使用的是以前的Django版本,比如3.2或更早的版本,请求有一个叫做is_ajax的方法,如果请求是通过XMLHttpRequest提出的,则返回True,否则返回false。

所以检查的结果就是。

if request.is_ajax():
    ...

利用自定义装饰器

现在是时候开始使用你在上一节中创建的自定义装饰器和视图函数了。

浏览accounts/forms.py并编写以下几行代码。

from django import forms
from django.contrib.auth import get_user_model
from django.conf import settings

User = get_user_model()
class RegisterForm(forms.ModelForm):
    password1 = forms.CharField(widget=forms.PasswordInput, min_length=6)
    password2 = forms.CharField(widget=forms.PasswordInput, min_length=6)

    class Meta:
        model = User
        fields = ['username', 'email']

    def clean_password2(self):
        password1 = self.cleaned_data.get('password1')
        password2 = self.cleaned_data.get('password2')
        if password1 != password2:
            raise forms.ValidationError("Passwords don't match")
        return password1

class LoginForm(forms.Form):
    username = forms.CharField()
    password = forms.CharField(widget=forms.PasswordInput, min_length=6)

在上面的代码中。

  • 我们创建了两个表单;一个用于登录和注册的表单。
  • 在注册表单中,clean_password2方法检查以确保在第一个密码字段和确认密码字段中输入的字符是一样的。

导航到 accounts/views.py。

from django.http import HttpResponse
from django.shortcuts import render, redirect
from django.contrib import auth
from .forms import RegisterForm,LoginForm
from django.contrib import messages
from .decorators import authentication_not_required, verification_required
from django.contrib.auth.decorators import login_required

@authentication_not_required
def register(request):
    """  
        registration view for users
    """
    if request.method == 'POST':
        form = RegisterForm(request.POST)
        if form.is_valid():
            # don't save to the database yet
            instance = form.save(commit=False)
            instance.set_password(form.cleaned_data['password1'])
            instance.is_active = False
            instance.save()
            messages.success(request, "Account created successfully!")
            return redirect('accounts:login')
        else:
            messages.error(request, 'Error creating your account!!!')
    else:
        form = RegisterForm()
    return render(request, 'accounts/register.html', context={'form': form})

@authentication_not_required
def login(request):
    login_form = LoginForm()
    if request.method == 'POST':
        login_form = LoginForm(request.POST)
        if login_form.is_valid():
            cleaned_data = login_form.cleaned_data
            user = auth.authenticate(request, username=cleaned_data.get('username'), 
            password=cleaned_data.get('password'))
            if user is not None:
                auth.login(request, user)
                messages.success(request, "Logged in Successfully!")
                print("Logged in Successfully!")
                return redirect("accounts:profile")
            else:
                messages.error(request, "Invalid credentials, wrong username or password")
                print("Invalid credentials, wrong username or password")
        else:
            messages.error(request, "form invalid")
            print("form invalid")
    return render(request, 'accounts/login.html', {'form': login_form})

def profile(request):
    return render(request, 'accounts/profile.html')

让我们简单了解一下上述代码中发生了什么。

  • 在第3行,我们导入了auth,以便在从用户的用户名和密码获得用户对象时使用默认的认证函数。
  • 在第4行,我们从forms.py文件中导入LoginForm和RegisterForm,以便在模板中呈现和用户填写。
  • 在注册视图中,新创建的用户已经被故意变成非活动的,以说明verificaton_required装饰器。
  • authentication_not_required装饰器已经被应用到注册和登录视图中,以实现其目的。
  • 在个人资料视图中,我们简单地将一个名为profile.html的模板呈现给用户。profile.html显示的是文本简介和我们视图中的消息框架中的消息。

templates/accounts/profile.html。

{% for message in messages %}
    <p>{{ message }}</p>
{% endfor %}
<h1>Profile</h1>

templates/accounts/register.html

<form action="{% url 'accounts:register' %}" method="post">
    {% csrf_token %}
    {{ form.as_p }}
    <input type="submit" value="Register">
</form>

login.html文件与register.html相同,只是用login代替register。创建一个名为urls.py 的新文件,将路由/路径映射到正确的视图函数中。

from django.urls import path
from .views import (login, profile, register)

app_name = 'accounts'
urlpatterns = [
    path('login', login, name="login"),
    path('register', register, name="register"),
    path('profile', profile, name="profile"),
]

测试到目前为止编写的应用程序...... 创建一个超级用户,然后启动你的服务器。

python manage.py createsuperuser
python manage.py runserver

进入你的浏览器并导航到。http://127.0.0.1:8000/

检查下面的图片。

Authentication Decorators in Action

  • 应向用户的电子邮件地址发送一个链接或OTP,以验证他/她的电子邮件账户并激活用户。为了使本教程简单,管理员可以使用户激活。

默认的认证后台不会为不活跃的用户返回一个用户对象。因此,一个使用'register.html'创建了账户的用户将无法登录,除非管理员使该用户处于激活状态。

现在要实现前面创建的'xhr_request_only'装饰器,在'post/views.py'中添加以下代码。

from django.http import JsonResponse
from django.shortcuts import get_object_or_404, render
from .decorators import xhr_request_only
from accounts.decorators import verification_required
from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_POST
from .models import Post
import json

def post_detail_view(request, id):
    post = get_object_or_404(Post, id=id)
    return render(request, 'posts/detail.html', {'post': post})

@verification_required
@login_required(login_url="accounts:login")
@xhr_request_only
@require_POST
def post_vote_view(request):
    data  = json.loads(request.body.decode("utf-8"))
    id = data.get('postId')
    action = data.get('action')
    post = get_object_or_404(Post, id=id)
    if action == "upvote":
        post.downvote_users.remove(request.user)
        post.upvote_users.add(request.user)
    elif action == "downvote":
        post.upvote_users.remove(request.user)
        post.downvote_users.add(request.user)
    return JsonResponse({"message": action})

在上面的代码中。

  • 第3行是从装饰器模块中导入xhr_request_only。
  • 第4行是在账户应用程序中从装饰器模块导入verification_required。
  • 内置的require_POST装饰器已被用于 "post_vote_view",以确保它只接受 "POST "请求方法。
  • 我们的自定义装饰器;verification_required和xhr_request_only也被用来实现其目的。
  • 你可以看到,多个装饰器可以被堆叠起来,即在一个视图中使用多个装饰器。
  • request.body返回一个字节。我们将需要对这个字节进行解码,以获得字典。解码过程返回一个字符串,json.load将其转换为一个字典。因此,我们在第8行导入json并在第18行使用json.load的原因。
  • 如果动作是向上投票,我们确保该用户被从向下投票的用户中删除,并将该用户添加到向上投票的用户字段。如果动作是降票,我们确保该用户也从upvote_users中删除,并将该用户添加到downvote_users字段。

这样做可以确保一个用户对一个帖子只能投上票或下票,而不能同时投上票和下票。你可能会想,如果用户不在这个字段里,而我们又想删除这个用户,会发生什么?

答案很简单,如果用户不存在,删除管理器不会返回错误。同样,如果用户已经存在于相关对象中,添加管理器也不会在那里复制用户。

在post应用程序中创建一个templates/posts目录,并创建一个名为detail.html的文件。

为了测试我们的装饰器。

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Bootstrap demo</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0-beta1/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-0evHe/X+R7YkIZDRvuzKMRqM+OrBnVFBL6DOitfPri4tjfHxaWutUpFmBp4vmVor" crossorigin="anonymous">
  </head>
  <body>
    {% with post_id=post.id %}  
    <div class="card" style="width: 18rem;">
        <div class="card-body">
          <h5 class="card-title">First Post</h5>
          <p class="card-text">{{ post.post }}</p>
          <button data-id = "{{ post_id }}" class="btn btn-primary" id="upvote">UPVOTE</button>
          <h2>{{ post.get_no_of_upvote }}</h2>
          <button data-id = "{{ post_id }}" class="btn btn-primary" id="downvote">DOWNVOTE</button>
          <h2>{{ post.get_no_of_downvote }}</h2>
            {% csrf_token %}
        </div>
      </div>
    {% endwith %}
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0-beta1/dist/js/bootstrap.bundle.min.js" integrity="sha384-pprn3073KE6tl6bjs2QrFaJGz5/SUsLqktiwsUTF55Jfv3qYSDhgCecCxMW52nD2" crossorigin="anonymous"></script>
    <script>
        const voteUrl = "{% url 'posts:post_vote' %}";
        const upvoteBtn = document.getElementById("upvote");
        const downvoteBtn = document.getElementById("downvote");
        const csrfToken = document.querySelector("input[name=csrfmiddlewaretoken]");
        function vote (action) {
            const data = {
            'action': action,
            'postId': parseInt("{{ post.id }}")
            }
            console.log(data);
            fetch(voteUrl, 
            {   
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                "X-Requested-With": "XMLHttpRequest",
                "HTTP_X_REQUESTED_WITH": "XMLHttpRequest",
                'X-CSRFToken': csrfToken.value,
            },
            credentials: 'same-origin',
            body: JSON.stringify(data) 
        })
        .then(res => res.json())
        .then(resdata => console.log(resdata));
        }

        upvoteBtn.addEventListener("click", vote.bind(null, 'upvote'));
        downvoteBtn.addEventListener("click", vote.bind(null, 'downvote'));
    </script>
</body>
</html>

在上面的代码中。

  • with 模板标签创建了一个名为post_id的变量,它存储了帖子id的值。帖子的字段也在模板中被呈现出来。有两个按钮,一个是向上投票,另一个是向下投票。我们还调用get_no_upvote和get_no_downvote方法来显示一个帖子所拥有的upvote和downvote的数量。
  • 在脚本标签中,我们使用url模板标签获得投票的URL,而不是硬编码。我们还选择了向上和向下投票的按钮,以监听每个按钮的点击事件。当这两个按钮中的任何一个被点击时,投票函数被调用。
  • 投票函数使用JavaScript中的fetch API向 voteUrl发送一个POST请求。它向后台发送动作(即向上投票或向下投票)和帖子ID,以及请求中的csrftoken。我们在控制台记录来自后端的响应。

使用fetch发送的数据可以在request.body而不是request.POST中访问。

上面的代码使用了bootstrap来使页面具有吸引力。

在post目录下创建一个urls.py文件,将下面的代码复制到里面。

from django.urls import path
from .views import post_detail_view, post_vote_view
app_name = 'posts'
urlpatterns = [
    path('<int:id>', post_detail_view, name="detail"),
    path('vote', post_vote_view, name="post_vote"),
]

下面是xhr_request_only装饰器运行时的gif图。

Xhr_request_only decorator in Action

装饰基于类的视图

Django应用程序总是使用基于类的视图,因为它们很简单。但是在基于类的视图中使用装饰器并不像基于函数的视图那样简单。幸运的是,Django提供了一个名为method_decorator的实用装饰器来实现这一点。

为了给基于类的视图的每个实例添加一个装饰器函数,你需要装饰类的定义本身。要做到这一点,你要把要装饰的方法的名字作为关键字参数名传递给它。

from .decorators import authentication_not_required
from django.utils.decorators import method_decorator

@method_decorator(authentication_not_required, name='dispatch')
class LoginView(TemplateView):
    ...

结论

如果你发现你在许多视图中重复相同的验证,建议你为你的视图创建自定义装饰器。

正如你在上面看到的,你会同意利用装饰器远比在基于函数的视图中写if语句和太多的条件语句要好得多。

编码愉快!!!