Repository: fidals/shopelectro

Issue:
se_extras.py:20-21: Reuse the logic.header.menu_qs menu...
The puzzle `974-ceadb5de` from #974 has to be resolved: 

https://github.com/fidals/shopelectro/blob/4da0030ab4692c401c4ff0cbcbacf947275ea369/shopelectro/templatetags/se_extras.py#L20-L21

The puzzle was created by duker33 on 30-Sep-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/logic/header.py b/shopelectro/logic/header.py
--- a/shopelectro/logic/header.py
+++ b/shopelectro/logic/header.py
@@ -6,18 +6,24 @@
 
 
 def menu_qs() -> pages_models.PageQuerySet:
+    """
+    QuerySet with header menu items.
+
+    Contains root categories.
+    Result can be tuned HEADER_LINKS settings option.
+    """
     return (
         pages_models.Page.objects.active()
         .filter(
             Q(slug__in=settings.HEADER_LINKS['add'])
-            & ~Q(slug__in=settings.HEADER_LINKS['exclude'])
             | (
                 # @todo #974:30m  Optimize the header menu query.
                 #  Fetch catalog page for the header menu at the same query.
                 #  root category pages.
                 Q(parent=pages_models.CustomPage.objects.filter(slug='catalog'))
+                & Q(type='model')
                 & Q(related_model_name=models.Category._meta.db_table)
-                & Q(parent=pages_models.CustomPage.objects.filter(slug='catalog'))
+                & ~Q(slug__in=settings.HEADER_LINKS['exclude'])
             )
         )
         .order_by('position')
diff --git a/shopelectro/settings/base.py b/shopelectro/settings/base.py
--- a/shopelectro/settings/base.py
+++ b/shopelectro/settings/base.py
@@ -465,6 +465,7 @@ def get_robots_content():
     ],
 }
 
+# add/exclude logic works only for root menu items
 HEADER_LINKS = {
     'exclude': ['radiopriiomniki'],
     'add': ['remont-akkumulyatorov'],
diff --git a/shopelectro/templatetags/se_extras.py b/shopelectro/templatetags/se_extras.py
--- a/shopelectro/templatetags/se_extras.py
+++ b/shopelectro/templatetags/se_extras.py
@@ -1,6 +1,5 @@
 import datetime
 import math
-from itertools import chain
 
 from django import template
 from django.conf import settings
@@ -10,27 +9,14 @@
 
 from images.models import ImageMixin
 from pages.models import Page
-from shopelectro.models import CategoryPage
+from shopelectro import logic
 
 register = template.Library()
 
 
 @register.simple_tag
 def roots():
-    # @todo #974:60m  Reuse the logic.header.menu_qs menu menu query.
-    #  And test HEADER_LINKS "exclude/add" settings option.
-    return sorted(
-        chain(
-            Page.objects.filter(slug__in=settings.HEADER_LINKS['add']),
-            filter(
-                lambda x: x.is_active,
-                CategoryPage.objects.exclude(slug__in=settings.HEADER_LINKS['exclude'])
-                # about get_cached_trees: https://goo.gl/rFKiku
-                .get_cached_trees()
-            ),
-        ),
-        key=lambda x: x.position,
-    )
+    return logic.header.menu_qs()
 
 
 @register.simple_tag


Test patch:
diff --git a/shopelectro/tests/tests_logic.py b/shopelectro/tests/tests_logic.py
--- a/shopelectro/tests/tests_logic.py
+++ b/shopelectro/tests/tests_logic.py
@@ -1,5 +1,6 @@
 from django.test import TestCase, override_settings, tag
 
+from pages import models as pages_models
 from shopelectro import models, logic
 
 
@@ -7,8 +8,18 @@
 @override_settings(HEADER_LINKS={'exclude': [], 'add': []})
 class HeaderMenu(TestCase):
     fixtures = ['dump.json']
+    to_exclude = models.CategoryPage.objects.get_cached_trees()[0]
+    to_add = pages_models.CustomPage.objects.get(slug='catalog')
 
     def test_roots_are_presented(self):
         from_db = set(models.CategoryPage.objects.get_cached_trees())
         from_logic = set(logic.header.menu_qs())
         self.assertEqual(from_db, from_logic)
+
+    @override_settings(HEADER_LINKS={'exclude': [to_exclude.slug], 'add': []})
+    def test_exclude_option(self):
+        self.assertNotIn(self.to_exclude, logic.header.menu_qs())
+
+    @override_settings(HEADER_LINKS={'exclude': [], 'add': [to_add.slug]})
+    def test_add_option(self):
+        self.assertIn(self.to_add, logic.header.menu_qs())
diff --git a/shopelectro/tests/tests_views.py b/shopelectro/tests/tests_views.py
--- a/shopelectro/tests/tests_views.py
+++ b/shopelectro/tests/tests_views.py
@@ -6,7 +6,8 @@
 """
 import json
 import re
-from functools import partial
+import typing
+from functools import lru_cache, partial
 from itertools import chain
 from operator import attrgetter
 from urllib.parse import urlparse, quote
@@ -16,6 +17,7 @@
 from django.conf import settings
 from django.db.models import Count, Q
 from django.http import HttpResponse
+from django.template.response import TemplateResponse
 from django.test import override_settings, TestCase, tag
 from django.urls import reverse
 from django.utils.translation import ugettext as _
@@ -23,7 +25,7 @@
 from catalog.helpers import reverse_catalog_url
 from pages import logic as pages_logic, models as pages_models
 from pages.urls import reverse_custom_page
-from shopelectro import models, views
+from shopelectro import logic, models, views
 from shopelectro.views.service import generate_md5_for_ya_kassa, \
     YANDEX_REQUEST_PARAM
 
@@ -38,6 +40,13 @@ def json_to_dict(response: HttpResponse) -> dict:
     return json.loads(response.content)
 
 
+def get_soup(page: TemplateResponse):
+    return BeautifulSoup(
+        page.content.decode('utf-8'),
+        'html.parser'
+    )
+
+
 class ViewsTestCase(TestCase):
 
     fixtures = ['dump.json']
@@ -77,17 +86,11 @@ def get_product_page(self, product: models.Product=None):
         return self.client.get(product.url)
 
     def get_category_soup(self, *args, **kwargs):
-        return BeautifulSoup(
-            self.get_category_page(*args, **kwargs).content.decode('utf-8'),
-            'html.parser'
-        )
+        return get_soup(self.get_category_page(*args, **kwargs))
 
     def get_product_soup(self, product: models.Product=None) -> BeautifulSoup:
         product_page = self.get_product_page(product)
-        return BeautifulSoup(
-            product_page.content.decode('utf-8'),
-            'html.parser'
-        )
+        return get_soup(product_page)
 
 
 @tag('fast', 'catalog')
@@ -320,10 +323,7 @@ def load_more(
     def get_load_more_soup(self, *args, **kwargs) -> BeautifulSoup:
         """Use interface of `self.load_more` method."""
         load_more_response = self.load_more(*args, **kwargs)
-        return BeautifulSoup(
-            load_more_response.content.decode('utf-8'),
-            'html.parser'
-        )
+        return get_soup(load_more_response)
 
     def test_pagination_numbering_first_page(self):
         self.assertEqual(get_page_number(self.load_more()), 1)
@@ -497,10 +497,7 @@ def get_page(self, *args, **kwargs):
 
     def get_soup(self, *args, **kwargs) -> BeautifulSoup:
         category_page = self.get_page(*args, **kwargs)
-        return BeautifulSoup(
-            category_page.content.decode('utf-8'),
-            'html.parser'
-        )
+        return get_soup(category_page)
 
     def test_subcategories_presented(self):
         child = models.Category.objects.active().exclude(parent=None).first()
@@ -643,10 +640,7 @@ def get_page(self):
         )
 
     def get_soup(self) -> BeautifulSoup:
-        return BeautifulSoup(
-            self.get_page().content.decode('utf-8'),
-            'html.parser'
-        )
+        return get_soup(self.get_page())
 
     def test_roots_sorting(self):
         soup = self.get_soup()
@@ -886,9 +880,7 @@ def test_search_has_no_model_pages(self):
             f'/search/?term={self.QUOTED_SIGNLE_RESULT_TERM}',
             follow=True,
         )
-        result_links = BeautifulSoup(
-            response.content.decode('utf-8'), 'html.parser'
-        ).find_all(class_='search-result-link')
+        result_links = get_soup(response).find_all(class_='search-result-link')
         self.assertTrue(len(result_links) == 1)
 
     def test_autocomplete_has_no_model_pages(self):
@@ -992,3 +984,52 @@ def test_500(self):
             self.client.get('/500/'),
             text='Ошибка на стороне сервера'
         )
+
+
+class HeaderMenu:
+    def __init__(self, soup: BeautifulSoup):
+        self.soup = soup
+
+    @lru_cache(maxsize=1)
+    def as_dict(self) -> typing.Dict[str, list]:
+        # i assume that dict is ordered there. It's py3.6
+        top_menu = {}
+        for container in self.soup.find_all(class_='nav-category-item'):
+            root = container.find(class_='nav-category-link').text.strip()
+            categories = [
+                category.text.strip()
+                for category in container.find_all(class_='list-white-link')
+            ]
+            top_menu.update({root: categories})
+        return top_menu
+
+    def roots(self) -> typing.List[str]:
+        return list(self.as_dict().keys())
+
+    def children(self, root: str) -> typing.List[str]:
+        return self.as_dict()[root]
+
+
+@tag('fast', 'catalog')
+class Header(ViewsTestCase):
+
+    fixtures = ['dump.json']
+
+    def test_menu_roots(self):
+        soup = get_soup(page=self.client.get('/'))
+        from_page = HeaderMenu(soup).roots()
+        from_logic = list(
+            logic.header.menu_qs().values_list('name', flat=True)
+        )
+        self.assertEqual(from_logic, from_page)
+
+    def test_menu_children(self):
+        soup = get_soup(page=self.client.get('/'))
+        menu = HeaderMenu(soup)
+        for root in logic.header.menu_qs().iterator():
+            from_page = menu.children(root.name)
+            from_logic = list(
+                root.get_children().values_list('name', flat=True)
+            )
+            self.assertEqual(from_logic, from_page)
+