Repository: fidals/shopelectro

Issue:
price.py:93-99: Split price.Context.context to smaller...
The puzzle `666-23eb8556` from #666 has to be resolved: 

https://github.com/fidals/shopelectro/blob/234023fb8d125375f1cd3a94382d24c1385e634f/shopelectro/management/commands/price.py#L93-L99

The puzzle was created by duker33 on 29-Jan-19. 

  

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). 

Hierarchical settings structure
Make settings structure hierarchical. There should be at least two types of settings: local_settings and settings. 



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


Resolving patch:
diff --git a/shopelectro/management/commands/price.py b/shopelectro/management/commands/price.py
--- a/shopelectro/management/commands/price.py
+++ b/shopelectro/management/commands/price.py
@@ -1,16 +1,25 @@
+"""
+Django command to generate yml price files for market-places.
+
+`utm` or `target` defines particular market-place.
+See `settings.UTM_PRICE_MAP` to explore current list of supported market-places.
+"""
+
+import logging
 import os
 import typing
 from collections import defaultdict
 
 from django.conf import settings
 from django.core.management.base import BaseCommand
-from django.db.models import Q, QuerySet
+from django.db.models import QuerySet
 from django.template.loader import render_to_string
-from django.urls import reverse
 
 from catalog import newcontext
 from shopelectro import models
 
+logger = logging.getLogger(__name__)
+
 
 # --- files processing ---
 class File:
@@ -21,7 +30,7 @@ def __init__(self, path: str, context: dict):
     def create(self):
         with open(self.path, 'w', encoding='utf-8') as file:
             file.write(render_to_string('prices/price.yml', self.context).strip())
-        return f'{self.path} generated...'
+        logger.info(f'{self.path} generated.')
 
 
 class Files:
@@ -33,16 +42,82 @@ def create(self):
             file.create()
 
 
-class Price:
-    pass
+class Context(newcontext.Context):
+    """DB data, extracted for price file."""
 
+    def __init__(self, target: str):
+        self.target = target
+
+    def context(self) -> dict:
+        categories = CategoriesFilter(self.target).qs()
+        products = ProductsPatch(
+            self.target,
+            products=ProductsFilter(self.target, categories).qs()
+        ).products()
+
+        return {
+            'base_url': settings.BASE_URL,
+            'categories': categories,
+            'products': products,
+            'shop': settings.SHOP,
+            'utm': self.target,
+        }
+
+
+class CategoriesFilter:
+    """Categories list for particular market place."""
+
+    # dict keys are url targets for every service
+    IGNORED_CATEGORIES_MAP = defaultdict(list, {
+        'GM': ['Усилители звука для слабослышащих'],
+        # will be ignored by every category
+        'default': [
+            'Измерительные приборы', 'Новогодние вращающиеся светодиодные лампы',
+            'Новогодние лазерные проекторы', 'MP3- колонки', 'Беспроводные звонки',
+            'Радиоприёмники', 'Фонари', 'Отвертки', 'Весы электронные портативные',
+        ]
+    })
+
+    @property
+    def ignored(self) -> typing.List[str]:
+        return (
+            self.IGNORED_CATEGORIES_MAP['default']
+            + self.IGNORED_CATEGORIES_MAP[self.target]
+        )
+
+    def __init__(self, target: str):
+        assert target in settings.UTM_PRICE_MAP
+        self.target = target
+
+    def qs(self) -> models.SECategoryQuerySet:
+        if self.target == 'SE78':
+            return models.Category.objects.all()
+
+        result_categories = (
+            models.Category.objects
+            .exclude(
+                id__in=(
+                    models.Category.objects
+                    .filter(name__in=self.ignored)
+                    .get_descendants(include_self=True)
+                )
+            )
+        )
 
-class Prices:
-    pass
+        if self.target == 'YM':
+            """
+            Yandex Market feed requires items in some categories to have pictures.
+            To simplify filtering we are excluding all categories
+            which don't contain at least one product with picture.
+            """
+            # @todo #715:30m  Try to rm ancestors filter in YM price filter.
+            #  Exclude only categories with no pictures, without their ancestors.
+            result_categories = result_categories.get_categories_tree_with_pictures()
 
+        return result_categories
 
-# --- data ---
-class PriceFilter:
+
+class ProductsFilter:
     """Filter offers with individual price requirements."""
 
     FILTERS = defaultdict(
@@ -60,127 +135,75 @@ class PriceFilter:
         )
     )
 
-    def __init__(self, utm: str):
-        assert utm in settings.UTM_PRICE_MAP
-        self.utm = utm
+    def __init__(self, target: str, categories: models.SECategoryQuerySet):
+        assert target in settings.UTM_PRICE_MAP
+        self.target = target
+        self.categories = categories
 
-    def run(self, query_set: QuerySet) -> QuerySet:
-        return self.FILTERS[self.utm](query_set)
+    def qs(self) -> QuerySet:
+        return self.FILTERS[self.target](
+            models.Product.objects.active()
+            .filter(category__in=self.categories, price__gt=0)
+        )
 
 
-class Context(newcontext.Context):
-    """DB data, extracted for price file."""
+class ProductsPatch:
 
     UTM_MEDIUM_DATA = defaultdict(
         lambda: 'cpc',
         {'YM': 'cpc-market'}
     )
 
-    IGNORED_CATEGORIES = [
-        'Измерительные приборы', 'Новогодние вращающиеся светодиодные лампы',
-        'Новогодние лазерные проекторы', 'MP3- колонки', 'Беспроводные звонки',
-        'Радиоприёмники', 'Фонари', 'Отвертки', 'Весы электронные портативные',
-    ]
-
-    # dict keys are url targets for every service
-    IGNORED_CATEGORIES_MAP = defaultdict(list, {
-        'GM': ['Усилители звука для слабослышащих'],
-    })
-
-    def __init__(self, target: str):
+    def __init__(self, target: str, products: QuerySet):
+        assert target in settings.UTM_PRICE_MAP
         self.target = target
-
-    # @todo #666:120m  Split price.Context.context to smaller classes
-    #  Don't forget it:
-    #  - Move class `PriceFilter` to `Product`
-    #  - Merge to `IGNORED` constants
-    #  - Rm constants from Tree class
-    #  - Inject logs object here instead of returning operation status
-    #  - Maybe split this file to blocks
-    def context(self) -> dict:
-        def put_utm(product):
-            """Put UTM attribute to product."""
-            utm_marks = [
-                ('utm_source', self.target),
-                ('utm_medium', self.UTM_MEDIUM_DATA[self.target]),
-                ('utm_content', product.get_root_category().page.slug),
-                ('utm_term', str(product.vendor_code)),
-            ]
-
-            url = reverse('product', args=(product.vendor_code,))
-            utm_mark_query = '&'.join(f'{k}={v}' for k, v in utm_marks)
-            product.utm_url = f'{settings.BASE_URL}{url}?{utm_mark_query}'
-
-            product.prepared_params = [
-                (group, tags[0].name)
-                for (group, tags) in filter(
-                    lambda x: x[0].name != 'Производитель',
-                    product.get_params()
-                ) if tags
-            ]
-
-            return product
-
-        def put_crumbs(product):  # Ignore PyDocStyleBear
-            """Crumbs for google merchant. https://goo.gl/b0UJQp"""
-            product.crumbs = ' > '.join(
-                product.page.get_ancestors_fields('h1', include_self=False)[1:]
-            )
-            return product
-
-        def put_brand(product, brands):
-            product.brand = brands.get(product)
-            return product
-
-        def filter_categories(utm):
-            categories_to_exclude = (
-                models.Category.objects
-                .filter(
-                    Q(name__in=self.IGNORED_CATEGORIES)
-                    | Q(name__in=self.IGNORED_CATEGORIES_MAP[utm])
-                )
-                .get_descendants(include_self=True)
-            )
-
-            result_categories = models.Category.objects.exclude(id__in=categories_to_exclude)
-
-            if utm == 'YM':
-                """
-                Yandex Market feed requires items in some categories to have pictures
-                To simplify filtering we are excluding all categories
-                which don't contain at least one product with picture
-                """
-                result_categories = result_categories.get_categories_tree_with_pictures()
-
-            return result_categories
-
-        def prepare_products(categories_, utm):
-            """Filter product list and patch it for rendering."""
-            products = PriceFilter(utm).run(
-                models.Product.objects.active()
-                .bind_fields()
-                .filter(category__in=categories_, price__gt=0)
-            )
-            brands = models.Tag.objects.get_brands(products)
-            return [
-                put_brand(put_crumbs(put_utm(product)), brands)
-                for product in products
-            ]
-
-        categories = (
-            filter_categories(self.target) if self.target != 'SE78'
-            else models.Category.objects.all()
+        self._products = products
+
+    def put_params(self, product):
+        product.prepared_params = [
+            (group, tags[0].name)
+            for (group, tags) in filter(
+                lambda x: x[0].name != 'Производитель',
+                product.get_params()
+            ) if tags
+        ]
+        return product
+
+    def put_utm(self, product):
+        """Put UTM attribute to product."""
+        utm_marks = [
+            ('utm_source', self.target),
+            ('utm_medium', self.UTM_MEDIUM_DATA[self.target]),
+            ('utm_content', product.get_root_category().page.slug),
+            ('utm_term', str(product.vendor_code)),
+        ]
+
+        utm_mark_query = '&'.join(f'{k}={v}' for k, v in utm_marks)
+        product.utm_url = f'{settings.BASE_URL}{product.url}?{utm_mark_query}'
+
+        return product
+
+    def put_crumbs(self, product):  # Ignore PyDocStyleBear
+        """Crumbs for google merchant. https://goo.gl/b0UJQp"""
+        product.crumbs = ' > '.join(
+            product.page.get_ancestors_fields('h1', include_self=False)[1:]
         )
-
-        products = prepare_products(categories, self.target)
-
-        return {
-            'base_url': settings.BASE_URL,
-            'categories': categories,
-            'products': products,
-            'shop': settings.SHOP,
-            'utm': self.target,
-        }
+        return product
+
+    def put_brand(self, product, brands):
+        product.brand = brands.get(product)
+        return product
+
+    def products(self) -> typing.List[models.Product]:
+        """Path every product with additional fields."""
+        brands = models.Tag.objects.get_brands(self._products)
+        return [
+            self.put_brand(
+                product=self.put_params(self.put_crumbs(self.put_utm(product))),
+                brands=brands
+            )
+            for product in self._products
+        ]
 
 
 # --- command block ---


Test patch:
diff --git a/shopelectro/tests/tests_commands.py b/shopelectro/tests/tests_commands.py
--- a/shopelectro/tests/tests_commands.py
+++ b/shopelectro/tests/tests_commands.py
@@ -229,7 +229,7 @@ def tearDownClass(cls):
     def call_command_patched(cls, name):
         """Patch with test constants and call."""
         with mock.patch(
-            'shopelectro.management.commands.price.Context.IGNORED_CATEGORIES_MAP',
+            'shopelectro.management.commands.price.CategoriesFilter.IGNORED_CATEGORIES_MAP',
             new_callable=mock.PropertyMock
         ) as target:
             target.return_value = defaultdict(list, {