29.11.2019       Выпуск 310 (25.11.2019 - 01.12.2019)       Статьи

Пуленепробиваемые модели Django

Перевод статьи: Haki Benita – Bullet Proofing Django Models

Недавно мы добавили банковские реквизиты в один из наших продуктов. Во время разработки мы столкнулись с некоторыми проблемами, и я подумал, что это может быть хорошей возможностью для изучения некоторых шаблонов, которые мы используем в наших моделях Django.

Читать>>




Экспериментальная функция:

Ниже вы видите текст статьи по ссылке. По нему можно быстро понять ссылка достойна прочтения или нет

Просим обратить внимание, что текст по ссылке и здесь может не совпадать.

Перевод статьи: Haki Benita – Bullet Proofing Django Models

Недавно мы добавили банковские реквизиты в один из наших продуктов. Во время разработки мы столкнулись с некоторыми проблемами, и я подумал, что это может быть хорошей возможностью для изучения некоторых шаблонов, которые мы используем в наших моделях Django.

Банковские реквизиты

Эта статья была написана в том порядке, в котором мы обычно решаем новые проблемы:

  1. Определение бизнес-требований.
  2. Реализация базового подхода и определение модели.
  3. Испытание решения.
  4. Уточнение, рефакторинг и повторения испытания.

Бизнес-требования

  • У каждого пользователя может быть только одна учетная запись, но не у каждого пользователя она должна быть.
  • Пользователь может пополнять и снимать со счета до определенной суммы.
  • Баланс счета не может быть отрицательным.
  • Существует максимальный лимит баланса аккаунта пользователя.
  • Общая сумма всех остатков в приложении не может превышать определенную сумму.
  • Для каждого действия в учетной записи должна быть запись логов.
  • Действия над учетной записью могут выполняться пользователем из мобильного приложения или через веб-интерфейс, а также специалистами службы поддержки из интерфейса администратора.

Теперь, когда у нас есть бизнес-требования, мы можем начать с определения модели.

Account Model

# models.py

import uuid

from django.conf import settings
from django.db import models

class Account(models.Model):
    class Meta:
        verbose_name = 'Account'
        verbose_name_plural = 'Accounts'

    MAX_TOTAL_BALANCES = 10000000

    MAX_BALANCE = 10000
    MIN_BALANCE = 0

    MAX_DEPOSIT = 1000
    MIN_DEPOSIT = 1

    MAX_WITHDRAW = 1000
    MIN_WITHDRAW = 1

    id = models.AutoField(
        primary_key=True,
    )
    uid = models.UUIDField(
        unique=True,
        editable=False,
        default=uuid.uuid4,
        verbose_name='Public identifier',
    )
    user = models.OneToOneField(
        settings.AUTH_USER_MODEL,
        on_delete=models.PROTECT,
    )
    created = models.DateTimeField(
        blank=True,
    )
    modified = models.DateTimeField(
        blank=True,
    )
    balance = models.PositiveIntegerField(
        verbose_name='Current balance',
    )


Давайте разберемся с этим:

  • Мы используем два уникальных идентификатора – закрытый идентификатор, который представляет собой автоматически генерируемый номер (id), и открытый идентификатор, который представляет собой uuid (uid).Рекомендуется сохранять конфиденциальность идентификаторов – они предоставляют важную информацию о наших данных, например, сколько у нас учетных записей.
  • Мы используем OneToOneField для пользователя – это похоже на ForeignKey, но с уникальным ограничением. Это гарантирует, что пользователь не может иметь более одной учетной записи.
  • Мы устанавливаем on_delete = models.PROTECT – Начиная с Django 2.0 этот аргумент стал обязательным. По умолчанию используется CASCADE – при удалении пользователя соответствующая учетная запись также удаляется. В нашем случае это не имеет смысла – представьте, что банк «удаляет ваш аккаунт», когда вы закрываете счет. Установка on_delete = models.PROTECT вызовет IntegrityError при попытке удалить пользователя с учетной записью.
  • Вы, наверное, заметили, что код очень … “вертикальный”. Мы пишем так, потому что это делает команду git diffs более понятной.

Модель Account Action

Теперь, когда у нас есть модель учетной записи, мы можем создать модель для регистрации действий, выполненных с учетной записью:

# models.py

class Action(models.Model):
    class Meta:
        verbose_name = 'Account Action'
        verbose_name_plural = 'Account Actions'

    ACTION_TYPE_CREATED = 'CREATED'
    ACTION_TYPE_DEPOSITED = 'DEPOSITED'
    ACTION_TYPE_WITHDRAWN = 'WITHDRAWN'
    ACTION_TYPE_CHOICES = (
        (ACTION_TYPE_CREATED, 'Created'),
        (ACTION_TYPE_DEPOSITED, 'Deposited'),
        (ACTION_TYPE_WITHDRAWN, 'Withdrawn'),
    )

    REFERENCE_TYPE_BANK_TRANSFER = 'BANK_TRANSFER'
    REFERENCE_TYPE_CHECK = 'CHECK'
    REFERENCE_TYPE_CASH = 'CASH'
    REFERENCE_TYPE_NONE = 'NONE'
    REFERENCE_TYPE_CHOICES = (
        (REFERENCE_TYPE_BANK_TRANSFER, 'Bank Transfer'),
        (REFERENCE_TYPE_CHECK, 'Check'),
        (REFERENCE_TYPE_CASH, 'Cash'),
        (REFERENCE_TYPE_NONE, 'None'),
    )

    id = models.AutoField(
        primary_key=True,
    )
    user_friendly_id = models.CharField(
        unique=True,
        editable=False,
        max_length=30,
    )
    user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.PROTECT,
        help_text='User who performed the action.',
    )
    created = models.DateTimeField(
        blank=True,
    )
    account = models.ForeignKey(
        Account,
    )
    type = models.CharField(
        max_length=30,
        choices=ACTION_TYPE_CHOICES,
    )
    delta = models.IntegerField(
        help_text='Balance delta.',
    )
    reference = models.TextField(
        blank=True,
    )
    reference_type = models.CharField(
        max_length=30,
        choices=REFERENCE_TYPE_CHOICES,
        default=REFERENCE_TYPE_NONE,
    )
    comment = models.TextField(
        blank=True,
    )

    # Fields used solely for debugging purposes.

    debug_balance = models.IntegerField(
        help_text='Balance after the action.',
    )


Что же мы имеем здесь?

  • Каждая запись будет содержать ссылку на соответствующий баланс и сумму дельты. Депозит 100 $ будет иметь дельту 100 $, а вывод 50 $ будет иметь дельту -50 $. Таким образом, мы можем суммировать дельты всех действий, совершенных с учетной записью, и получать текущий баланс. Это важно для проверки нашего расчетного баланса.
  • Мы следуем той же схеме добавления двух идентификаторов – приватного и открытого. Разница здесь в том, что ссылочные номера для действий часто используются пользователями и персоналом службы поддержки для определения конкретного действия по телефону или по электронной почте. Uuid не дружественен пользователю – он очень длинный, и это не то, что пользователи привыкли видеть. Я нашел хорошую реализацию удобных идентификаторов в django-invoice.
  • Два поля относятся только к одному типу действия, депозит – ссылка и тип ссылки. Существует много способов решения этой проблемы – наследование таблиц и преобразование в нижнюю часть, поля JSON, полиморфизм таблиц и список чрезмерно сложных решений можно продолжить. В нашем случае мы будем использовать разреженную таблицу (sparse table).

Примечание о дизайне: сохранение рассчитанных полей в модели обычно является плохим дизайном. По возможности следует избегать хранения вычисляемых полей, таких как баланс счета.

Однако в нашей «реальной» реализации есть дополнительные типы действий и тысячи действий для каждой учетной записи – мы рассматриваем хранение рассчитанного атрибута как оптимизацию.

Проблемы

Несколько платформ

У нас есть три клиентских приложения, которые мы должны поддерживать:

  • Мобильное приложение – использует интерфейс API для управления учетной записью.
  • Веб-клиент – использует либо интерфейс API (если у нас есть какой-то SPA), либо старый добрый рендеринг на стороне сервера с формами Django.
  • Интерфейс администратора – использует модуль администратора Django с формами Django.

Наша задача так же состоит в том, чтобы следовать принципу DRY, насколько это возможно.

Валидация

У нас есть два типа проверок, скрывающихся в бизнес-требованиях:

Проверка ввода, такая как «сумма должна быть между X и Y», «баланс не может превышать Z» и т. д. – эти типы проверки хорошо поддерживаются Django и обычно могут быть выражены как ограничения базы данных (database constraints) или проверки django (django validations).

Вторая проверка немного сложнее. Мы должны обеспечить, чтобы общая сумма всех остатков во всей системе не превышала определенной суммы. Это заставляет нас проверять экземпляр на соответствие всем остальным экземплярам модели.

Атомарность (Atomicity)

Условия гонки – это очень распространенная проблема в распределенных системах, и особенно в моделях, которые поддерживают состояние, таких как банковский счет (вы можете прочитать больше о условиях гонки в Википедии (race conditions in Wikipedia)).

Для иллюстрации проблемы рассмотрим пример со счетом с балансом 100 $. Пользователь подключается с двух разных устройств одновременно и запускает снятие 100$. Поскольку оба действия были выполнены в одно и то же время, возможно, что оба они получили текущий баланс в 100$. Учитывая, что оба сеанса видят достаточный баланс, они оба будут одобрены и обновят новый баланс до 0$. Пользователь снял в общей сложности 200$, и текущий баланс теперь равен 0$ – у нас есть условие гонки, и мы потеряли 100$.

Логирование/История

Журнал логирования служит двум целям:

  • Журнал и аудит – информация об исторических действиях – даты, суммы, пользователи и т. д.
  • Проверка согласованности – мы поддерживаем состояние в модели, поэтому мы хотим иметь возможность проверить рассчитанный баланс путем объединения дельт действий.

Записи истории должны быть на 100% неизменными.


Наивная (базовая) реализация

Давайте начнем с наивного внедрения депозита (сразу предупредим это не очень хорошая реализация):

class Account(models.Model):

    # ...

    def deposit(self, amount, deposited_by, asof):
        assert amount > 0

        if not self.MIN_DEPOSIT <= amount <= self.MAX_DEPOSIT:
            raise InvalidAmount(amount)

        if self.balance + amount > self.MAX_BALANCE:
            raise ExceedsLimit()

        total = Account.objects.aggregate(
            total=Sum('balance')
        )['total']
        if total + amount > self.MAX_TOTAL_BALANCES:
            raise ExceedsLimit()

        action = self.actions.create(
            user=deposited_by,
            type=Action.ACTION_TYPE_DEPOSITED,
            delta=amount,
            asof=asof,
        )

    self.balance += amount
    self.modified = asof

    self.save()


И давайте добавим для этого простую конечную точку (URL), используя DRF @api_view:

# api.py

# ...
from django.db.models import transaction
# ...

@api_view('POST')
def deposit(request):
    try:
        amount = int(request.data\['amount'\])
    except (KeyError, ValueError):
        return Response(status=status.HTTP_400_BAD_REQUEST)

    with transaction.atomic():
        try:
            account = (
                Account.objects
                .select_for_update()
                .get(user=request.user)
            )
        except Account.DoesNotExist:
            return Response(status=status.HTTP_404_NOT_FOUND)

        try:
            account.deposit(
                amount=amount,
                deposited_by=request.user,
                asof=timezone.now(),
            )
        except (ExceedsLimit, InvalidAmount):
            return Response(status=status.HTTP_400_BAD_REQUEST)

        return Response(status=status.HTTP_200_OK)


Так в чем проблема?

Блокировка учетной записи – экземпляр не может заблокировать себя, поскольку он уже был получен. Мы отказались от контроля над блокировкой и извлечением, поэтому мы должны доверять вызывающей стороне для правильного получения блокировки – это очень плохой дизайн. Не верьте мне на слово, посмотрите на философию дизайна Django (Django’s design philosophy):

Слабая связь Основная цель стека Джанго – слабая связь и тесная сплоченность. Различные слои фреймворка не должны «знать» друг о друге без крайней необходимости.

Так действительно ли дело в нашем API, формах и администраторе django, чтобы получить учетную запись для нас и получить надлежащую блокировку? Думаю, нет.

Валидация – учетная запись должна проверять себя в отношении всех других учетных записей – что просто неудобно.

Лучший подход

Нам нужно подключиться к процессу до получения учетной записи (чтобы создать блокировку) и в месте, где имеет смысл проверять и обрабатывать более одной учетной записи.

Давайте начнем с функции, чтобы создать экземпляр Action и записать его как classmethod:

# models.py

from django.core.exceptions import ValidationError


class Action(models.Model):

    # ...

    @classmethod
    def create(
        cls,
        user,
        account,
        type,
        delta,
        asof,
        reference=None,
        reference_type=None,
        comment=None,
    ):
        """Create Action.

        user (User):
            User who executed the action.
        account (Account):
            Account the action executed on.
        type (str, one of Action.ACTION_TYPE_\*):
            Type of action.
        delta (int):
            Change in balance.
        asof (datetime.datetime):
            When was the action executed.
        reference (str or None):
            Reference number when appropriate.
        reference_type(str or None):
            Type of reference.
            Defaults to "NONE".
        comment (str or None):
            Optional comment on the action.

        Raises:
            ValidationError

        Returns (Action)
        """
        assert asof is not None

        if (type == cls.ACTION_TYPE_DEPOSITED and
            reference_type is None):
            raise errors.ValidationError({
                'reference_type': 'required for deposit.',
            })

        if reference_type is None:
            reference_type = cls.REFERENCE_TYPE_NONE

        # Don't store null in text field.

        if reference is None:
            reference = ''

        if comment is None:
            comment = ''

        user_friendly_id = generate_user_friendly_id()

        return cls.objects.create(
            user_friendly_id=user_friendly_id,
            created=asof,
            user=user,
            account=account,
            type=type,
            delta=delta,
            reference=reference,
            reference_type=reference_type,
            comment=comment,
            debug_balance=account.balance,
        )


Что же мы имеем здесь:

  • Мы использовали classmethod, который принимает все необходимые данные для проверки и создания нового экземпляра. * Не * используя функцию создания менеджера по умолчанию (Action.objects.create), мы инкапсулируем всю бизнес-логику в процессе создания.
  • Мы легко представили пользовательскую проверку и подняли ValidationError.
  • Мы принимаем время создания в качестве аргумента. На первый взгляд это может показаться немного странным – почему бы не использовать встроенный auto_time_add? Для начинающих гораздо проще тестировать с предсказуемыми значениями. Во-вторых, как мы увидим чуть позже, мы можем убедиться, что время изменения учетной записи точно совпадает со временем создания действия.

Прежде чем перейти к реализации методов учетной записи, давайте определим пользовательские исключения для нашего модуля учетной записи:

# errors.py

class Error(Exception):
    pass

class ExceedsLimit(Error):
    pass

class InvalidAmount(Error):
    def __init__(self, amount):
        self.amount = amount

    def __str__(str):
        return 'Invalid Amount: {}'.format(amount)

class InsufficientFunds(Error):
    def __init__(self, balance, amount):
        self.balance = balance
        self.amount = amount

    def __str__(self):
        return 'amount: {}, current balance: {}'.format(
                self.amount, self.balance)


Мы определяем базовый класс Error, который наследуется от Exception. Это то, что мы сочли очень полезным и используем его очень часто. Базовый класс ошибок позволяет нам отлавливать все ошибки, поступающие от определенного модуля:

from account.errors import Error as AccountError

try:
   # action on account
except AccountError:
   # Handle all errors from account


Аналогичный шаблон можно найти в популярном пакете requests.

Давайте реализуем метод для создания новой учетной записи Account:

class Account(models.Model):

    # ...

    @classmethod
    def create(cls, user, created_by, asof):
        """Create account.

        user (User):
            Owner of the account.
        created_by (User):
            User that created the account.
        asof (datetime.datetime):
            Time of creation.

        Returns (tuple):
            [0] Account
            [1] Action
        """
        with transaction.atomic():
            account = cls.objects.create(
                user=user,
                created=asof,
                modified=asof,
                balance=0,
            )

            action = Action.create(
                user=created_by,
                account=account,
                type=Action.ACTION_TYPE_CREATED,
                delta=0,
                asof=asof,
            )

        return account, action


Довольно просто – создайте экземпляр, создайте действие и верните их обоих.

Обратите внимание, что и здесь мы принимаем asof – изменено, создано и время создания действия одинаково – вы не можете сделать это с помощью auto_add и auto_add_now.

Теперь к бизнес-логике:

# models.py

@classmethod
def deposit(
    cls,
    uid,
    deposited_by,
    amount,
    asof,
    comment=None,
):
    """Deposit to account.

    uid (uuid.UUID):
        Account public identifier.
    deposited_by (User):
        Deposited by.
    amount (positive int):
        Amount to deposit.
    asof (datetime.datetime):
        Time of deposit.
    comment(str or None):
       Optional comment.

    Raises
        Account.DoesNotExist
        InvalidAmount
        ExceedsLimit

    Returns (tuple):
        [0] (Account) Updated account instance.
        [1] (Action) Deposit action.
    """
    assert amount > 0

    with transaction.atomic():
        account = cls.objects.select_for_update().get(uid=uid)

        if not (cls.MIN_DEPOSIT <= amount <= cls.MAX_DEPOSIT):
            raise errors.InvalidAmount(amount)

        if account.balance + amount > cls.MAX_BALANCE:
            raise errors.ExceedsLimit()

        total = cls.objects.aggregate(total=Sum('balance'))\['total'\]
        if total + amount > cls.MAX_TOTAL_BALANCES:
            raise errors.ExceedsLimit()

        account.balance += amount
        account.modified = asof

        account.save(update_fields=[
            'balance',
            'modified',
        ])

        action = Action.create(
            user=deposited_by,
            account=account,
            type=Action.ACTION_TYPE_DEPOSITED,
            delta=amount,
            asof=asof,
        )

    return account, action

@classmethod
def withdraw(
    cls,
    uid,
    withdrawn_by,
    amount,
    asof
    comment=None,
):
    """Withdraw from account.

    uid (uuid.UUID):
        Account public identifier.
    withdrawn_by (User):
        The withdrawing user.
    amount (positive int):
        Amount to withdraw.
    asof (datetime.datetime):
        Time of withdraw.
    comment (str or None):
       Optional comment.

    Raises:
        Account.DoesNotExist
        InvalidAmount
        InsufficientFunds

    Returns (tuple):
        [0] (Account) Updated account instance.
        [1] (Action) Withdraw action.
    """
    assert amount > 0

    with transaction.atomic():
        account = cls.objects.select_for_update().get(uid=uid)

        if not (cls.MIN_WITHDRAW <= amount <= cls.MAX_WITHDRAW):
            raise InvalidAmount(amount)

        if account.balance - amount < cls.MIN_BALANCE:
            raise InsufficientFunds(amount, account.balance)

        account.balance -= amount
        account.modified = asof

        account.save(update_fields=[
            'balance',
            'modified',
        ])

        action = Action.create(
            user=withdrawn_by,
            account=account,
            type=Action.ACTION_TYPE_WITHDRAWN,
            delta=-amount,
            asof=asof,
        )

    return account, action


Что мы здесь сделали:

  1. Получили блокировку учетной записи, используя select_for_update. Это заблокирует строку учетной записи в базе данных и убедимся, что никто не сможет обновить экземпляр учетной записи, пока транзакция не будет завершена (подтверждена или откатана).
  2. Выполнили проверки правильности и создали правильные исключения – вызов исключения вызовет отката транзакции.
  3. Если все пройденные проверки обновляют состояние (текущий баланс), установили время изменения, сохранили экземпляр и создали журнал (Action).

Итак, как модель справляется с нашими проблемами?

  • Несколько платформ и валидация. Мы инкапсулировали всю нашу бизнес-логику, включая входную и общесистемную валидацию, внутри метода модели, поэтому потребителям, таким как API, действия администратора или формы, нужно обрабатывать только исключения и сериализацию / пользовательский интерфейс.
  • Атомарность – каждый метод получает свою собственную блокировку, поэтому нет риска возникновения гонки.
  • Журнал / История – Мы создали модель действия и убедились, что каждая функция регистрирует правильное действие.

Тестирование

Наше приложение будет неполным без надлежащих тестов. Ранее я писал о тестировании на основе классов – здесь мы собираемся использовать немного другой подход, но у нас все еще будет базовый класс с функциями утилит:

# tests/common.py

class TestAccountBase:
    DEFAULT = object()

    @classmethod
    def default(value, default_value):
        return default_value if value is cls.DEFAULT else value

    @classmethod
    def setUpTestData(cls):
        super().setUpTestData()

        # Set up some default values
        cls.admin = User.objects.create_superuser(
            'Admin',
            'admin',
            'admin@testing.test',
        )

        cls.user_A = User.objects.create_user(
            'user_A',
            'user_A',
            'A@testing.test',
        )

    @classmethod
    def create(
        cls,
        user=DEFAULT,
        created_by=DEFAULT,
        asof=DEFAULT
    ):
        user = cls.default(user, cls.user_A)
        created_by = cls.default(created_by, cls.admin)
        asof = cls.default(asof, timezone.now())

        account, action = Account.create(user, created_by, asof)
        return cls.account, action

    def deposit(
        self,
        amount,
        account=DEFAULT,
        deposited_by=DEFAULT,
        asof=DEFAULT,
        comment=DEFAULT,
    ):
        account = self.default(account, self.account)
        deposited_by = self.default(deposited_by, self.admin)
        asof = self.default(asof, timezone.now())
        comment = self.default(comment, 'deposit comment')

        self.account, action = Account.deposit(
            uid=account.uid,
            deposited_by=deposited_by,
            amount=amount,
            asof=asof,
        )

        self.assertEqual(action.type, Action.ACTION_TYPE_DEPOSITED)
        self.assertIsNotNone(action.user_friendly_id)
        self.assertEqual(action.created, asof)
        self.assertEqual(action.delta, amount)
        self.assertEqual(action.user, deposited_by)

        return action

    def withdraw(
        self,
        amount,
        account=DEFAULT,
        withdrawn_by=DEFAULT,
        asof=DEFAULT,
        comment=DEFAULT,
    ):
        account = self.default(account, self.account)
        withdrawn_by = self.default(withdrawn_by, self.admin)
        asof = self.default(asof, timezone.now())
        comment = self.default(comment, 'withdraw comment')

        self.account, action = Account.withdraw(
            uid=account.uid,
            withdrawn_by=withdrawn_by,
            amount=amount,
            asof=asof,
        )

        self.assertEqual(action.type, Action.ACTION_TYPE_WITHDRAWN)
        self.assertIsNotNone(action.user_friendly_id)
        self.assertEqual(action.created, asof)
        self.assertEqual(action.delta, amount)
        self.assertEqual(action.user, withdrawn_by)

        return action


Чтобы упростить написание теста, мы используем служебные функции, чтобы каждый раз указывать пользователя, учетную запись и т. д. путем предоставления значений по умолчанию и работы с self.account.

Давайте использовать наш базовый класс, чтобы написать несколько тестов:

# tests/test_account.py

from unittest import mock
from django.test import TestCase

from .common import TestAccoutBase
from ..models import Account, Action
from ..errors import (
    InvalidAmount,
    ExceedsLimit,
    InsuficientFunds,
)

class TestAccount(TestAccountBase, TestCase):

    def setUp(self):
 **self.account, _ = cls.create()**

    def test_should_start_with_zero_balance(self):
        self.assertEqual(self.account.balance, 0)

    def test_should_deposit(self):
        self.deposit(100)
        self.assertEqual(self.account.balance, 100)
        self.deposit(150)
        self.assertEqual(self.account.balance, 250)

    def test_should_fail_to_deposit_less_than_minimum(self):
        with self.assertRaises(InvalidAmount):
            self.deposit(Account.MIN_DEPOSIT - 1)
        self.assertEqual(self.account.balance, 0)

    def test_should_fail_to_deposit_more_than_maximum(self):
        with self.assertRaises(InvalidAmount):
            self.deposit(Account.MAX_DEPOSIT + 1)
        self.assertEqual(self.account.balance, 0)

    @mock.patch('account.models.Account.MAX_BALANCE', 500)
    @mock.patch('account.models.Account.MAX_DEPOSIT', 502)
    def test_should_fail_to_deposit_more_than_max_balance(self):
        with self.assertRaises(ExceedsLimit):
            self.deposit(501)
        self.assertEqual(self.account.balance, 0)

    @mock.patch('account.models.Account.MAX_BALANCE', 500)
    @mock.patch('account.models.Account.MAX_DEPOSIT', 500)
    @mock.patch('account.models.Account.MAX_TOTAL_BALANCES', 600)
    def test_should_fail_when_exceed_max_total_balances(self):

        # Exceed max total balances for the same account

        self.deposit(500)
        with self.assertRaises(ExceedsLimit):
            self.deposit(500)
        self.assertEqual(self.account.balance, 500)

        # Exceed max total balances in other account

        other_user = User.objects.create_user('foo', 'bar', 'baz')
        other_account = self.create(user=other_user)

        with self.assertRaises(ExceedsLimit):
            self.deposit(200, account=other_account)
        self.assertEqual(other_account.balance, 0)

    def test_should_withdraw(self):
        self.deposit(100)
        self.withdraw(50)
        self.assertEqual(self.account.balance, 50)
        self.withdraw(30)
        self.assertEqual(self.account.balance, 20)

    def test_should_fail_when_insufficient_funds(self):
        self.deposit(100)
        with self.assertRaises(InsufficientFunds):
            self.withdraw(101)
        self.assertEqual(self.account.balance, 100)


Заключение

Классовый подход зарекомендовал себя в нашей разработке уже довольно давно. Мы обнаружили, что он обеспечивает необходимую гибкость, удобочитаемость и тестируемость при минимальных затратах.

В этой статье мы представили две общие проблемы, с которыми мы часто сталкиваемся – проверка и параллелизм. Этот метод может быть расширен для обработки контроля доступа (разрешений) и кэширования (у нас есть полный контроль над выборкой, помните?), Оптимизации производительности (используйте select_related и update_fields …), аудита и мониторинга и дополнительной бизнес-логики.

Мы обычно поддерживаем несколько интерфейсов для каждой модели – интерфейс администратора для поддержки, API для мобильных клиентов и клиентов SPA и панель инструментов. Инкапсуляция бизнес-логики внутри модели сократила количество дублирования кода и требует проведения тестов, что приводит к общему качеству кода, который легко поддерживать.

В последующем посте я (возможно) представлю интерфейс администратора для этой модели с некоторыми полезными трюками (такими как настраиваемые действия, промежуточные страницы и т. д.) и, возможно, реализацией RPC, использующей DRF для взаимодействия с учетной записью в качестве API.






Разместим вашу рекламу

Пиши: mail@pythondigest.ru

Нашли опечатку?

Выделите фрагмент и отправьте нажатием Ctrl+Enter.

Система Orphus