Skip to content

Commit

Permalink
#550 Split TaggedCategoryPage class (#551)
Browse files Browse the repository at this point in the history
* #550  Review#3 fixes. Rm get_tags caching

#550  Resolve branch inner conflicts

#550  Apply linter rules

#550  Review#2 fixes. Rm redundant pdd issue, fix imports

#550  Rm redundant pdd issue

#550  Pdd issue about context module improving

#550  Merge fixes after hell

#550  Minor self-review fixes

#550  Add code example for creating context

#550  Make context names shorten

#550  Fork AbstractPageContext

#550  Cleanup code

#550  Fork PaginationCategoryContext class

#550  Fork SortingCategoryContext class

#550  Fork DBTemplateContext class. Improve pipe mech

#550  Fork TaggedCategoryContext class

#550  Implement CatalogContext

#550  Fork SortingCategoryPage class

#550  Fork PaginatedCatalogPage class

#550  Fork DBContextCatalogPage class

#548  Apply E305 linter rule

#550  Review#1 fixes. Cache in memory db queries

#548  Rm E305 pycodestyle rule

#550  Add pdd issue for continue splitting

#550  Create TaggedCategoryPage class

* #550  Merge fixes

* #550  Fix shadowed var

* #550  Apply linter rules after rebase
  • Loading branch information
duker33 authored Sep 7, 2018
1 parent d26df5b commit 9399dd6
Show file tree
Hide file tree
Showing 7 changed files with 559 additions and 187 deletions.
2 changes: 1 addition & 1 deletion .coafile
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ minimum_tokens = 35
max_line_length = 100
# https://pycodestyle.readthedocs.io/en/latest/intro.html#error-codes
# ignore W503 according to pep8 notes: https://goo.gl/rEjtY6
pycodestyle_ignore = E305,W503
pycodestyle_ignore = W503
pydocstyle_ignore = D100,D101,D102,D103,D104,D105,D106,D203,D212
language = Python 3

Expand Down
368 changes: 368 additions & 0 deletions shopelectro/context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,368 @@
"""
Contains view context classes.
Context is our own concept.
It's part of the View abstract layer in MTV paradigm.
Data flow looks like this:
Urls -> Context -> View.
So, by concept, View receives context directly
without accessing url args.
It stands between urls and view functions.
Every context class is used via objects composition.
Code example to create tagged category:
>>> from django.http import HttpRequest
>>> url_kwargs = {'slug': 'my-page'}
>>> Category(url_kwargs, request=HttpRequest()) | TaggedCategory()
"""

import typing
from abc import ABC, abstractmethod
from functools import lru_cache, partial

from django import http
from django.db.models import QuerySet
from django.conf import settings
from django.core.paginator import Paginator, InvalidPage
from django_user_agents.utils import get_user_agent

from images.models import Image
from pages.models import ModelPage

from shopelectro import models


class SortingOption:
def __init__(self, index=0):
options = settings.CATEGORY_SORTING_OPTIONS[index]
self.label = options['label']
self.field = options['field']
self.direction = options['direction']

@property
def directed_field(self):
return self.direction + self.field


class PaginatorLinks:

def __init__(self, number, path, paginated: Paginator):
self.paginated = paginated
self.number = number
self.path = path

self.index = number - 1
self.neighbor_bounds = settings.PAGINATION_NEIGHBORS // 2
self.neighbor_range = list(self.paginated.page_range)

def page(self):
try:
return self.paginated.page(self.number)
except InvalidPage:
raise http.Http404('Page does not exist')

def showed_number(self):
return self.index * self.paginated.per_page + self.page().object_list.count()

def _url(self, number):
self.paginated.validate_number(number)
return self.path if number == 1 else f'{self.path}?page={number}'

def prev_numbers(self):
return self.neighbor_range[:self.index][-self.neighbor_bounds:]

def next_numbers(self):
return self.neighbor_range[self.index + 1:][:self.neighbor_bounds]

def number_url_map(self):
numbers = self.prev_numbers() + self.next_numbers()
return {number: self._url(number) for number in numbers}


# @todo #550:30m Split to ProductImagesContext and ProductBrandContext
@lru_cache(maxsize=64)
def prepare_tile_products(products: QuerySet):
assert isinstance(products, QuerySet)

images = Image.objects.get_main_images_by_pages(
models.ProductPage.objects.filter(shopelectro_product__in=products)
)

brands = (
models.Tag.objects
.filter_by_products(products)
.get_brands(products)
)

return [
(product, images.get(product.page), brands.get(product))
for product in products
]


class ObjectsComposition:

super: 'ObjectsComposition' = None

def __or__(self, other: 'ObjectsComposition'):
other.super = self
return other


# @todo #550:120m Move context realization to pure to objects composition.
# Discussed some thoughts with Artemiy via call.
# Artemiy will do it.
# For example SortedCategory should
# consist of separated SortedList and Category classes/objects.
class AbstractContext(ObjectsComposition, ABC):

super: 'AbstractContext' = None

def __init__( # Ignore PyDocStyleBear
self,
url_kwargs: typing.Dict[str, str]=None,
request: http.HttpRequest=None
):
"""
:param url_kwargs: Came from `urls` module.
:param request: Came from `urls` module
"""

self.url_kwargs_ = url_kwargs or {}
self.request_ = request

@property
def url_kwargs(self) -> typing.Dict[str, str]:
return self.url_kwargs_ or self.super.url_kwargs

@property
def request(self) -> http.HttpRequest:
return self.request_ or self.super.request

@abstractmethod
def get_context_data(self) -> typing.Dict[str, typing.Any]:
...


class AbstractPageContext(AbstractContext, ABC):

super: 'AbstractPageContext' = None

def __init__( # Ignore PyDocStyleBear
self,
url_kwargs: typing.Dict[str, str]=None,
request: http.HttpRequest=None
):
"""
:param url_kwargs: Came from `urls` module.
:param request: Came from `urls` module
"""

if url_kwargs:
assert 'slug' in url_kwargs
super().__init__(url_kwargs, request)

@property
def slug(self) -> str:
return self.url_kwargs['slug']

@property
@lru_cache(maxsize=1)
def page(self):
return ModelPage.objects.get(slug=self.slug)


class AbstractProductsListContext(AbstractPageContext, ABC):

super: 'AbstractProductsListContext' = None

@property
def products(self) -> QuerySet:
if self.super:
return self.super.products
else:
raise NotImplementedError


class Category(AbstractProductsListContext):
@property
def products(self) -> QuerySet:
return models.Product.actives.get_category_descendants(
self.page.model
)

def get_context_data(self):
"""Add sorting options and view_types in context."""
# @todo #550:15m Take `view_type` value from dataclass.
# 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,
# can be `tile` or `list`. Defines products list layout.
'view_type': view_type,
}


class TaggedCategory(AbstractProductsListContext):

def get_sorting_index(self):
return int(self.url_kwargs.get('sorting', 0))

def get_tags(self) -> typing.Optional[models.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)
if not tags:
raise http.Http404('No such tag.')
return tags

@property
def products(self):
products = self.super.products
sorting_option = SortingOption(index=self.get_sorting_index())
tags = self.get_tags()
if tags:
products = (
products
.filter(tags__in=tags)
# Use distinct because filtering by QuerySet tags,
# that related with products by many-to-many relation.
# @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)
)
return products

def get_context_data(self):
context = self.super.get_context_data()
tags = self.get_tags()
return {
**context,
'tags': 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.
'skip_canonical': bool(tags),
}


class DBTemplate(AbstractPageContext):
"""Processes some page data fields as templates with their own context."""

@property
@lru_cache(maxsize=1)
def page(self):
page = self.super.page
context = self.get_super_context_data_cached()

def template_context(page, tag_titles, tags):
return {
'page': page,
'tag_titles': tag_titles,
'tags': tags,
}

tags = context['tags']
if tags:
tag_titles = tags.as_title()
page.get_template_render_context = partial(
template_context, page, tag_titles, tags
)

return page

@lru_cache(maxsize=1)
def get_super_context_data_cached(self):
return self.super.get_context_data()

@lru_cache(maxsize=1)
def get_context_data(self):
return {
**self.get_super_context_data_cached(),
'page': self.page,
}


class SortingCategory(AbstractProductsListContext):

def get_sorting_index(self):
return int(self.url_kwargs.get('sorting', 0))

@property
def products(self) -> QuerySet:
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)

def get_context_data(self):
context = self.super.get_context_data()
return {
**context,
'products_data': prepare_tile_products(self.products),
'sort': self.get_sorting_index(),
}


class PaginationCategory(AbstractProductsListContext):

def get_products_count(self):
"""Calculate max products list size from request. List size depends on device type."""
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):
try:
return Paginator(self.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(
'step', self.get_products_count(),
))
page_number = int(self.request.GET.get('page', 1))

if (
page_number < 1 or
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:
raise http.Http404('Page without products does not exist.')

paginated = PaginatorLinks(
page_number,
self.request.path,
Paginator(self.products, products_on_page)
)
paginated_page = paginated.page()

return {
**context,
'products_data': prepare_tile_products(products),
'total_products': total_products,
'products_count': (page_number - 1) * products_on_page + products.count(),
'paginated': paginated,
'paginated_page': paginated_page,
'sorting_options': settings.CATEGORY_SORTING_OPTIONS.values(),
'limits': settings.CATEGORY_STEP_MULTIPLIERS,
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ def get_uuid_name_pair(
'tags_data': tags_data,
}


tag_file = XmlFile(
fetch_callback=fetch_tags,
xml_path_pattern='**/webdata/**/properties/**/import*.xml',
Expand Down
Loading

5 comments on commit 9399dd6

@0pdd
Copy link
Collaborator

@0pdd 0pdd commented on 9399dd6 Sep 7, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Puzzle 539-e6f6dde5 disappeared from shopelectro/views/catalog.py, that's why I closed #556. Please, remember that the puzzle was not necessarily removed in this particular commit. Maybe it happened earlier, but we discovered this fact only now.

@0pdd
Copy link
Collaborator

@0pdd 0pdd commented on 9399dd6 Sep 7, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Puzzle 550-87015712 discovered in shopelectro/context.py and submitted as #566. Please, remember that the puzzle was not necessarily added in this particular commit. Maybe it was added earlier, but we discovered it only now.

@0pdd
Copy link
Collaborator

@0pdd 0pdd commented on 9399dd6 Sep 7, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Puzzle 550-c08f412e discovered in shopelectro/context.py and submitted as #567. Please, remember that the puzzle was not necessarily added in this particular commit. Maybe it was added earlier, but we discovered it only now.

@0pdd
Copy link
Collaborator

@0pdd 0pdd commented on 9399dd6 Sep 7, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Puzzle 550-6e482196 discovered in shopelectro/context.py and submitted as #568. Please, remember that the puzzle was not necessarily added in this particular commit. Maybe it was added earlier, but we discovered it only now.

@0pdd
Copy link
Collaborator

@0pdd 0pdd commented on 9399dd6 Sep 7, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Puzzle 550-035b850e discovered in shopelectro/context.py and submitted as #569. Please, remember that the puzzle was not necessarily added in this particular commit. Maybe it was added earlier, but we discovered it only now.

Please sign in to comment.