Repository: tough-dev-school/education-backend

Issue:
Добавить constraint, при котором у Order может быть только один Product
Заказ может иметь несколько разныз [ForeignKey](https://github.com/tough-dev-school/education-backend/blob/master/src/orders/models/order.py#L47) до разных айтемов — курсов, бандлов, записей. При этом по бизнес-логике такой айтем может быть только один. Получается, что у нас есть возможность записать в базу данные, которые нарушат бизнес-логику.

Чтобы этого не было, надо добавить констрейнт, при котором у заказа может быть либо course, либо record, либо bundle, либо course.


Discussion:

Resolving patch:
diff --git a/src/app/models.py b/src/app/models.py
--- a/src/app/models.py
+++ b/src/app/models.py
@@ -1,11 +1,13 @@
 from typing import Optional
 
 import contextlib
+import operator
 from behaviors.behaviors import Timestamped  # type: ignore
 from copy import copy
 from django.contrib.contenttypes.models import ContentType
 from django.db import models
 from django.utils.functional import cached_property
+from functools import reduce
 
 __all__ = [
     'models',
@@ -109,3 +111,24 @@ class EmailLogEntry(TimestampedModel):
     class Meta:
         index_together = ['email', 'template_id']
         unique_together = ['email', 'template_id']
+
+
+def only_one_or_zero_is_set(*fields: str) -> models.Q:
+    """Generate a query for CheckConstraint that allows to set only one (or none of) given fields"""
+    constraints = []
+    for field in fields:
+        constraint = models.Q(
+            **{
+                f'{field}__isnull': False,
+                **{f'{empty_field}__isnull': True for empty_field in fields if empty_field != field},
+            },
+        )
+        constraints.append(constraint)
+
+    all_fields_can_empty_constraint = models.Q(
+        **{f'{field}__isnull': True for field in fields},
+    )
+
+    constraints.append(all_fields_can_empty_constraint)
+
+    return models.Q(reduce(operator.or_, constraints))
diff --git a/src/orders/migrations/0021_OrderConstraintForProductItemsCountCheck.py b/src/orders/migrations/0021_OrderConstraintForProductItemsCountCheck.py
new file mode 100644
--- /dev/null
+++ b/src/orders/migrations/0021_OrderConstraintForProductItemsCountCheck.py
@@ -0,0 +1,17 @@
+# Generated by Django 3.2.10 on 2022-01-01 19:21
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('orders', '0020_OrderAuthorMigrationFix'),
+    ]
+
+    operations = [
+        migrations.AddConstraint(
+            model_name='order',
+            constraint=models.CheckConstraint(check=models.Q(models.Q(models.Q(('bundle__isnull', True), ('course__isnull', False), ('record__isnull', True)), models.Q(('bundle__isnull', True), ('course__isnull', True), ('record__isnull', False)), models.Q(('bundle__isnull', False), ('course__isnull', True), ('record__isnull', True)), models.Q(('bundle__isnull', True), ('course__isnull', True), ('record__isnull', True)), _connector='OR')), name='only_one_or_zero_item_type_is_allowed'),
+        ),
+    ]
diff --git a/src/orders/models/order.py b/src/orders/models/order.py
--- a/src/orders/models/order.py
+++ b/src/orders/models/order.py
@@ -1,11 +1,11 @@
 from typing import Iterable, Optional
 
-from django.db.models import QuerySet
+from django.db.models import CheckConstraint, QuerySet
 from django.utils import timezone
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import pgettext_lazy
 
-from app.models import TimestampedModel, models
+from app.models import TimestampedModel, models, only_one_or_zero_is_set
 from orders.fields import ItemField
 from products.models import Product
 
@@ -63,6 +63,13 @@ class Meta:
             ('unpay_order', _('May mark orders as unpaid')),
         ]
 
+        constraints = [
+            CheckConstraint(
+                check=only_one_or_zero_is_set('course', 'record', 'bundle'),
+                name='only_one_or_zero_item_type_is_allowed',
+            ),
+        ]
+
     def __str__(self) -> str:
         return f'Order #{self.pk}'
 


Test patch:
diff --git a/src/orders/tests/test_order_item_constraints.py b/src/orders/tests/test_order_item_constraints.py
new file mode 100644
--- /dev/null
+++ b/src/orders/tests/test_order_item_constraints.py
@@ -0,0 +1,49 @@
+import pytest
+from django.db.utils import IntegrityError
+
+from orders.models.order import Order
+
+pytestmark = [pytest.mark.django_db]
+
+
+@pytest.fixture
+def record(mixer):
+    return mixer.blend('products.Record')
+
+
+@pytest.fixture
+def course(mixer):
+    return mixer.blend('products.Course')
+
+
+@pytest.fixture
+def bundle(mixer):
+    return mixer.blend('products.Bundle')
+
+
+@pytest.fixture
+def student(mixer):
+    return mixer.blend('users.User', first_name='Омон', last_name='Кривомазов', gender='male')
+
+
+def test_order_constraints_check_product_with_two_items(student, record, course):
+    with pytest.raises(IntegrityError):
+        Order.objects.create(
+            price=100,
+            record=record,
+            course=course,
+            author=student,
+            user=student,
+        )
+
+
+def test_order_constraints_check_product_with_three_items(student, course, record, bundle):
+    with pytest.raises(IntegrityError):
+        Order.objects.create(
+            price=100,
+            record=record,
+            course=course,
+            bundle=bundle,
+            author=student,
+            user=student,
+        )