表单的应用

我们继续来完成上一章节中的项目,实现“用户注册”和“用户登录”的功能,并限制只有登录的用户才能为老师投票。Django框架中提供了对表单的封装,而且提供了多种不同的使用方式。

首先添加用户模型。

  1. class User(models.Model):
  2. """用户"""
  3. no = models.AutoField(primary_key=True, verbose_name='编号')
  4. username = models.CharField(max_length=20, unique=True, verbose_name='用户名')
  5. password = models.CharField(max_length=32, verbose_name='密码')
  6. regdate = models.DateTimeField(auto_now_add=True, verbose_name='注册时间')
  7. class Meta:
  8. db_table = 'tb_user'
  9. verbose_name_plural = '用户'

通过生成迁移和执行迁移操作,在数据库中创建对应的用户表。

  1. (venv)$ python manage.py makemigrations vote
  2. ...
  3. (venv)$ python manage.py migrate
  4. ...

定制一个非常简单的注册模板页面。

  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <title>用户注册</title>
  6. <style>/* 此处省略层叠样式表选择器 */</style>
  7. </head>
  8. <body>
  9. <h1>用户注册</h1>
  10. <hr>
  11. <p class="hint">{{ hint }}</p>
  12. <form action="/register/" method="post">
  13. {% csrf_token %}
  14. <div class="input">
  15. <label for="username">用户名:</label>
  16. <input type="text" id="username" name="username">
  17. </div>
  18. <div class="input">
  19. <label for="password">密码:</label>
  20. <input type="password" id="password" name="password">
  21. </div>
  22. <div class="input">
  23. <label for="repassword">确认密码:</label>
  24. <input type="password" id="repassword" name="repassword">
  25. </div>
  26. <div class="input">
  27. <input type="submit" value="注册">
  28. <input type="reset" value="重置">
  29. </div>
  30. </form>
  31. <a href="/login">返回登录</a>
  32. </body>
  33. </html>

注意,在上面的表单中,我们使用了模板指令{% csrf_token %}为表单添加一个隐藏域(type属性值为hidden的input标签),它的作用是在表单中生成一个随机令牌(token)来防范跨站请求伪造(通常简称为CSRF),这也是Django在提交表单时的硬性要求,除非我们设置了免除CSRF令牌。下图是一个关于CSRF简单生动的例子,它来自于维基百科

表单的应用 - 图1

用户在提交注册表单时,我们还需要对用户的输入进行验证,例如我们的网站要求用户名必须由字母、数字、下划线构成且长度在4-20个字符之间,密码的长度为8-20个字符,确认密码必须跟密码保持一致。这些验证操作首先可以通过浏览器中的JavaScript代码来完成,但是即便如此,在服务器端仍然要对用户输入再次进行验证来避免将无效的数据库交给数据库,因为用户可能会禁用浏览器的JavaScript功能,也有可能绕过浏览器的输入检查将注册数据提交给服务器,所以服务器端的用户输入检查仍然是必要的。

我们可以利用Django框架封装的表单功能来对用户输入的有效性进行检查,虽然Django封装的表单还能帮助我们定制出页面上的表单元素,但这显然是一种灵活性很差的设计,这样的功能在实际开发中基本不考虑,所以表单主要的作用就在于数据验证,具体的做法如下所示。

  1. USERNAME_PATTERN = re.compile(r'\w{4,20}')
  2. class RegisterForm(forms.ModelForm):
  3. repassword = forms.CharField(min_length=8, max_length=20)
  4. def clean_username(self):
  5. username = self.cleaned_data['username']
  6. if not USERNAME_PATTERN.fullmatch(username):
  7. raise ValidationError('用户名由字母、数字和下划线构成且长度为4-20个字符')
  8. return username
  9. def clean_password(self):
  10. password = self.cleaned_data['password']
  11. if len(password) < 8 or len(password) > 20:
  12. raise ValidationError('无效的密码,密码长度为8-20个字符')
  13. return to_md5_hex(self.cleaned_data['password'])
  14. def clean_repassword(self):
  15. repassword = to_md5_hex(self.cleaned_data['repassword'])
  16. if repassword != self.cleaned_data['password']:
  17. raise ValidationError('密码和确认密码不一致')
  18. return repassword
  19. class Meta:
  20. model = User
  21. exclude = ('no', 'regdate')

上面,我们定义了一个与User模型绑定的表单(继承自ModelForm),我们排除了用户编号(no)和注册日期(regdate)这两个属性,并添加了一个repassword属性用来接收从用户表单传给服务器的确认密码。我们在定义User模型时已经对用户名的最大长度进行了限制,上面我们又对确认密码的最小和最大长度进行了限制,但是这些都不足以完成我们对用户输入的验证。上面以clean_打头的方法就是我们自定义的验证规则。很明显,clean_username是对用户名的检查,而clean_password是对密码的检查。由于数据库二维表中不应该保存密码的原文,所以对密码做了一个简单的MD5摘要处理,实际开发中如果只做出这样的处理还不太够,因为即便使用了摘要,仍然有利用彩虹表反向查询破解用户密码的风险,如何做得更好我们会在后续的内容中讲到。为字符串生成MD5摘要的代码如下所示。

  1. def to_md5_hex(message):
  2. return hashlib.md5(message.encode()).hexdigest()

新增一个视图函数实现用户注册的功能。

  1. def register(request):
  2. page, hint = 'register.html', ''
  3. if request.method == 'POST':
  4. form = RegisterForm(request.POST)
  5. if form.is_valid():
  6. form.save()
  7. page = 'login.html'
  8. hint = '注册成功,请登录'
  9. else:
  10. hint = '请输入有效的注册信息'
  11. return render(request, page, {'hint': hint})

如果用户发起GET请求,将直接跳转到注册的页面;如果用户以POST方式提交注册表单,则创建自定义的注册表单对象并获取用户输入。可以通过表单对象的is_valid方法对表单进行验证,如果用户输入没有问题,该方法返回True,否则返回False;由于我们定义的RegisterForm继承自ModelForm,因此也可以直接使用表单对象的save方法来保存模型。下面是注册请求的URL配置。

  1. from django.contrib import admin
  2. from django.urls import path
  3. from vote import views
  4. urlpatterns = [
  5. # 此处省略上面的代码
  6. path('register/', views.register, name='register'),
  7. # 此处省略下面的代码
  8. ]

说明:path函数可以通过name参数给URL绑定一个逆向解析的名字,也就是说,如果需要可以从后面给的名字逆向解析出对应的URL。

我们再来定制一个非常简单的登录页。

  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <title>用户登录</title>
  6. <style>/* 此处省略层叠样式表选择器 */</style>
  7. </head>
  8. <body>
  9. <h1>用户登录</h1>
  10. <hr>
  11. <p class="hint">{{ hint }}</p>
  12. <form action="/login/" method="post">
  13. {% csrf_token %}
  14. <div class="input">
  15. <label for="username">用户名:</label>
  16. <input type="text" id="username" name="username">
  17. </div>
  18. <div class="input">
  19. <label for="password">密码:</label>
  20. <input type="password" id="password" name="password">
  21. </div>
  22. <div class="input captcha">
  23. <label for="captcha">验证码:</label>
  24. <input type="text" id="captcha" name="captcha">
  25. <img src="/captcha/" width="120">
  26. </div>
  27. <div class="input">
  28. <input type="submit" value="登录">
  29. <input type="reset" value="重置">
  30. </div>
  31. </form>
  32. <a href="/register">注册新用户</a>
  33. </body>
  34. </html>

上面的登录页中,我们要求用户提供验证码,验证码全称是全自动区分计算机和人类的公开图灵测试,它是一种用来区分系统的使用者是计算机还是人类的程序。简单的说就是程序出一个只有人类能够回答的问题,由系统使用者来解答,由于计算机理论上无法解答程序提出的问题,所以回答出问题的用户就可以被认为是人类。大多数的网站都使用了不同类型的验证码技术来防范用程序自动注册用户或模拟用户登录(暴力破解用户密码),因为验证码具有一次消费性,而没有通过图灵测试的程序是不能够完成注册或登录的。

在Python程序中生成验证码并不算特别复杂,但需要三方库Pillow的支持(PIL的分支),因为要对验证码图片进行旋转、扭曲、拉伸以及加入干扰信息来防范那些用OCR(光学文字识别)破解验证码的程序。下面的代码封装了生成验证码图片的功能,大家可以直接用这些代码来生成图片验证码,不要“重复发明轮子”。

  1. """
  2. 图片验证码
  3. """
  4. import os
  5. import random
  6. from io import BytesIO
  7. from PIL import Image
  8. from PIL import ImageFilter
  9. from PIL.ImageDraw import Draw
  10. from PIL.ImageFont import truetype
  11. class Bezier(object):
  12. """贝塞尔曲线"""
  13. def __init__(self):
  14. self.tsequence = tuple([t / 20.0 for t in range(21)])
  15. self.beziers = {}
  16. def make_bezier(self, n):
  17. """绘制贝塞尔曲线"""
  18. try:
  19. return self.beziers[n]
  20. except KeyError:
  21. combinations = pascal_row(n - 1)
  22. result = []
  23. for t in self.tsequence:
  24. tpowers = (t ** i for i in range(n))
  25. upowers = ((1 - t) ** i for i in range(n - 1, -1, -1))
  26. coefs = [c * a * b for c, a, b in zip(combinations,
  27. tpowers, upowers)]
  28. result.append(coefs)
  29. self.beziers[n] = result
  30. return result
  31. class Captcha(object):
  32. """验证码"""
  33. def __init__(self, width, height, fonts=None, color=None):
  34. self._image = None
  35. self._fonts = fonts if fonts else \
  36. [os.path.join(os.path.dirname(__file__), 'fonts', font)
  37. for font in ['ArialRB.ttf', 'ArialNI.ttf', 'Georgia.ttf', 'Kongxin.ttf']]
  38. self._color = color if color else random_color(0, 200, random.randint(220, 255))
  39. self._width, self._height = width, height
  40. @classmethod
  41. def instance(cls, width=200, height=75):
  42. prop_name = f'_instance_{width}_{height}'
  43. if not hasattr(cls, prop_name):
  44. setattr(cls, prop_name, cls(width, height))
  45. return getattr(cls, prop_name)
  46. def background(self):
  47. """绘制背景"""
  48. Draw(self._image).rectangle([(0, 0), self._image.size],
  49. fill=random_color(230, 255))
  50. def smooth(self):
  51. """平滑图像"""
  52. return self._image.filter(ImageFilter.SMOOTH)
  53. def curve(self, width=4, number=6, color=None):
  54. """绘制曲线"""
  55. dx, height = self._image.size
  56. dx /= number
  57. path = [(dx * i, random.randint(0, height))
  58. for i in range(1, number)]
  59. bcoefs = Bezier().make_bezier(number - 1)
  60. points = []
  61. for coefs in bcoefs:
  62. points.append(tuple(sum([coef * p for coef, p in zip(coefs, ps)])
  63. for ps in zip(*path)))
  64. Draw(self._image).line(points, fill=color if color else self._color, width=width)
  65. def noise(self, number=50, level=2, color=None):
  66. """绘制扰码"""
  67. width, height = self._image.size
  68. dx, dy = width / 10, height / 10
  69. width, height = width - dx, height - dy
  70. draw = Draw(self._image)
  71. for i in range(number):
  72. x = int(random.uniform(dx, width))
  73. y = int(random.uniform(dy, height))
  74. draw.line(((x, y), (x + level, y)),
  75. fill=color if color else self._color, width=level)
  76. def text(self, captcha_text, fonts, font_sizes=None, drawings=None, squeeze_factor=0.75, color=None):
  77. """绘制文本"""
  78. color = color if color else self._color
  79. fonts = tuple([truetype(name, size)
  80. for name in fonts
  81. for size in font_sizes or (65, 70, 75)])
  82. draw = Draw(self._image)
  83. char_images = []
  84. for c in captcha_text:
  85. font = random.choice(fonts)
  86. c_width, c_height = draw.textsize(c, font=font)
  87. char_image = Image.new('RGB', (c_width, c_height), (0, 0, 0))
  88. char_draw = Draw(char_image)
  89. char_draw.text((0, 0), c, font=font, fill=color)
  90. char_image = char_image.crop(char_image.getbbox())
  91. for drawing in drawings:
  92. d = getattr(self, drawing)
  93. char_image = d(char_image)
  94. char_images.append(char_image)
  95. width, height = self._image.size
  96. offset = int((width - sum(int(i.size[0] * squeeze_factor)
  97. for i in char_images[:-1]) -
  98. char_images[-1].size[0]) / 2)
  99. for char_image in char_images:
  100. c_width, c_height = char_image.size
  101. mask = char_image.convert('L').point(lambda i: i * 1.97)
  102. self._image.paste(char_image,
  103. (offset, int((height - c_height) / 2)),
  104. mask)
  105. offset += int(c_width * squeeze_factor)
  106. @staticmethod
  107. def warp(image, dx_factor=0.3, dy_factor=0.3):
  108. """图像扭曲"""
  109. width, height = image.size
  110. dx = width * dx_factor
  111. dy = height * dy_factor
  112. x1 = int(random.uniform(-dx, dx))
  113. y1 = int(random.uniform(-dy, dy))
  114. x2 = int(random.uniform(-dx, dx))
  115. y2 = int(random.uniform(-dy, dy))
  116. warp_image = Image.new(
  117. 'RGB',
  118. (width + abs(x1) + abs(x2), height + abs(y1) + abs(y2)))
  119. warp_image.paste(image, (abs(x1), abs(y1)))
  120. width2, height2 = warp_image.size
  121. return warp_image.transform(
  122. (width, height),
  123. Image.QUAD,
  124. (x1, y1, -x1, height2 - y2, width2 + x2, height2 + y2, width2 - x2, -y1))
  125. @staticmethod
  126. def offset(image, dx_factor=0.1, dy_factor=0.2):
  127. """图像偏移"""
  128. width, height = image.size
  129. dx = int(random.random() * width * dx_factor)
  130. dy = int(random.random() * height * dy_factor)
  131. offset_image = Image.new('RGB', (width + dx, height + dy))
  132. offset_image.paste(image, (dx, dy))
  133. return offset_image
  134. @staticmethod
  135. def rotate(image, angle=25):
  136. """图像旋转"""
  137. return image.rotate(random.uniform(-angle, angle),
  138. Image.BILINEAR, expand=1)
  139. def generate(self, captcha_text='', fmt='PNG'):
  140. """生成验证码(文字和图片)"""
  141. self._image = Image.new('RGB', (self._width, self._height), (255, 255, 255))
  142. self.background()
  143. self.text(captcha_text, self._fonts,
  144. drawings=['warp', 'rotate', 'offset'])
  145. self.curve()
  146. self.noise()
  147. self.smooth()
  148. image_bytes = BytesIO()
  149. self._image.save(image_bytes, format=fmt)
  150. return image_bytes.getvalue()
  151. def pascal_row(n=0):
  152. """生成Pascal三角第n行"""
  153. result = [1]
  154. x, numerator = 1, n
  155. for denominator in range(1, n // 2 + 1):
  156. x *= numerator
  157. x /= denominator
  158. result.append(x)
  159. numerator -= 1
  160. if n & 1 == 0:
  161. result.extend(reversed(result[:-1]))
  162. else:
  163. result.extend(reversed(result))
  164. return result
  165. def random_color(start=0, end=255, opacity=255):
  166. """获得随机颜色"""
  167. red = random.randint(start, end)
  168. green = random.randint(start, end)
  169. blue = random.randint(start, end)
  170. if opacity is None:
  171. return red, green, blue
  172. return red, green, blue, opacity

说明:上面的代码在生成验证码图片时用到了三种字体文件,使用上面的代码时需要添加字体文件到应用目录下的fonts目录中。

下面的视图函数用来生成验证码并通过HttpResponse对象输出到用户浏览器中。

  1. ALL_CHARS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
  2. def get_captcha_text(length=4):
  3. selected_chars = random.choices(ALL_CHARS, k=length)
  4. return ''.join(selected_chars)
  5. def get_captcha(request):
  6. """获得验证码"""
  7. captcha_text = get_captcha_text()
  8. image = Captcha.instance().generate(captcha_text)
  9. return HttpResponse(image, content_type='image/png')

生成的验证码如下图所示。

表单的应用 - 图2

为了验证用户提交的登录表单,我们再定义个表单类。

  1. class LoginForm(forms.Form):
  2. username = forms.CharField(min_length=4, max_length=20)
  3. password = forms.CharField(min_length=8, max_length=20)
  4. captcha = forms.CharField(min_length=4, max_length=4)
  5. def clean_username(self):
  6. username = self.cleaned_data['username']
  7. if not USERNAME_PATTERN.fullmatch(username):
  8. raise ValidationError('无效的用户名')
  9. return username
  10. def clean_password(self):
  11. return to_md5_hex(self.cleaned_data['password'])

跟之前我们定义的注册表单类略有区别,登录表单类直接继承自Form没有跟模型绑定,定义了三个字段分别对应登录表单中的用户名、密码和验证码。接下来是处理用户登录的视图函数。

  1. def login(request):
  2. hint = ''
  3. if request.method == 'POST':
  4. form = LoginForm(request.POST)
  5. if form.is_valid():
  6. username = form.cleaned_data['username']
  7. password = form.cleaned_data['password']
  8. user = User.objects.filter(username=username, password=password).first()
  9. if user:
  10. return redirect('/')
  11. else:
  12. hint = '用户名或密码错误'
  13. else:
  14. hint = '请输入有效的登录信息'
  15. return render(request, 'login.html', {'hint': hint})

映射URL。

  1. from django.contrib import admin
  2. from django.urls import path
  3. from vote import views
  4. urlpatterns = [
  5. # 此处省略上面的代码
  6. path('login/', views.login, name='login'),
  7. # 此处省略下面的代码
  8. ]

需要指出,上面我们设定用户登录成功时直接返回首页,而且在用户登录时并没有验证用户输入的验证码是否正确,这些我们留到下一个单元再为大家讲解。另外,如果要在Django自带的管理后台中进行表单验证,可以在admin.py的模型管理类中指定form属性为自定义的表单即可,例如:

  1. class UserForm(forms.ModelForm):
  2. password = forms.CharField(min_length=8, max_length=20,
  3. widget=forms.PasswordInput, label='密码')
  4. def clean_username(self):
  5. username = self.cleaned_data['username']
  6. if not USERNAME_PATTERN.fullmatch(username):
  7. raise ValidationError('用户名由字母、数字和下划线构成且长度为4-20个字符')
  8. return username
  9. def clean_password(self):
  10. password = self.cleaned_data['password']
  11. return to_md5_hex(self.cleaned_data['password'])
  12. class Meta:
  13. model = User
  14. exclude = ('no', )
  15. class UserAdmin(admin.ModelAdmin):
  16. list_display = ('no', 'username', 'password', 'email', 'tel')
  17. ordering = ('no', )
  18. form = UserForm
  19. list_per_page = 10
  20. admin.site.register(User, UserAdmin)