From dde62565f22a501564b7f742b8236936393e8c76 Mon Sep 17 00:00:00 2001 From: duker33 Date: Wed, 26 Jun 2019 10:47:47 +0300 Subject: [PATCH] #669 Create sections (#700) * #558 Rm redundant pdd subtask * #669 Draft for a Section model code * #669 Handle code duplication with PDD subtask * #669 Create sections migrations * #584 Rm closed manually pdd subtask * #669 Create section page with test * #669 Apply linter rules * #669 Create subtasks for section feature * #699 Apply linter rules * #699 Review#1 fixes. Section: get_min_price, rm slug, tests subtask * #699 Review#1 fixes. Rm search, fix tests setUp, subtask seo_texts exploring --- .../management/commands/seo_texts.py | 4 +- .../migrations/0026_create_section.py | 47 +++++++ stroyprombeton/models.py | 95 +++++++++++-- stroyprombeton/tests/tests_views.py | 127 ++++++++++++++---- stroyprombeton/urls.py | 2 + stroyprombeton/views/catalog.py | 21 +++ templates/catalog/section.html | 44 ++++++ templates/catalog/series.html | 14 -- 8 files changed, 305 insertions(+), 49 deletions(-) create mode 100644 stroyprombeton/migrations/0026_create_section.py create mode 100644 templates/catalog/section.html diff --git a/stroyprombeton/management/commands/seo_texts.py b/stroyprombeton/management/commands/seo_texts.py index 0b20a427..e604ae66 100644 --- a/stroyprombeton/management/commands/seo_texts.py +++ b/stroyprombeton/management/commands/seo_texts.py @@ -1,3 +1,6 @@ +# @todo #669:30m Explore seo_texts command. +# Either document or remove it. + from functools import reduce from operator import or_ @@ -7,7 +10,6 @@ from stroyprombeton.models import ProductPage - product_page = { 'content': ''' Производим, продаем и доставляем по России {} {}. diff --git a/stroyprombeton/migrations/0026_create_section.py b/stroyprombeton/migrations/0026_create_section.py new file mode 100644 index 00000000..73bb6409 --- /dev/null +++ b/stroyprombeton/migrations/0026_create_section.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.20 on 2019-06-24 05:56 +from __future__ import unicode_literals + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pages', '0018_page_template_increase_name_size'), + ('stroyprombeton', '0025_series_slug'), + ] + + operations = [ + migrations.CreateModel( + name='Section', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(db_index=True, max_length=1000, unique=True, verbose_name='name')), + ('page', models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='stroyprombeton_section', to='pages.Page')), + ], + options={ + 'verbose_name': 'Section', + 'verbose_name_plural': 'Sections', + }, + ), + migrations.CreateModel( + name='SeriesPage', + fields=[ + ], + options={ + 'verbose_name': 'serie', + 'verbose_name_plural': 'series', + 'abstract': False, + 'proxy': True, + 'indexes': [], + }, + bases=('pages.modelpage',), + ), + migrations.AddField( + model_name='product', + name='section', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='products', to='stroyprombeton.Section', verbose_name='section'), + ), + ] diff --git a/stroyprombeton/models.py b/stroyprombeton/models.py index 0b9d7f9a..d8789fc9 100644 --- a/stroyprombeton/models.py +++ b/stroyprombeton/models.py @@ -105,7 +105,7 @@ class SeriesQuerySet(models.QuerySet): def bind_fields(self): """Prefetch or select typical related fields to reduce sql queries count.""" - return self.select_related('page') + return self.select_related('page') # Ignore CPDBear def active(self) -> 'SeriesQuerySet': return self.filter(page__is_active=True) @@ -125,9 +125,6 @@ def active(self): return self.get_queryset().active() -# @todo #510:60m Create Series navigation. -# See the parent's task body for details about navigation. -# This task depends on Series page creation. class Series(pages.models.PageMixin): """ Series is another way to organize products. @@ -141,10 +138,8 @@ class Series(pages.models.PageMixin): SLUG_MAX_LENGTH = 50 class Meta: - # @todo #510:15m Translate "Series" term. - # And "series" too. It's used in the relation with Product. verbose_name = _('Series') - verbose_name_plural = _('Series') + verbose_name_plural = _('Series') # Ignore CPDBear name = models.CharField( max_length=1000, db_index=True, unique=True, verbose_name=_('name') @@ -156,8 +151,10 @@ def url(self): def get_absolute_url(self): """Url path to the related page.""" - return reverse('series', args=(self.slug,)) + return reverse('series', args=(self.slug,)) # Ignore CPDBear + # @todo #669:60m Remove `Series.slug` field. + # Series page already contains it. slug = models.SlugField( blank=False, unique=True, max_length=SLUG_MAX_LENGTH, ) @@ -191,6 +188,80 @@ def save(self, *args, **kwargs): super().save(*args, **kwargs) +class SectionQuerySet(models.QuerySet): + + def bind_fields(self): + """Prefetch or select typical related fields to reduce sql queries count.""" + return ( + self.select_related('page') + .prefetch_related('products') + ) + + def active(self) -> 'SeriesQuerySet': + return self.filter(page__is_active=True) + + def exclude_empty(self) -> 'SeriesQuerySet': + return ( + self.active() + .filter(products__page__is_active=True) + .distinct() + ) + + +class SectionManager(models.Manager.from_queryset(SeriesQuerySet)): + """Get all products of given category by Category's id or instance.""" + + def active(self): + return self.get_queryset().active() + + +# @todo #669:30m Add Sections to the admin panel. +# @todo #669:30m Get rid of Category-Series-Section models code doubling. +class Section(pages.models.PageMixin): + """ + Section is one more way to organize products. + + It's like Category, but has no hierarchy. + And it's like Series, but it's bound to products instead of options. + """ + + # @todo #669:30m Doc section concept. + # What problem it solves, who required it. + # Why we solved problem in this way. + + objects = SeriesManager() + + SLUG_HASH_SIZE = 5 + SLUG_MAX_LENGTH = 50 + + class Meta: + # @todo #510:15m Translate "Section" term. + # And "series" too. It's used in the relation with Product. + verbose_name = _('Section') + verbose_name_plural = _('Sections') + + name = models.CharField( + max_length=1000, db_index=True, unique=True, verbose_name=_('name') + ) + + @property + def url(self): + return self.get_absolute_url() + + def get_absolute_url(self): + """Url path to the related page.""" + return reverse('section', args=(self.page.slug,)) + + # @todo #669:60m Implement `Section.get_min_price` method. + # And look at the get_min_price arch in a whole. + # Maybe only ProductsQS and OptionsQS should have min_price, + # but Section and Series should not. + # Category, Series and Product page templates at the production DB + # contains series min price usage. + def get_min_price(self) -> float: + raise NotImplemented() + + class OptionQuerySet(models.QuerySet): def active(self): @@ -328,6 +399,14 @@ class Product(catalog.models.AbstractProduct, pages.models.PageMixin): verbose_name=_('category'), ) + section = models.ForeignKey( + Section, + on_delete=models.CASCADE, + related_name='products', + verbose_name=_('section'), + null=True, + ) + def __str__(self): return self.name diff --git a/stroyprombeton/tests/tests_views.py b/stroyprombeton/tests/tests_views.py index bcf8c751..ece805b1 100644 --- a/stroyprombeton/tests/tests_views.py +++ b/stroyprombeton/tests/tests_views.py @@ -1016,30 +1016,6 @@ def get_series_soup(self, *args, **kwargs) -> BeautifulSoup: 'html.parser' ) - # @todo #570:60m Implement search on series. - # And resurrect the test below. - @unittest.skip - def test_fetch_positions_searching(self): - term = '#1' - options = self.series.options - searched = context.search( - term, options, context.SearchedOptions.LOOKUPS, - ordering=('product__name', ) - ) - - response = self.client.post( - reverse('fetch_products'), - data={ - 'seriesId': self.series.id, - 'filtered': 'true', - 'term': term, - 'offset': 0, - 'limit': 10**6, - } - ) - self.assertEqual(searched.count(), len(response.context['products'])) - self.assertEqual(list(searched), list(response.context['products'])) - def test_options_are_from_series(self): response = self.client.get(self.get_series_url(self.series)) self.assertTrue( @@ -1049,7 +1025,7 @@ def test_options_are_from_series(self): def test_active_options(self): """Series page should contain only options with active related products.""" options_qs = ( - self.series.options + self.series.options # Ignore CPDBear .bind_fields() .order_by(*settings.OPTIONS_ORDERING) ) @@ -1079,7 +1055,7 @@ def test_product_images(self): product = models.Product.objects.get(id=PRODUCT_WITH_IMAGE) series = models.Series.objects.first() product.options.update(series=series) - response = self.client.get(series.url) + response = self.client.get(series.url) # Ignore CPDBear image = response.context['product_images'][110] self.assertTrue(image) self.assertTrue(image.image.url) @@ -1087,6 +1063,105 @@ def test_product_images(self): def test_product_image_button(self): """Series page should contain button to open existing product image.""" # product with image + product = models.Product.objects.get(id=PRODUCT_WITH_IMAGE) # Ignore CPDBear + response = self.client.get(product.category.url) + self.assertContains(response, 'table-photo-ico') + + +# will be removed during this Section class implementing. +@tag('fast', 'catalog') +class SectionFirst(BaseCatalogTestCase): + fixtures = ['dump.json'] + + def test_page_success(self): + section = models.Section.objects.create( + name='First section', + page=ModelPage.objects.create( + name='First section', + slug='first', + ), + ) + models.Product.objects.active().update(section=section) + response = self.client.get(section.url) + self.assertEqual(200, response.status_code) + + +# @todo #669:120m Create tests for Section. +# Use Section tests draft below. Rm current SectionFirst draft. +# Create fixtures with sections. +@unittest.skip +@tag('fast', 'catalog') +class Section(BaseCatalogTestCase): + fixtures = ['dump.json'] + + def setUp(self): + self.section = models.Section.objects.filter(products__isnull=False).first() + + def get_section_url( + self, + section: models.Section = None, + ): + section = section or self.section + return section.url + + def get_section_page(self, *args, **kwargs): + return self.client.get(self.get_section_url(*args, **kwargs)) # Ignore CPDBear + + def get_section_soup(self, *args, **kwargs) -> BeautifulSoup: + section_page = self.get_section_page(*args, **kwargs) + return BeautifulSoup( + section_page.content.decode('utf-8'), + 'html.parser' + ) + + def test_options_are_from_section(self): + response = self.client.get(self.get_section_url(self.section)) + self.assertTrue( + all(option.section == self.section for option in response.context['products']) + ) + + def test_active_options(self): + """Section page should contain only options with active related products.""" + options_qs = ( + self.section.options + .bind_fields() + # @todo #699:60m Implement `OptionsQS.default_order` method. se2 + .order_by(*settings.OPTIONS_ORDERING) + ) + # make inactive the first option in a section page list + inactive = options_qs.first() + inactive.product.page.is_active = False + inactive.product.page.save() + active = options_qs.active().first() + + response = self.client.get(self.get_section_url(self.section)) + self.assertIn(active, response.context['products']) + self.assertNotIn(inactive, response.context['products']) + + def test_emtpy_404(self): + """Section with not active options should return response 404.""" + section = ( + models.Section.objects + .annotate(count=Count('options')) + .filter(count=0) + ).first() + response = self.get_section_page(section) + self.assertEqual(404, response.status_code) + + def test_product_images(self): + """Section page should contain only options with active related products.""" + # product with image + product = models.Product.objects.get(id=PRODUCT_WITH_IMAGE) + section = models.Section.objects.first() + product.options.update(section=section) + response = self.client.get(section.url) + image = response.context['product_images'][110] + self.assertTrue(image) + self.assertTrue(image.image.url) + + def test_product_image_button(self): + """Section page should contain button to open existing product image.""" + # product with image product = models.Product.objects.get(id=PRODUCT_WITH_IMAGE) response = self.client.get(product.category.url) self.assertContains(response, 'table-photo-ico') diff --git a/stroyprombeton/urls.py b/stroyprombeton/urls.py index 8009288e..0011be63 100644 --- a/stroyprombeton/urls.py +++ b/stroyprombeton/urls.py @@ -35,6 +35,8 @@ views.series, name='series'), url(r'^series/(?P[\w_-]+)/category/(?P[0-9]+)/$', views.series_by_category, name='series_by_category'), + url(r'^section/(?P[\w_-]+)/$', + views.section, name='section'), ] custom_pages = [ diff --git a/stroyprombeton/views/catalog.py b/stroyprombeton/views/catalog.py index 1d615094..6eee792c 100644 --- a/stroyprombeton/views/catalog.py +++ b/stroyprombeton/views/catalog.py @@ -264,6 +264,27 @@ def series(request, series_slug: str): ) +# @todo #669:120m Create sections navigation. +# Top menu link, matrix page, links from and to categories. +def section(request, section_slug: str): + section = get_object_or_404(models.Section.objects, page__slug=section_slug) + products = section.products.active() + images = context.products.ProductImages( + products, Image.objects.all() + ) + if not products: + raise http.Http404('

В секции нет изделий + {% if category %} +
+
+

+ Изделия отфильтрованы по секции + {{ category.name }}. +

+

+ Полный список изделий этой серии. +

+
+
+



+ {% endif %} + +
+
+
Не является публичной офертой.
+ + + + + + + + + + + + + {% include 'catalog/options.html' with products=products product_images=product_images only %} + +
ФотоКодНаименование / МаркаЦенаКол-во
+
+
+ +{% endblock %} diff --git a/templates/catalog/series.html b/templates/catalog/series.html index a5cec164..c967a9d1 100644 --- a/templates/catalog/series.html +++ b/templates/catalog/series.html @@ -5,20 +5,6 @@ {% include 'components/breadcrumbs.html' with page=page base_url=base_url only %} {% include 'components/page_h1.html' with page=page only %} - -
{% if category %}