Repository: fidals/shopelectro

Issue:
catalog.py:45-47: Improve categories matrix arch. Now...
The puzzle `837-edb24a44` from #837 has to be resolved: 

https://github.com/fidals/shopelectro/blob/bd9b654124b09163e5f1bb3a77f467fff56cffcd/shopelectro/views/catalog.py#L45-L47

The puzzle was created by duker33 on 17-Jun-19. 

Estimate: 60 minutes,  role: DEV. 

If you have any technical questions, don't ask me, submit new tickets instead. The task will be \"done\" when the problem is fixed and the text of the puzzle is _removed_ from the source code. Here is more about [PDD](http://www.yegor256.com/2009/03/04/pdd.html) and [about me](http://www.yegor256.com/2017/04/05/pdd-in-action.html). 



Discussion:
@artemiyds2 @duker33 please pay attention to this new issue.

Resolving patch:
diff --git a/shopelectro/migrations/0036_matrixblock.py b/shopelectro/migrations/0036_matrixblock.py
new file mode 100644
--- /dev/null
+++ b/shopelectro/migrations/0036_matrixblock.py
@@ -0,0 +1,28 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.20 on 2019-07-04 08:46
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('shopelectro', '0035_product_in_pack'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='MatrixBlock',
+            fields=[
+                ('category', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='matrix_block', serialize=False, to='shopelectro.Category', verbose_name='category')),
+                ('block_size', models.PositiveSmallIntegerField(null=True, verbose_name='block size')),
+            ],
+            options={
+                'verbose_name': 'Matrix block',
+                'verbose_name_plural': 'Matrix blocks',
+                'ordering': ['category__page__position', 'category__name'],
+            },
+        ),
+    ]
diff --git a/shopelectro/models.py b/shopelectro/models.py
--- a/shopelectro/models.py
+++ b/shopelectro/models.py
@@ -56,6 +56,63 @@ def get_absolute_url(self):
         return reverse('category', args=(self.page.slug,))
 
 
+class MatrixBlockManager(models.Manager):
+
+    def blocks(self):
+        return (
+            super()
+            .get_queryset()
+            .select_related('category', 'category__page')
+            .prefetch_related('category__children')
+            .filter(category__level=0, category__page__is_active=True)
+        )
+
+
+class MatrixBlock(models.Model):
+    """It is an UI element of catalog matrix."""
+
+    # @todo #880:30m Add MatrixBlock to the admin panel.
+    #  Inline it on Category Edit page.
+
+    # @todo #880:60m Use MatrixBlock in the matrix view.
+    #  Get the block_size data from the matrix view and fill out the model.
+
+    class Meta:
+        verbose_name = _('Matrix block')
+        verbose_name_plural = _('Matrix blocks')
+        ordering = ['category__page__position', 'category__name']
+
+    objects = MatrixBlockManager()
+
+    category = models.OneToOneField(
+        Category,
+        on_delete=models.CASCADE,
+        primary_key=True,
+        verbose_name=_('category'),
+        related_name=_('matrix_block'),
+        limit_choices_to={'level': 0},
+    )
+
+    block_size = models.PositiveSmallIntegerField(
+        null=True,
+        verbose_name=_('block size'),
+    )
+
+    @property
+    def name(self):
+        self.category.name
+
+    @property
+    def url(self):
+        self.category.url
+
+    def rows(self) -> SECategoryQuerySet:
+        rows = self.category.children.active()
+        if self.block_size:
+            return rows[:self.block_size]
+        return rows
+
+
 class Product(
     catalog_models.AbstractProduct,
     catalog_models.AbstractPosition,
diff --git a/shopelectro/views/catalog.py b/shopelectro/views/catalog.py
--- a/shopelectro/views/catalog.py
+++ b/shopelectro/views/catalog.py
@@ -42,9 +42,6 @@ def category_matrix(request, page: str):
         # How the matrix looks like:
         # https://github.com/fidals/shopelectro/issues/837#issuecomment-501161967
 
-        # @todo #837:60m  Improve categories matrix arch.
-        #  Now it's untyped data structure with common comments.
-        #  Turn it to the set of object with clear names.
         matrix[(root.name, root.url)] = (
             children
             if i not in MATRIX_BLOCKS_TO_LIMIT


Test patch:
diff --git a/shopelectro/tests/tests_models.py b/shopelectro/tests/tests_models.py
--- a/shopelectro/tests/tests_models.py
+++ b/shopelectro/tests/tests_models.py
@@ -2,10 +2,11 @@
 from itertools import chain
 
 from django.conf import settings
+from django.db.utils import IntegrityError
 from django.forms.models import model_to_dict
 from django.test import TestCase, TransactionTestCase, tag
 
-from shopelectro.models import Product, Tag, TagGroup
+from shopelectro.models import Category, MatrixBlock, Product, Tag, TagGroup
 
 
 @tag('fast')
@@ -86,3 +87,75 @@ def test_get_brands_from_products(self):
                 p: p.tags.filter(group__name=settings.BRAND_TAG_GROUP_NAME).first()
                 for p in products
             }
+
+    def test_get_matrix_blocks(self):
+        roots = Category.objects.filter(level=0)
+        roots_count = roots.count()
+        for category in roots:
+            MatrixBlock.objects.create(category=category)
+
+        blocks = MatrixBlock.objects.blocks()
+
+        # 2 queries: MatrixBlock + Category joined with CategoryPage
+        # select_related doesn't deffer the queries
+        with self.assertNumQueries(2):
+            self.assertEquals(roots_count, len(blocks))
+            for block in blocks:
+                self.assertTrue(block.category)
+                self.assertTrue(block.category.page)
+
+        with self.assertNumQueries(2):
+            for block in blocks:
+                self.assertTrue(block.rows())
+
+
+@tag('fast')
+class MatrixBlockModel(TestCase):
+
+    fixtures = ['dump.json']
+
+    def test_block_category_relation_uniqueness(self):
+        category = Category.objects.first()
+
+        with self.assertRaises(IntegrityError):
+            MatrixBlock.objects.create(category=category)
+            MatrixBlock.objects.create(category=category)
+
+    def test_unsized_rows_count(self):
+        block = MatrixBlock.objects.create(category=Category.objects.first())
+
+        self.assertEquals(
+            block.category.children.active().count(),
+            block.rows().count(),
+        )
+
+    def test_sized_rows_count(self):
+        sized_category, oversized_category = Category.objects.all()[:2]
+
+        # block_size < category's children quantity
+        sized_block = MatrixBlock.objects.create(
+            category=sized_category,
+            block_size=sized_category.children.count() - 1,
+        )
+        self.assertNotEquals(
+            sized_category.children.active().count(),
+            sized_block.rows().count(),
+        )
+        self.assertEquals(
+            sized_block.block_size,
+            sized_block.rows().count(),
+        )
+
+        # block_size > category's children quantity
+        oversized_block = MatrixBlock.objects.create(
+            category=oversized_category,
+            block_size=oversized_category.children.count() + 1,
+        )
+        self.assertEquals(
+            oversized_category.children.active().count(),
+            oversized_block.rows().count(),
+        )
+        self.assertNotEquals(
+            oversized_block.block_size,
+            oversized_block.rows().count(),
+        )