Django身份验证系统 - GitHub动作,自动测试,静态分析和Vercel上的部署
#网络开发人员 #python #githubactions #githubhack23

介绍

到目前为止,我们已经构建了一些很棒的API端点,用于以性能和表现方式对用户进行认证和授权。但是,除了与Postman(或在VS代码上使用Thunder Client)进行测试,并且对于那些通过Frontend应用程序构建previous series的人,我们还没有使我们的应用程序变得足够简单发出一个简单的命令,该命令通过应用程序运行并报告该应用程序是否按预期工作。另外,在这方面,Python比JavaScript更好。我们一定会以与社区所采用的公认样式的样式编写我们的代码。由于Python是一种动态键入的语言,因此我们需要一种在所使用的所有变量上执行类型的方法,以使我们不会将字符串分配给整数变量。我们还需要部署我们的应用程序,以便其他所有人都可以访问我们建立的美丽。所有这些都是我们将在本文中解决的。

假设和建议

假定您熟悉Django。我还建议您在本系列中浏览以前的文章,以便您可以跟上这篇文章。

源代码

该系列的源代码通过:

在GitHub上托管

GitHub logo Sirneij / django-auth-backend

带有Sveltekit Frontend的基于Django会话的身份验证系统

django-auth-backend

CI Test coverage

基于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-covpytest-djangopytest-bddpyflakespylintpylint-celerypylint-celerypytest-xdistdjango-stubs和其他软件包。为了减轻您的负担,我已经安装了这些负担,并将它们提供在项目的requirements_dev.txt中。

首先,让我们创建一些bash脚本以自动运行我们的测试和静态分析。一个将运行测试,另一个将执行静态分析,最后一个将有助于删除所有测试数据库,以使它们不会集中我们的计算机:

在我们项目的根源上,创建一个scripts文件夹,在其中创建test.shdrop_test_dbs.shstatic_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>)。我添加了其他一些型号,例如SeriesArticles,以帮助保存该项目的文章。为了提供图像字段的默认值,我使用了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_PASSWORDDJANGO_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 ☕。您也可以在LinkedInTwitter上与我联系/关注我。如果您帮助分享本文以获得更广泛的报道,那还不错。我会很感激...