介绍
到目前为止,我们已经构建了一些很棒的API端点,用于以性能和表现方式对用户进行认证和授权。但是,除了与Postman(或在VS代码上使用Thunder Client)进行测试,并且对于那些通过Frontend应用程序构建previous series的人,我们还没有使我们的应用程序变得足够简单发出一个简单的命令,该命令通过应用程序运行并报告该应用程序是否按预期工作。另外,在这方面,Python比JavaScript更好。我们一定会以与社区所采用的公认样式的样式编写我们的代码。由于Python是一种动态键入的语言,因此我们需要一种在所使用的所有变量上执行类型的方法,以使我们不会将字符串分配给整数变量。我们还需要部署我们的应用程序,以便其他所有人都可以访问我们建立的美丽。所有这些都是我们将在本文中解决的。
假设和建议
假定您熟悉Django。我还建议您在本系列中浏览以前的文章,以便您可以跟上这篇文章。
源代码
该系列的源代码通过:
在GitHub上托管Sirneij / django-auth-backend
带有Sveltekit Frontend的基于Django会话的身份验证系统
django-auth-backend
基于Sveltekit Frontend和GitHub Actions的基于DJANGO会话的身份验证系统。
此应用程序使用最小依赖项(纯django-无REST API框架)来构建安全,性能和可靠(具有100%自动化测试覆盖范围,使用Python最佳统一代码标准执行静态分析) - 基于基于Sveltekit的前端应用程序消耗的基于身份验证的REST API。
用户的配置文件图像直接上传到AWS S3(在测试中,我们抛弃了S3并使用Django的InMemoryStorage进行更快的测试)。
还合并了一个自定义密码重置过程,芹菜任务进行了电子邮件发送。
在本地运行
-
要运行应用程序,请克隆:
git克隆https://github.com/sirneij/django-auth-backend.git
您可以,如果需要,请抓住它的frontend counterpart。
-
将目录更改为文件夹,并使用Python 3.9、3.10或3.11(根据三个版本进行测试)创建虚拟环境。然后激活它:
执行
步骤1:静态分析和测试设置
您需要安装
pytest-cov
,pytest-django
,pytest-bdd
,pyflakes
,pylint
,pylint-celery
,pylint-celery
,pytest-xdist
,django-stubs
和其他软件包。为了减轻您的负担,我已经安装了这些负担,并将它们提供在项目的requirements_dev.txt
中。
首先,让我们创建一些bash脚本以自动运行我们的测试和静态分析。一个将运行测试,另一个将执行静态分析,最后一个将有助于删除所有测试数据库,以使它们不会集中我们的计算机:
在我们项目的根源上,创建一个scripts
文件夹,在其中创建test.sh
,drop_test_dbs.sh
和static_validation.sh
:
# scripts/tests.sh
#!/usr/bin/env bash
py.test -n auto --nomigrations --reuse-db -W error::RuntimeWarning --cov=src --cov-report=html tests/
此命令使用pytest-xdist
允许分布式测试。我们在这里使用auto
表示将要产生测试的工人人数。 auto
等于机器上可用的CPU数量。您可以使用诸如2、4或任何整数之类的数字而不是自动。只需确保数量小于或等于您的机器拥有的CPU数量即可。 --nomigrations
使用pytest-django
禁用我们的测试迁移迁移。这使得测试套件更快。此外,--reuse-db
使用pytest-django
在测试运行后而无需删除数据库而创建数据库。因此,我们需要drop_test_dbs.sh
的原因。 --cov=src --cov-report=html
使用pytest-cov
来帮助报告我们的测试统计数据。 -W error::RuntimeWarning
将我们的运行时警告变成了错误。接下来是drop_test_dbs.sh
:
# src/drop_test_dbs.sh
#!/bin/bash
PREFIX='test' || '_sqlx_test'
export PGPASSWORD=<your_db_password>
export PGUSER=<your_db_user>
export PGHOST=<your_db_host>
export PGPORT=<your_db_port>
TEST_DB_LIST=$(psql -l | awk '{ print $1 }' | grep '^[a-z]' | grep -v template | grep -v postgres)
for TEST_DB in $TEST_DB_LIST ; do
if [ $(echo $TEST_DB | sed "s%^$PREFIX%%") != $TEST_DB ]
then
echo "Dropping $TEST_DB"
dropdb --if-exists $TEST_DB
fi
done
它使用您的数据库凭据删除以“测试”开头的所有DB。
下一步:
# scripts/static_validation.sh
#!/usr/bin/env bash
# checks whether or not the source files conform with black and isort formatting
black --skip-string-normalization --check tests
black --skip-string-normalization --check src
isort --atomic --profile black -c src
isort --atomic --profile black -c tests
cd src
# Exits with non-zero code if there is any model without a corresponding migration file
python manage.py makemigrations --check --dry-run
# Uses prospector to ensure that the source code conforms with Python best practices
prospector --profile=../.prospector.yml --path=. --ignore-patterns=static
# Analysis and checks whether or not we have common security issues in our Python code.
bandit -r . -ll
# Checks for correct annotations
mypy .
这是很好的评论。为了确保您的代码通过它们,您必须在修改每个代码后运行以下内容:
black --skip-string-normalization src tests
isort --atomic --profile black src tests
--skip-string-normalization
可防止黑色用""
代替''
,反之亦然。
存储库还有其他重要文件。继续前进,我们负担不起使用S3进行测试。我们更喜欢使用文件系统或更好的内存存储。因此,我们将在测试期间覆盖STORAGES
设置和其他设置。一种方便的方法是在src/django_auth
中创建一个test_settings.py
文件:
# src/django_auth/test_settings.py
from django.test import override_settings
common_settings = override_settings(
STORAGES={
'default': {
'BACKEND': 'django.core.files.storage.InMemoryStorage',
},
'staticfiles': {
'BACKEND': 'django.contrib.staticfiles.storage.StaticFilesStorage',
},
},
DEFAULT_FROM_EMAIL='admin@example.com',
PASSWORD_HASHERS=[
'django.contrib.auth.hashers.MD5PasswordHasher',
],
)
我们使用Django的override_settings
来设置更快的存储,更快的密码hasher和默认的DEFAULT_FROM_EMAIL
。我们接下来使用此。
步骤2:测试
让我们从我们的models.py
开始。在tests
软件包中,创建一个users
软件包,在其中,test_models.py
:
from tempfile import NamedTemporaryFile
import pytest
from django.test import TestCase
from factory.django import DjangoModelFactory
from django_auth.test_settings import common_settings
from users.models import Articles, Series, User, UserProfile
class UserFactory(DjangoModelFactory):
first_name = 'John'
last_name = 'Doe'
is_active = True
class Meta:
model = User
django_get_or_create = ('email',)
class UserProfileFactory(DjangoModelFactory):
class Meta:
model = UserProfile
django_get_or_create = ('user',)
class SeriesFactory(DjangoModelFactory):
name = 'Some title'
image = NamedTemporaryFile(suffix='.jpg').name
class Meta:
model = Series
class ArticlesFactory(DjangoModelFactory):
title = 'Some article title'
url = 'https://dev.to/sirneij/authentication-system-using-python-django-and-sveltekit-23e1'
class Meta:
model = Articles
django_get_or_create = ('series',)
@common_settings
class UserModelTests(TestCase):
def setUp(self):
"""Test Setup."""
self.user = UserFactory.create(email='john@example.com')
def test_str_representation(self):
"""Test __str__ of user."""
self.assertEqual(str(self.user), f'{self.user.id} {self.user.email}')
@common_settings
class UserProfileModelTests(TestCase):
def setUp(self):
"""Test Setup."""
self.user = UserFactory.create(email='john@example.com')
self.user_profile = UserProfileFactory.create(user=self.user)
def test_str_representation(self):
"""Test __str__ of user."""
self.assertEqual(
str(self.user_profile),
f'<UserProfile {self.user_profile.id} {self.user_profile.user.email}>',
)
def test_create_user_success(self):
"""Test create_user method."""
user = User.objects.create_user(email='nelson@example.com', password='123456Data')
self.assertEqual(user.email, 'nelson@example.com')
def test_create_user_failure(self):
"""Test create_user method fails."""
with pytest.raises(ValueError, match='The Email must be set'):
User.objects.create_user(email='', password='123456Data')
def test_create_super_user_success(self):
"""Test create_user method."""
user = User.objects.create_superuser(email='nelson@example.com', password='123456Data')
self.assertEqual(user.email, 'nelson@example.com')
def test_create_super_user_failure(self):
"""Test create_user method fails."""
with pytest.raises(TypeError, match='Superusers must have a password.'):
User.objects.create_superuser(email='nelson@example.com', password=None)
@common_settings
class SeriesAndArticlesModelTests(TestCase):
def setUp(self):
"""Test Setup."""
self.series = SeriesFactory.create()
self.articles = ArticlesFactory.create(series=self.series)
def test_str_representation(self):
"""Test __str__ of series and articles."""
self.assertEqual(str(self.series), self.series.name)
self.assertEqual(str(self.articles), self.articles.title)
我们正在使用Factoryboy来初始化我们的模型。因此,我们可以使用ModelName.create()
创建模型实例。如果我们希望在Creation中提供一个或多个模型的字段,则在Meta
类中使用django_get_or_create = (<tuple_of_the_fields>)
。我添加了其他一些型号,例如Series
和Articles
,以帮助保存该项目的文章。为了提供图像字段的默认值,我使用了NamedTemporaryFile
的默认值。在每个测试用例上,我添加了我们从test_settings.py
导入的@common_settings
装饰器,以便我们使用更快的设置变量的测试。在每个测试案例中,我们都测试了所有重要事物的模型和其他模型。我们还测试了我们的自定义UserManager
。
接下来,让我们测试芹菜任务:
# tests/users/test_tasks.py
from unittest.mock import patch
from django.test import TestCase
from django_auth.test_settings import common_settings
from tests.users.test_models import UserFactory
from users.tasks import send_email_message
@common_settings
class SendMessageTests(TestCase):
@patch('users.tasks.send_mail')
def test_success(self, send_mail):
user = UserFactory.create(email='john@example.com')
send_email_message(
subject='Some subject',
template_name='test.html',
user_id=user.id,
ctx={'a': 'b'},
)
send_mail.assert_called_with(
subject='Some subject',
message='',
from_email='admin@example.com',
recipient_list=[user.email],
fail_silently=False,
html_message='',
)
我们使用了从unittest.mock
到django的patch
到Mock send_mail
,因此在测试中,我们不会通过模仿发送它来真正发送任何邮件。这是使您的测试可预测的好方法。我们还测试了我们的validate_email
ut的一部分:
# tests/users/test_utils.py
from django.test import TestCase
from users.utils import validate_email
class ValidateEmailTests(TestCase):
def test_email_empty(self):
"""Test when even is empty."""
is_valid, message = validate_email('')
self.assertFalse(is_valid)
self.assertEqual(message, 'Enter a valid email address.')
除了profile_update测试外,我们不会谈论其他测试:
# tests/users/views/test_profile_update.py
from shutil import rmtree
from tempfile import NamedTemporaryFile, mkdtemp
from django.test import Client, TestCase
from django.test.client import BOUNDARY, MULTIPART_CONTENT, encode_multipart
from django.test.utils import override_settings
from django.urls import reverse
from django.utils import timezone
from django_auth.test_settings import common_settings
from tests.users.test_models import UserFactory
@common_settings
class UserUpdateViewTests(TestCase):
def setUp(self) -> None:
"""Set up."""
self.url = reverse('users:profile_update')
self.client = Client()
self.media_folder = mkdtemp()
def tearDown(self):
rmtree(self.media_folder)
def test_update_user_not_authenticated(self):
"""Test when user is not authenticated."""
response = self.client.patch(self.url)
self.assertEqual(response.status_code, 401)
self.assertEqual(
response.json()['error'],
'You are not logged in. Kindly ensure you are logged in and try again',
)
def test_update_user_success_first_name(self):
"""Test update user success with first_name."""
# First login
user = UserFactory.create(email='john@example.com')
user.set_password('12345SomeData')
user.save()
url_login = reverse('users:login')
login_data = {'email': user.email, 'password': '12345SomeData'}
response = self.client.post(
path=url_login, data=login_data, content_type='application/json'
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['email'], user.email)
# User update
data = {'first_name': 'Owolabi'}
encoded_data = encode_multipart(BOUNDARY, data)
response = self.client.patch(
self.url, encoded_data, content_type=MULTIPART_CONTENT
)
self.assertEqual(response.status_code, 200)
user.refresh_from_db()
self.assertEqual(user.first_name, data['first_name'])
def test_update_user_success_last_name(self):
"""Test update user success with last_name."""
# First login
user = UserFactory.create(email='john@example.com')
user.set_password('12345SomeData')
user.save()
url_login = reverse('users:login')
login_data = {'email': user.email, 'password': '12345SomeData'}
response = self.client.post(
path=url_login, data=login_data, content_type='application/json'
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['email'], user.email)
# User update
data = {'last_name': 'Idogun'}
encoded_data = encode_multipart(BOUNDARY, data)
response = self.client.patch(
self.url, encoded_data, content_type=MULTIPART_CONTENT
)
self.assertEqual(response.status_code, 200)
user.refresh_from_db()
self.assertEqual(user.last_name, data['last_name'])
def test_update_user_success_thumbnail(self):
"""Test update user success with thumbnail."""
# First login
user = UserFactory.create(email='john@example.com')
user.set_password('12345SomeData')
user.save()
url_login = reverse('users:login')
login_data = {'email': user.email, 'password': '12345SomeData'}
response = self.client.post(
path=url_login, data=login_data, content_type='application/json'
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['email'], user.email)
# Update user
with override_settings(MEDIA_ROOT=self.media_folder):
with NamedTemporaryFile() as f:
f.write(b'some file data')
f.seek(0)
data = {'thumbnail': f}
encoded_data = encode_multipart(BOUNDARY, data)
response = self.client.patch(
self.url, encoded_data, content_type=MULTIPART_CONTENT
)
self.assertEqual(response.status_code, 200)
user.refresh_from_db()
self.assertIsNotNone(user.thumbnail)
def test_update_user_success_phone_number(self):
"""Test update user success with phone_number."""
# First login
user = UserFactory.create(email='john@example.com')
user.set_password('12345SomeData')
user.save()
url_login = reverse('users:login')
login_data = {'email': user.email, 'password': '12345SomeData'}
response = self.client.post(
path=url_login, data=login_data, content_type='application/json'
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['email'], user.email)
# User update
data = {'phone_number': '+2348145359073'}
encoded_data = encode_multipart(BOUNDARY, data)
response = self.client.patch(
self.url, encoded_data, content_type=MULTIPART_CONTENT
)
self.assertEqual(response.status_code, 200)
user.userprofile.refresh_from_db()
self.assertEqual(user.userprofile.phone_number, data['phone_number'])
def test_update_user_success_birth_date(self):
"""Test update user success with birth_date."""
# First login
user = UserFactory.create(email='john@example.com')
user.set_password('12345SomeData')
user.save()
url_login = reverse('users:login')
login_data = {'email': user.email, 'password': '12345SomeData'}
response = self.client.post(
path=url_login, data=login_data, content_type='application/json'
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['email'], user.email)
# User update
data = {'birth_date': timezone.localdate()}
encoded_data = encode_multipart(BOUNDARY, data)
response = self.client.patch(
self.url, encoded_data, content_type=MULTIPART_CONTENT
)
self.assertEqual(response.status_code, 200)
user.userprofile.refresh_from_db()
self.assertEqual(user.userprofile.birth_date, data['birth_date'])
def test_update_user_success_github_link(self):
"""Test update user success with github_link."""
# First login
user = UserFactory.create(email='john@example.com')
user.set_password('12345SomeData')
user.save()
url_login = reverse('users:login')
login_data = {'email': user.email, 'password': '12345SomeData'}
response = self.client.post(
path=url_login, data=login_data, content_type='application/json'
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['email'], user.email)
# User update
data = {'github_link': 'https://github.com/Sirneij'}
encoded_data = encode_multipart(BOUNDARY, data)
response = self.client.patch(
self.url, encoded_data, content_type=MULTIPART_CONTENT
)
self.assertEqual(response.status_code, 200)
user.userprofile.refresh_from_db()
self.assertEqual(user.userprofile.github_link, data['github_link'])
我们在这里使用了一些漂亮的技巧。在setUp
,我们创建了一个临时的media_folder
来保存上传的测试图像。使用tearDown
方法运行测试完成后,该文件夹立即删除。由于此端点期望来自请求的FormData
,因此我们使用Django的encode_multipart
来编码我们的数据。重要的是使用来自同一Django模型的相应的BOUNDARY
,以便正确编码FormData
。否则,我们的端点将在正确解析表单方面存在问题,并且输入数据不会是DB中存储的内容。为了上传图像,我们做到了:
...
# Update user
with override_settings(MEDIA_ROOT=self.media_folder):
with NamedTemporaryFile() as f:
f.write(b'some file data')
f.seek(0)
data = {'thumbnail': f}
encoded_data = encode_multipart(BOUNDARY, data)
response = self.client.patch(
self.url, encoded_data, content_type=MULTIPART_CONTENT
)
self.assertEqual(response.status_code, 200)
再次,NamedTemporaryFile
用于生成一个临时文件,我们将应用程序的MEDIA_ROOT
覆盖为我们在setUp
方法中创建的临时文件夹。对于每个请求,我们确保我们已登录。
此处讨论的测试概念足以查看最终代码测试套件而不会丢失。
步骤3:设置GitHub操作进行测试和静态分析
我假设您有一个GitHub帐户,并且已经将您的代码推到了平台。让我们在项目中添加一个操作,每次我们创建拉动请求或推到main
分支。您还可以设置它,以便在拉动请求通过之前,您不能将此类请求合并到main
分支。让我们创建一个流程。为此,创建一个.github/workflows/django.yml
文件:
# .github/workflows/django.yml
name: Django-auth-backend CI
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
jobs:
build:
runs-on: ubuntu-latest
strategy:
max-parallel: 4
matrix:
python-version: [3.9.13, 3.10.11, 3.11]
services:
postgres:
image: postgres:latest
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: github_actions
ports:
- 5432:5432
# needed because the postgres container does not provide a healthcheck
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
redis:
image: redis:7
ports:
- 6379:6379
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v3
with:
python-version: ${{ matrix.python-version }}
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements_dev.txt
- name: Run static analysis
run: chmod +x ./scripts/static_validation.sh && ./scripts/static_validation.sh
- name: Run tests
run: chmod +x ./scripts/test.sh && ./scripts/test.sh
我们给它一个名字,我们希望在推到main
分支或有拉请求时运行。然后,我们指定了build
作业,该作业使用了最新版本的Ubuntu来针对Python的三个主要版本[3.9.13, 3.10.11, 3.11]
构建应用程序。要运行我们的作业,我们需要一个PostgreSQL数据库和一个REDIS实例。这些是正确配置的。请注意,我们指定了我们的数据库凭据。
接下来,我们指定了使用重要actions/checkout@v3
的构建步骤。这些步骤涉及设置Python,安装我们的项目依赖关系以及运行静态分析和测试。
步骤4:在Vercel上部署
我们曾经免费使用Heroku和Hobby部署,直到他们在2022年10月将其停止。Vercel已进行营救,我们将在此处部署Django应用程序。您有两个选择:
- 安装Vercel CLI并使用它部署
- 将您的存储库连接到Vercel,并允许每次推到Repo的
main
分支或您选择的任何分支。
您可以自由选择任何方法,但要确保您为我们的应用程序结构创建一个文件,vercel.json
in,src
:
// src/vercel.json
{
"version": 2,
"builds": [
{
"src": "django_auth/wsgi.py",
"use": "@vercel/python",
"config": { "maxLambdaSize": "15mb" }
}
],
"routes": [
{
"src": "/(.*)",
"dest": "django_auth/wsgi.py"
}
]
}
我们正在使用wsgi
,但您也可以使用asgi
。确保您的文件夹中也有requirements.txt
。您可以查看存储库以获取详细信息。
由于我们的应用程序需要一个数据库,因此您可以使用Railway来免费提供PostgreSQL和REDIS实例。确保您获得他们的凭据并将其设置为申请的环境变量在Vercel上。
如果使用CLI,则可以运行迁移并使用以下步骤创建超级用户:
- ssh使用命令
vercel ssh
进入vercel实例。 - 导航到您的应用程序目录并运行命令
python manage.py migrate
以应用任何待处理的迁移。 - 通过运行命令
python manage.py createsuperuser
并遵循提示来创建超级用户。
在最后一步中,您可以设置DJANGO_SUPERUSER_PASSWORD
和DJANGO_SUPERUSER_EMAIL
环境变量,然后发布python manage.py createsuperuser --no-input
。
如果您决定使用Vercel UI,则可以创建一个带有此内容的build_files.sh
脚本:
# build_files.sh
pip install -r requirements.txt
python3.9 manage.py migrate
python3.9 manage.py createsuperuser --no-input
然后修改vercel.json
:
{
"version": 2,
"builds": [
{
"src": "django_auth/wsgi.py",
"use": "@vercel/python",
"config": { "maxLambdaSize": "15mb" }
},
{
"src": "build_files.sh",
"use": "@vercel/static-build",
"config": {
"distDir": "staticfiles_build"
}
}
],
"routes": [
{
"src": "/(.*)",
"dest": "django_auth/wsgi.py"
}
]
}
这是一些骇客,会导致部署故障,因为我们不为静态文件使用文件存储,但是这些命令将运行。然后,您可以删除该细分市场并重新部署它。
使用CLI使您可以将部署过程作为我们的github工作流程的构建过程之一。
就是这个系列。不要犹豫,放弃您的反应,评论和贡献。您可能还会喜欢我写任何东西,如果可以的话,我一定会义务。我也可以参加演出。
其他
喜欢这篇文章?考虑contacting me for a job, something worthwhile or buying a coffee ☕。您也可以在LinkedIn和Twitter上与我联系/关注我。如果您帮助分享本文以获得更广泛的报道,那还不错。我会很感激...