diff --git a/requirements.txt b/requirements.txt index fe949e90..e34e0398 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,4 +17,4 @@ ua-parser==0.8.0 user-agents==1.1.0 sorl-thumbnail==12.4a1 https://github.com/selwin/django-user_agents/archive/master.zip -https://github.com/fidals/refarm-site/archive/0.3.0.zip +https://github.com/fidals/refarm-site/archive/0.4.0.zip diff --git a/shopelectro/context.py b/shopelectro/context.py index b9d35092..2c10a763 100644 --- a/shopelectro/context.py +++ b/shopelectro/context.py @@ -17,21 +17,23 @@ >>> Category(url_kwargs, request=HttpRequest()) | TaggedCategory() """ +# @todo #550:60m Move context module to refarm.catalog app + import typing from abc import ABC, abstractmethod +from collections import defaultdict from functools import lru_cache, partial from django import http -from django.db.models import QuerySet from django.conf import settings +from django.db.models import QuerySet from django.core.paginator import Paginator, InvalidPage from django_user_agents.utils import get_user_agent +from catalog.models import ProductQuerySet, Tag, TagQuerySet from images.models import Image from pages.models import ModelPage -from shopelectro import models - class SortingOption: def __init__(self, index=0): @@ -82,18 +84,22 @@ def number_url_map(self): # @todo #550:30m Split to ProductImagesContext and ProductBrandContext @lru_cache(maxsize=64) -def prepare_tile_products(products: QuerySet): - assert isinstance(products, QuerySet) +def prepare_tile_products( + products: ProductQuerySet, product_pages: QuerySet, tags: TagQuerySet=None +): + # @todo #550:60m Move prepare_tile_products func to context + # Now it's separated function with huge of inconsistent queryset deps. + assert isinstance(products, ProductQuerySet) images = Image.objects.get_main_images_by_pages( - models.ProductPage.objects.filter(shopelectro_product__in=products) + product_pages.filter(shopelectro_product__in=products) ) brands = ( - models.Tag.objects + tags .filter_by_products(products) .get_brands(products) - ) + ) if tags else defaultdict(lambda: None) return [ (product, images.get(product.page), brands.get(product)) @@ -177,18 +183,40 @@ class AbstractProductsListContext(AbstractPageContext, ABC): super: 'AbstractProductsListContext' = None + def __init__( # Ignore PyDocStyleBear + self, + url_kwargs: typing.Dict[str, str]=None, + request: http.HttpRequest=None, + products: ProductQuerySet=None, + product_pages: QuerySet=None, + ): + """ + :param url_kwargs: Came from `urls` module. + :param request: Came from `urls` module. + :param products: Every project provides products from DB. + """ + super().__init__(url_kwargs, request) + self.products_ = products + self.product_pages_ = product_pages + @property - def products(self) -> QuerySet: + def product_pages(self) -> QuerySet: + return self.product_pages_ or self.super.product_pages + + @property + def products(self) -> ProductQuerySet: if self.super: return self.super.products + elif self.products_: + return self.products_ else: - raise NotImplementedError + raise NotImplementedError('Set products queryset') class Category(AbstractProductsListContext): @property - def products(self) -> QuerySet: - return models.Product.actives.get_category_descendants( + def products(self) -> ProductQuerySet: + return super().products.active().get_category_descendants( self.page.model ) @@ -198,15 +226,8 @@ def get_context_data(self): # Depends on updating to python3.7 view_type = self.request.session.get('view_type', 'tile') - group_tags_pairs = ( - models.Tag.objects - .filter_by_products(self.products) - .get_group_tags_pairs() - ) - return { - 'products_data': prepare_tile_products(self.products), - 'group_tags_pairs': group_tags_pairs, + 'products_data': prepare_tile_products(self.products, self.product_pages), # can be `tile` or `list`. Defines products list layout. 'view_type': view_type, } @@ -214,16 +235,38 @@ def get_context_data(self): class TaggedCategory(AbstractProductsListContext): + def __init__( # Ignore PyDocStyleBear + self, + url_kwargs: typing.Dict[str, str]=None, + request: http.HttpRequest=None, + products: ProductQuerySet=None, + tags: TagQuerySet=None + ): + """ + :param url_kwargs: Came from `urls` module. + :param request: Came from `urls` module. + :param products: Every project provides products from DB. + :param tags: Every project provides tags from DB. + """ + super().__init__(url_kwargs, request, products) + # it's not good. Arg should not be default. + # That's how we'll prevent assertion. + # But we'll throw away inheritance in se#567. + assert tags, 'tags is required arg' + self.tags_ = tags + def get_sorting_index(self): return int(self.url_kwargs.get('sorting', 0)) - def get_tags(self) -> typing.Optional[models.TagQuerySet]: + # @todo #550:15m Move `TaggedCategory.get_tags` to property. + # As in `products` property case. + def get_tags(self) -> typing.Optional[TagQuerySet]: request_tags = self.url_kwargs.get('tags') if not request_tags: return None - slugs = models.Tag.parse_url_tags(request_tags) - tags = models.Tag.objects.filter(slug__in=slugs) + slugs = Tag.parse_url_tags(request_tags) + tags = self.tags_.filter(slug__in=slugs) if not tags: raise http.Http404('No such tag.') return tags @@ -242,15 +285,28 @@ def products(self): # @todo #550:60m Try to rm sorting staff from context.TaggedCategory. # Or explain again why it's impossible. Now it's not clear from comment. .distinct(sorting_option.field) + .order_by(sorting_option.field) ) return products def get_context_data(self): context = self.super.get_context_data() tags = self.get_tags() + group_tags_pairs = ( + self.tags_ + .filter_by_products(self.products) + .get_group_tags_pairs() + ) return { **context, 'tags': tags, + 'group_tags_pairs': group_tags_pairs, + 'products_data': prepare_tile_products( + self.products, + self.product_pages, + # requires all tags, not only selected + self.tags_ + ), # Category's canonical link is `category.page.get_absolute_url`. # So, this link always contains no tags. # That's why we skip canonical link on tagged category page. @@ -301,7 +357,7 @@ def get_sorting_index(self): return int(self.url_kwargs.get('sorting', 0)) @property - def products(self) -> QuerySet: + def products(self) -> ProductQuerySet: sorting_index = int(self.url_kwargs.get('sorting', 0)) sorting_option = SortingOption(index=sorting_index) return self.super.products.order_by(sorting_option.directed_field) @@ -310,7 +366,9 @@ def get_context_data(self): context = self.super.get_context_data() return { **context, - 'products_data': prepare_tile_products(self.products), + 'products_data': prepare_tile_products( + self.products, self.product_pages + ), 'sort': self.get_sorting_index(), } @@ -322,45 +380,70 @@ def get_products_count(self): mobile_view = get_user_agent(self.request).is_mobile return settings.PRODUCTS_ON_PAGE_MOB if mobile_view else settings.PRODUCTS_ON_PAGE_PC - def get_paginated_page_or_404(self, per_page, page_number): + def get_paginated_page_or_404(self, per_page, page_number) -> Paginator: try: - return Paginator(self.products, per_page).page(page_number) + return Paginator(self.all_products, per_page).page(page_number) except InvalidPage: raise http.Http404('Page does not exist') - def get_context_data(self): - context = self.super.get_context_data() - products_on_page = int(self.request.GET.get( + @property + def products_on_page(self): + return int(self.request.GET.get( 'step', self.get_products_count(), )) - page_number = int(self.request.GET.get('page', 1)) + @property + def page_number(self): + return int(self.request.GET.get('page', 1)) + + @property + def all_products(self) -> ProductQuerySet: + return self.super.products + + @property + def products(self) -> ProductQuerySet: + """Only products for current page.""" + paginated_page = self.get_paginated_page_or_404( + self.products_on_page, self.page_number + ) + # it's queryset, but it's sliced + products: ProductQuerySet = paginated_page.object_list + return products + + @property + def products_count(self): + return (self.page_number - 1) * self.products_on_page + self.products.count() + + def check_pagination_args(self): if ( - page_number < 1 or - products_on_page not in settings.CATEGORY_STEP_MULTIPLIERS + self.page_number < 1 or + self.products_on_page not in settings.CATEGORY_STEP_MULTIPLIERS ): raise http.Http404('Page does not exist.') # Ignore CPDBear - paginated_page = self.get_paginated_page_or_404( - products_on_page, page_number - ) - total_products = self.products.count() - products = paginated_page.object_list - if not products: + def get_context_data(self): + context = self.super.get_context_data() + self.check_pagination_args() + + if not self.products: raise http.Http404('Page without products does not exist.') paginated = PaginatorLinks( - page_number, + self.page_number, self.request.path, - Paginator(self.products, products_on_page) + Paginator(self.all_products, self.products_on_page) ) paginated_page = paginated.page() + total_products = self.all_products.count() + return { **context, - 'products_data': prepare_tile_products(products), + 'products_data': prepare_tile_products( + self.products, self.product_pages + ), 'total_products': total_products, - 'products_count': (page_number - 1) * products_on_page + products.count(), + 'products_count': self.products_count, 'paginated': paginated, 'paginated_page': paginated_page, 'sorting_options': settings.CATEGORY_SORTING_OPTIONS.values(), diff --git a/shopelectro/models.py b/shopelectro/models.py index 4c43ee8c..0dca81cf 100644 --- a/shopelectro/models.py +++ b/shopelectro/models.py @@ -20,6 +20,8 @@ ProductActiveManager, ProductManager, TagGroup as caTagGroup, + Tag as caTag, + TagQuerySet as caTagQuerySet, ) from ecommerce.models import Order as ecOrder from pages.models import CustomPage, ModelPage, Page, SyncPageMixin, PageManager @@ -202,142 +204,18 @@ class Meta(ModelPage.Meta): # Ignore PycodestyleBear (E303) class TagGroup(caTagGroup): + pass - def __str__(self): - return self.name - - -class TagQuerySet(models.QuerySet): - - def filter_by_products(self, products: typing.List[Product]): - ordering = settings.TAGS_ORDER - distinct = [order.lstrip('-') for order in ordering] - - return ( - self - .filter(products__in=products) - .order_by(*ordering) - .distinct(*distinct, 'id') - ) - - def get_group_tags_pairs(self) -> typing.List[typing.Tuple[TagGroup, typing.List['Tag']]]: - grouped_tags = groupby(self.prefetch_related('group'), key=attrgetter('group')) - return [ - (group, list(tags_)) - for group, tags_ in grouped_tags - ] - - def get_brands(self, products: typing.List[Product]) -> typing.Dict[Product, 'Tag']: - brand_tags = ( - self.filter(group__name=settings.BRAND_TAG_GROUP_NAME) - .prefetch_related('products') - .select_related('group') - ) - - return { - product: brand - for brand in brand_tags for product in products - if product in brand.products.all() - } - - def as_string( # Ignore PyDocStyleBear - self, - field_name: str, - type_delimiter: str, - group_delimiter: str, - ) -> str: - """ - :param field_name: Only field's value is used to represent tag as string. - :param type_delimiter: - :param group_delimiter: - :return: - """ - if not self: - return '' - - group_tags_map = self.get_group_tags_pairs() - - _, tags_by_group = zip(*group_tags_map) - - return group_delimiter.join( - type_delimiter.join(getattr(tag, field_name) for tag in tags_list) - for tags_list in tags_by_group - ) - - def as_title(self) -> str: - return self.as_string( - field_name='name', - type_delimiter=settings.TAGS_TITLE_DELIMITER, - group_delimiter=settings.TAG_GROUPS_TITLE_DELIMITER - ) - - def as_url(self) -> str: - return self.as_string( - field_name='slug', - type_delimiter=settings.TAGS_URL_DELIMITER, - group_delimiter=settings.TAG_GROUPS_URL_DELIMITER - ) - - -class TagManager(models.Manager.from_queryset(TagQuerySet)): - - def get_queryset(self): - return ( - super().get_queryset() - .order_by(*settings.TAGS_ORDER) - ) - - def get_group_tags_pairs(self): - return self.get_queryset().get_group_tags_pairs() - - def filter_by_products(self, products): - return self.get_queryset().filter_by_products(products) - - def get_brands(self, products): - """Get a batch of products' brands.""" - return self.get_queryset().get_brands(products) - - -class Tag(models.Model): - - # Uncomment it after moving to refarm with rf#162 - # class Meta: - # unique_together = ('name', 'group') - - objects = TagManager() - uuid = models.UUIDField(default=uuid4, editable=False) - name = models.CharField( - max_length=100, db_index=True, verbose_name=_('name')) - position = models.PositiveSmallIntegerField( - default=0, blank=True, db_index=True, verbose_name=_('position'), - ) +class TagQuerySet(caTagQuerySet): + pass - # Set it as unique with rf#162 - slug = models.SlugField(default='') +class Tag(caTag): group = models.ForeignKey( TagGroup, on_delete=models.CASCADE, null=True, related_name='tags', ) - def save(self, *args, **kwargs): - if not self.slug: - # same slugify code used in PageMixin object - self.slug = slugify( - unidecode(self.name.replace('.', '-').replace('+', '-')) - ) - doubled_tag_qs = self.__class__.objects.filter(slug=self.slug) - if doubled_tag_qs: - self.slug = randomize_slug(self.slug) - super(Tag, self).save(*args, **kwargs) - - @staticmethod - def parse_url_tags(tags: str) -> list: - groups = tags.split(settings.TAGS_URL_DELIMITER) - return set(chain.from_iterable( - group.split(settings.TAG_GROUPS_URL_DELIMITER) for group in groups - )) - class ExcludedModelTPageManager(PageManager): diff --git a/shopelectro/tests/tests_views.py b/shopelectro/tests/tests_views.py index ca12b5df..ddf03918 100644 --- a/shopelectro/tests/tests_views.py +++ b/shopelectro/tests/tests_views.py @@ -19,8 +19,7 @@ from django.urls import reverse from django.utils.translation import ugettext as _ -from shopelectro import context -from shopelectro.models import Category, Product, Tag, TagGroup, TagQuerySet +from shopelectro import context, models from shopelectro.views.service import generate_md5_for_ya_kassa, YANDEX_REQUEST_PARAM from shopelectro.tests.helpers import create_doubled_tag @@ -31,7 +30,7 @@ def reverse_catalog_url( url: str, route_kwargs: dict, - tags: TagQuerySet=None, + tags: models.TagQuerySet=None, sorting: int=None, query_string: dict=None, ) -> str: @@ -60,13 +59,13 @@ class BaseCatalogTestCase(TestCase): fixtures = ['dump.json'] def setUp(self): - self.category = Category.objects.root_nodes().select_related('page').first() - self.tags = Tag.objects.order_by(*settings.TAGS_ORDER).all() + self.category = models.Category.objects.root_nodes().select_related('page').first() + self.tags = models.Tag.objects.order_by(*settings.TAGS_ORDER).all() def get_category_page( self, - category: Category=None, - tags: TagQuerySet=None, + category: models.Category=None, + tags: models.TagQuerySet=None, sorting: int=None, query_string: dict=None, ): @@ -80,13 +79,14 @@ class CatalogPage(BaseCatalogTestCase): def test_merge_product_cache(self): """Context merging should cached.""" - products = Product.objects.all()[:2] - with self.assertNumQueries(7): + products = models.Product.objects.all()[:2] + product_pages = models.ProductPage.objects.all() + with self.assertNumQueries(5): # N db queries without before cached - context.prepare_tile_products(products) + context.prepare_tile_products(products, product_pages) with self.assertNumQueries(0): # no db queries after cached - context.prepare_tile_products(products) + context.prepare_tile_products(products, product_pages) class CatalogTags(BaseCatalogTestCase): @@ -97,7 +97,7 @@ def test_category_page_contains_all_tags(self): tags = set(chain.from_iterable(map( lambda x: x.tags.all(), ( - Product.objects + models.Product.objects .get_by_category(self.category) .prefetch_related('tags') ) @@ -149,7 +149,7 @@ def test_contains_product_with_certain_tags(self): products_count = len(list(filter( lambda x: x.category.is_descendant_of(self.category), - Product.objects.filter(Q(tags=tags[0]) | Q(tags=tags[1])) + models.Product.objects.filter(Q(tags=tags[0]) | Q(tags=tags[1])) ))) self.assertContains(response, products_count) @@ -161,7 +161,7 @@ def test_tag_titles_content_disjunction(self): CategoryTagsPage with tags "Напряжение 6В" и "Напряжение 24В" should contain tag_titles var content: "6В или 24В". """ - tag_group = TagGroup.objects.first() + tag_group = models.TagGroup.objects.first() tags = tag_group.tags.order_by(*settings.TAGS_ORDER).all() response = self.get_category_page(tags=tags) self.assertEqual(response.status_code, 200) @@ -176,9 +176,9 @@ def test_tag_titles_content_conjunction(self): CategoryTagsPage with tags "Напряжение 6В" и "Cила тока 1А" should contain tag_titles var content: "6В и 1А". """ - tag_groups = TagGroup.objects.order_by('position', 'name').all() + tag_groups = models.TagGroup.objects.order_by('position', 'name').all() tag_ids = [g.tags.first().id for g in tag_groups] - tags = Tag.objects.filter(id__in=tag_ids) + tags = models.Tag.objects.filter(id__in=tag_ids) response = self.get_category_page(tags=tags) self.assertEqual(response.status_code, 200) delimiter = settings.TAG_GROUPS_TITLE_DELIMITER @@ -192,7 +192,7 @@ def test_tags_var(self): CategoryTagsPage should contain "tags" template var tag=each(tags) is Tag class instance. """ - tags = Tag.objects.order_by(*settings.TAGS_ORDER).all() + tags = models.Tag.objects.order_by(*settings.TAGS_ORDER).all() response = self.get_category_page(tags=tags) self.assertEqual(response.status_code, 200) tag_names = ', '.join([t.name for t in tags]) @@ -202,7 +202,7 @@ def test_doubled_tag(self): """Category tags page filtered by the same tag from different tag groups.""" tag_ = create_doubled_tag() response = self.get_category_page( - tags=Tag.objects.filter(id=tag_.id) + tags=models.Tag.objects.filter(id=tag_.id) ) self.assertEqual(response.status_code, 200) self.assertContains(response, tag_.name) @@ -211,7 +211,7 @@ def test_doubled_tag(self): def test_product_tag_linking(self): """Product should contain links on CategoryTagPage for it's every tag.""" - product = Product.objects.first() + product = models.Product.objects.first() self.assertGreater(product.tags.count(), 0) property_links = [ @@ -226,7 +226,7 @@ def test_product_tag_linking(self): def test_non_existing_tags_404(self): """Product should contain links on CategoryTagPage for it's every tag.""" - product = Product.objects.first() + product = models.Product.objects.first() self.assertGreater(product.tags.count(), 0) bad_tag_url = reverse('category', kwargs={ @@ -273,11 +273,16 @@ def assert_pagination_link(self, link, page_number): ) def get_category_soup(self, page_number: int) -> BeautifulSoup: + category_page = self.get_category_page(query_string={'page': page_number}) return BeautifulSoup( - self.get_category_page(query_string={'page': page_number}).content.decode('utf-8'), + category_page.content.decode('utf-8'), 'html.parser' ) + def test_category_200(self): + category_page = self.get_category_page() + assert category_page.status_code == 200 + def test_numbered_pagination_links(self): """Forward to numbered pagination pages.""" page_number = 3 @@ -317,12 +322,12 @@ class LoadMore(TestCase): DEFAULT_LIMIT = 48 def setUp(self): - self.category = Category.objects.root_nodes().select_related('page').first() + self.category = models.Category.objects.root_nodes().select_related('page').first() def load_more( self, - category: Category=None, - tags: TagQuerySet=None, + category: models.Category=None, + tags: models.TagQuerySet=None, offset: int=0, # uncomment after implementation urls for load_more with pagination # limit: int=0, @@ -344,7 +349,7 @@ def test_pagination_numbering_first_page(self): self.assertEqual(get_page_number(self.load_more()), 1) def test_pagination_numbering_last_page(self): - offset = Product.objects.get_by_category(self.category).count() - 1 + offset = models.Product.objects.get_by_category(self.category).count() - 1 self.assertEqual( get_page_number(self.load_more(offset=offset)), offset // self.DEFAULT_LIMIT + 1, @@ -481,7 +486,7 @@ class ProductPage(TestCase): fixtures = ['dump.json'] def setUp(self): - self.product = Product.objects.first() + self.product = models.Product.objects.first() def test_orphan_product(self): self.product.category = None @@ -522,7 +527,7 @@ def test_available(self): """Page of an product with stock > 0 has $schema_url/InStock link.""" self.assertContains( self.client.get( - Product.objects.filter(in_stock__gt=0).first().url + models.Product.objects.filter(in_stock__gt=0).first().url ), f'{self.schema_url}/InStock', ) @@ -531,7 +536,7 @@ def test_not_available(self): """Page of an product with stock = 0 has $schema_url/PreOrder link.""" self.assertContains( self.client.get( - Product.objects.filter(in_stock=0).first().url + models.Product.objects.filter(in_stock=0).first().url ), f'{self.schema_url}/PreOrder', ) diff --git a/shopelectro/views/catalog.py b/shopelectro/views/catalog.py index fe3bef58..36d48203 100644 --- a/shopelectro/views/catalog.py +++ b/shopelectro/views/catalog.py @@ -67,7 +67,8 @@ def get_context_data(self, **kwargs): 'price_bounds': settings.PRICE_BOUNDS, 'group_tags_pairs': product.get_params(), 'tile_products': context.prepare_tile_products( - product.get_siblings(offset=settings.PRODUCT_SIBLINGS_COUNT) + product.get_siblings(offset=settings.PRODUCT_SIBLINGS_COUNT), + models.ProductPage.objects.all() ), } @@ -87,7 +88,8 @@ def render_siblings_on_404( tile_products=context.prepare_tile_products( inactive_product.get_siblings( offset=settings.PRODUCT_SIBLINGS_COUNT - ) + ), + models.ProductPage.objects.all() ), tile_title='Возможно вас заинтересуют похожие товары:', **url_kwargs, @@ -112,7 +114,10 @@ def get_context_data(self, **kwargs): .prefetch_related('category') .select_related('page') ) - tile_products = context.prepare_tile_products(top_products) + tile_products = context.prepare_tile_products( + top_products, + models.ProductPage.objects.all() + ) return { **context_, @@ -128,9 +133,13 @@ class CategoryPage(catalog.CategoryPage): def get_context_data(self, **kwargs): """Add sorting options and view_types in context.""" context_ = ( - context.Category(self.kwargs, self.request) - | context.TaggedCategory() - | context.SortingCategory() + context.Category( + self.kwargs, self.request, + models.Product.objects.all(), + models.ProductPage.objects.all(), + ) + | context.TaggedCategory(tags=models.Tag.objects.all()) + | context.SortingCategory() # requires TaggedCategory | context.PaginationCategory() # requires SortingCategory | context.DBTemplate() # requires TaggedCategory ) @@ -195,7 +204,9 @@ def load_more(request, category_slug, offset=0, limit=0, sorting=0, tags=None): view = request.session.get('view_type', 'tile') return render(request, 'catalog/category_products.html', { - 'products_data': context.prepare_tile_products(products), + 'products_data': context.prepare_tile_products( + products, models.ProductPage.objects.all() + ), 'paginated': paginated, 'paginated_page': paginated_page, 'view_type': view,