From fd6cd2eab61878da155ad86df9c0f70c7edea7df Mon Sep 17 00:00:00 2001 From: James Ramm Date: Sun, 17 Sep 2017 14:01:12 +0200 Subject: [PATCH] Reworking of Products (#86) * Fixes #76 and fixes #47 by removing concrete product models from longclaw. Abstract classes are provided as a starting point and concrete models moved to the project template * Products fully customisable (and therefore product index). Work on #76 * Removes concrete product models from longclaw, allowing customisation. For #76 and #47 * Product base testing * Reorder imports for tests * Drop py3.3 * Add back admin.py * Update page panels * Add tests to products --- docs/usage/products.rst | 18 ++++- docs/walkthrough/products.rst | 40 +++++++--- longclaw/longclawbasket/models.py | 2 +- longclaw/longclawproducts/admin.py | 0 .../migrations/0004_auto_20170903_1132.py | 31 ++++++++ longclaw/longclawproducts/models.py | 75 ++++++------------- longclaw/longclawproducts/serializers.py | 5 +- longclaw/longclawproducts/tests.py | 33 +++++++- longclaw/longclawstats/wagtail_hooks.py | 11 ++- longclaw/project_template/products/models.py | 54 ++++++++++++- .../0002_remove_productvariant_product.py | 19 +++++ .../migrations/0003_productvariant_product.py | 24 ++++++ .../migrations/0004_auto_20170903_1132.py | 49 ++++++++++++ longclaw/tests/products/models.py | 31 ++++++-- longclaw/tests/utils.py | 7 +- longclaw/utils.py | 8 ++ setup.py | 1 - 17 files changed, 320 insertions(+), 88 deletions(-) create mode 100644 longclaw/longclawproducts/admin.py create mode 100644 longclaw/longclawproducts/migrations/0004_auto_20170903_1132.py create mode 100644 longclaw/tests/products/migrations/0002_remove_productvariant_product.py create mode 100644 longclaw/tests/products/migrations/0003_productvariant_product.py create mode 100644 longclaw/tests/products/migrations/0004_auto_20170903_1132.py diff --git a/docs/usage/products.rst b/docs/usage/products.rst index 4a7190e..de51865 100644 --- a/docs/usage/products.rst +++ b/docs/usage/products.rst @@ -3,16 +3,28 @@ Adding Products =============== -Your new longclaw project has ``products`` app installed with a ``ProductVariant`` model. +Your new longclaw project has ``products`` app installed with ``ProductVariant``, ``Product`` and ``ProductIndex`` models. You should add your own custom fields to ``ProductVariant`` to meet the demands of your catalogue. -A ``ProductVariant`` is a child of the longclaw ``Product`` model and is used to represent variants of a single product. +A ``ProductVariant`` is a child of the ``Product`` model and is used to represent variants of a single product. E.g different sizes, colours etc. +``Product`` and ``ProductIndex`` are not required by longclaw, although this way of modelling your catalogue means that: + +- Your models fit into Wagtail way of creating ``Page`` models. Here, ``Product`` is your ``Page``, with ``ProductVariant`` being an + inline model. ``ProductIndex`` is the index page for listing all ``Products``. + +- It is easy with this setup to model fairly simple catalogues where each product has multiple options. E.g. a music shop selling + CD and vinyl versions of each product. + +Other examples might include having multiple ``ProductIndex`` models to represent different catalogues - e.g. clothing lines +or categories in a large shop. +You may also wish to create of supporting models for images, categories, tags etc. This is all up to you. + Writing the templates ----------------------- -Since ``ProductIndex`` and ``Product`` are Wagtail pages, HTML templates should be created for each. +Since ``ProductIndex`` and ``Product`` are Wagtail pages, HTML templates should be created for each. The developer should refer to the `Wagtail documentation `_ for further details. Basic example templates are provided in ``your_project/templates/longclawproducts/`` when creating a project with the longclaw project template. diff --git a/docs/walkthrough/products.rst b/docs/walkthrough/products.rst index a9698f9..b895ba1 100644 --- a/docs/walkthrough/products.rst +++ b/docs/walkthrough/products.rst @@ -1,7 +1,28 @@ .. _tutorial_products: -Managing the Catalogue -====================== +Modelling Your Catalogue +======================== + +Longclaw makes as few assumptions as possible when it comes to modelling your products, since the +requirements of different shops can be wide and varied. + +It is required that you create a ``ProductVariant`` model (it can be called anything) and implement +a small number of fields Longclaw expects. +The easiest way to do this is by inheriting from ``longclaw.longclawproducts.ProductVariantBase``. +Longclaws' project template will have setup a ``products`` app for you, with a ``ProductVariant`` model. + +You will also notice that the settings file has ``PRODUCT_VARIANT_MODEL`` set to the ``ProductVariant`` model. + +The project template has also created a ``ProductIndex`` and ``Product`` model. +``Product`` is a regular Wagtail ``Page`` which ``ProductVariant`` is an line model of. + +.. note:: + + This is just one way of modelling yor catalogue but you are not bound to it. ``ProductVariant`` is the only model + required by Longclaw and precisely what this represents is up to your (e.g. it could be the product itself, or, as the name + suggests, a variant of a product). You could create multiple 'index' pages, perhaps representing different lines + aswell as multiple 'product' type pages, or do away with ``Product`` completely. + Creating the Product Index -------------------------- @@ -36,14 +57,15 @@ Under the explorer homepage, we should now see our newly created ``ProductIndex` .. figure:: ../_static/images/product.png -Customising Variants --------------------- +Customising +------------ +As mentioned above, we can customise the ``ProductIndex`` and ``Product`` model completely - they +are not strict requirements for longclaw to operate. We reccomend using something similar to +the project layout so that ``Product``'s will appear in the Wagtail admin page explorer. -The ``ProductVariant`` model is where we can customise the attributes of our model. Running ``longclaw start`` -provided a ``products`` with a minimal implementation of a custom ``ProductVariant`` model. -We can further customise this now by opening ``my_shop/products/models.py`` in a text editor. - -``ProductVariant`` inherits from ``ProductVariantBase`` which provides the ``price``, ``ref`` and ``slug`` fields. +``ProductVariant`` can be customised, as long as we inherit from ``ProductVariantBase`` in order to ensure +the fields longclaw expects are present. +``ProductVariantBase`` provides the ``price``, ``ref`` and ``slug`` fields. The ``ref`` field is intended to be used as a short description or sub-title to help distinguish a particular variant. The ``slug`` field is autogenerated from the ``ref`` and the parent ``Product`` title. diff --git a/longclaw/longclawbasket/models.py b/longclaw/longclawbasket/models.py index 753a6bc..8bf5488 100644 --- a/longclaw/longclawbasket/models.py +++ b/longclaw/longclawbasket/models.py @@ -20,7 +20,7 @@ class BasketItem(models.Model): return self.quantity * self.variant.price def name(self): - return "{} ({})".format(self.variant.product.title, self.variant.ref) + return self.variant.__str__() def price(self): return self.variant.price diff --git a/longclaw/longclawproducts/admin.py b/longclaw/longclawproducts/admin.py new file mode 100644 index 0000000..e69de29 diff --git a/longclaw/longclawproducts/migrations/0004_auto_20170903_1132.py b/longclaw/longclawproducts/migrations/0004_auto_20170903_1132.py new file mode 100644 index 0000000..74d079b --- /dev/null +++ b/longclaw/longclawproducts/migrations/0004_auto_20170903_1132.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2017-09-03 16:32 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('wagtailcore', '0032_add_bulk_delete_page_permission'), + ('wagtailforms', '0003_capitalizeverbose'), + ('products', '0004_auto_20170903_1132'), + ('longclawproducts', '0002_auto_20170219_0804'), + ('wagtailredirects', '0005_capitalizeverbose'), + ] + + operations = [ + migrations.DeleteModel( + name='Product', + ), + migrations.DeleteModel( + name='ProductImage', + ), + migrations.DeleteModel( + name='ProductIndex', + ), + migrations.DeleteModel( + name='ProductTag', + ), + ] diff --git a/longclaw/longclawproducts/models.py b/longclaw/longclawproducts/models.py index 593d3cf..984b8c1 100644 --- a/longclaw/longclawproducts/models.py +++ b/longclaw/longclawproducts/models.py @@ -1,44 +1,21 @@ from django.db import models from django.utils.encoding import python_2_unicode_compatible -from django_extensions.db.fields import AutoSlugField -from modelcluster.fields import ParentalKey -from modelcluster.tags import ClusterTaggableManager -from taggit.models import TaggedItemBase +from wagtail.wagtailcore.models import Page -from wagtail.wagtailcore.models import Page, Orderable -from wagtail.wagtailcore.fields import RichTextField -from wagtail.wagtailadmin.edit_handlers import FieldPanel, InlinePanel -from wagtail.wagtailimages.edit_handlers import ImageChooserPanel -from wagtail.wagtailsearch import index +# Abstract base classes a user can use to implement their own product system +@python_2_unicode_compatible +class ProductBase(Page): + '''Base classes for ``Product`` implementations. All this provides are + a few helper methods for ``ProductVariant``'s. It assumes that ``ProductVariant``'s + have a ``related_name`` of ``variants`` + ''' -class ProductIndex(Page): - subpage_types = ('longclawproducts.Product', 'longclawproducts.ProductIndex') + class Meta: + abstract = True -class ProductTag(TaggedItemBase): - content_object = ParentalKey('Product', related_name='tagged_items') - -class Product(Page): - parent_page_types = ['longclawproducts.ProductIndex'] - description = RichTextField() - tags = ClusterTaggableManager(through=ProductTag, blank=True) - - search_fields = Page.search_fields + [ - index.RelatedFields('tags', [ - index.SearchField('name', partial_match=True, boost=10), - ]), - ] - - content_panels = Page.content_panels + [ - FieldPanel('description'), - InlinePanel('variants', label='Product variants'), - InlinePanel('images', label='Product images'), - FieldPanel('tags'), - ] - - @property - def first_image(self): - return self.images.first() + def __str__(self): + return self.title @property def price_range(self): @@ -56,35 +33,27 @@ class Product(Page): ''' return any(self.variants.filter(stock__gt=0)) + @python_2_unicode_compatible class ProductVariantBase(models.Model): """ Base model for creating product variants """ - product = ParentalKey(Product, related_name='variants') price = models.DecimalField(max_digits=12, decimal_places=2) ref = models.CharField(max_length=32) stock = models.IntegerField(default=0) - slug = AutoSlugField( - separator='', - populate_from=('product', 'ref'), - ) + class Meta: abstract = True def __str__(self): - return "{} - {}".format(self.product.title, self.ref) + try: + return "{} - {}".format(self.product.title, self.ref) + except AttributeError: + return self.ref def get_product_title(self): - return self.product.title - -class ProductImage(Orderable): - - product = ParentalKey(Product, related_name='images') - image = models.ForeignKey('wagtailimages.Image', on_delete=models.CASCADE, related_name='+') - caption = models.CharField(blank=True, max_length=255) - - panels = [ - ImageChooserPanel('image'), - FieldPanel('caption') - ] + try: + return self.product.title + except AttributeError: + return self.ref diff --git a/longclaw/longclawproducts/serializers.py b/longclaw/longclawproducts/serializers.py index 5a86cfc..1558753 100644 --- a/longclaw/longclawproducts/serializers.py +++ b/longclaw/longclawproducts/serializers.py @@ -1,11 +1,10 @@ from rest_framework import serializers -from longclaw.longclawproducts.models import Product -from longclaw.utils import ProductVariant +from longclaw.utils import ProductVariant, maybe_get_product_model class ProductSerializer(serializers.ModelSerializer): class Meta: - model = Product + model = maybe_get_product_model() fields = "__all__" diff --git a/longclaw/longclawproducts/tests.py b/longclaw/longclawproducts/tests.py index 27869a2..4655b33 100644 --- a/longclaw/longclawproducts/tests.py +++ b/longclaw/longclawproducts/tests.py @@ -1,12 +1,39 @@ -from longclaw.longclawproducts import models from wagtail.tests.utils import WagtailPageTests +from longclaw.utils import maybe_get_product_model +from longclaw.tests.products.models import ProductIndex +from longclaw.tests.utils import ProductVariantFactory +from longclaw.longclawproducts.serializers import ProductVariantSerializer class TestProducts(WagtailPageTests): + def setUp(self): + self.product_model = maybe_get_product_model() + def test_can_create_product(self): - self.assertCanCreateAt(models.ProductIndex, models.Product) + self.assertCanCreateAt(ProductIndex, self.product_model) def test_variant_price(self): - product = models.Product(title="test", description="test") + variant = ProductVariantFactory() + self.assertTrue(variant.price > 0) + def test_price_range(self): + variant = ProductVariantFactory() + prices = variant.product.price_range + self.assertTrue(prices[0] == prices[1]) + def test_stock(self): + variant = ProductVariantFactory() + variant.stock = 1 + variant.save() + self.assertTrue(variant.product.in_stock) + + def test_out_of_stock(self): + variant = ProductVariantFactory() + variant.stock = 0 + variant.save() + self.assertFalse(variant.product.in_stock) + + def test_variant_serializer(self): + variant = ProductVariantFactory() + serializer = ProductVariantSerializer(variant) + self.assertIn('product', serializer.data) diff --git a/longclaw/longclawstats/wagtail_hooks.py b/longclaw/longclawstats/wagtail_hooks.py index bf1d964..6bff687 100644 --- a/longclaw/longclawstats/wagtail_hooks.py +++ b/longclaw/longclawstats/wagtail_hooks.py @@ -2,9 +2,9 @@ import datetime from wagtail.wagtailcore import hooks from wagtail.wagtailadmin.site_summary import SummaryItem from longclaw.longclaworders.models import Order -from longclaw.longclawproducts.models import Product from longclaw.longclawstats import stats from longclaw.longclawsettings.models import LongclawSettings +from longclaw.utils import ProductVariant, maybe_get_product_model class LongclawSummaryItem(SummaryItem): @@ -33,9 +33,14 @@ class OutstandingOrders(LongclawSummaryItem): class ProductCount(LongclawSummaryItem): order = 20 def get_context(self): + product_model = maybe_get_product_model() + if product_model: + count = product_model.objects.all().count() + else: + count = ProductVariant.objects.all().count() return { - 'total': Product.objects.all().count(), - 'text': 'Products', + 'total': count, + 'text': 'Product', 'url': '', 'icon': 'icon-list-ul' } diff --git a/longclaw/project_template/products/models.py b/longclaw/project_template/products/models.py index 1dd5d84..059680a 100644 --- a/longclaw/project_template/products/models.py +++ b/longclaw/project_template/products/models.py @@ -1,10 +1,58 @@ +from django.db import models +from django_extensions.db.fields import AutoSlugField +from modelcluster.fields import ParentalKey +from wagtail.wagtailcore.models import Page, Orderable from wagtail.wagtailcore.fields import RichTextField -from longclaw.longclawproducts.models import ProductVariantBase +from wagtail.wagtailadmin.edit_handlers import FieldPanel, InlinePanel +from wagtail.wagtailimages.edit_handlers import ImageChooserPanel +from longclaw.longclawproducts.models import ProductVariantBase, ProductBase + +class ProductIndex(Page): + """Index page for all products + """ + subpage_types = ('products.Product', 'products.ProductIndex') + + +class Product(ProductBase): + parent_page_types = ['products.ProductIndex'] + description = RichTextField() + content_panels = ProductBase.content_panels + [ + FieldPanel('description'), + InlinePanel('variants', label='Product variants'), + + ] + + @property + def first_image(self): + return self.images.first() + class ProductVariant(ProductVariantBase): + """Represents a 'variant' of a product + """ + # You *could* do away with the 'Product' concept entirely - e.g. if you only + # want to support 1 'variant' per 'product'. + product = ParentalKey(Product, related_name='variants') + + slug = AutoSlugField( + separator='', + populate_from=('product', 'ref'), + ) # Enter your custom product variant fields here # e.g. colour, size, stock and so on. - # Remember, ProductVariantBase provides 'price', 'ref', 'slug' fields - # and the parental key to the Product model. + # Remember, ProductVariantBase provides 'price', 'ref' and 'stock' fields description = RichTextField() + + +class ProductImage(Orderable): + """Example of adding images related to a product model + """ + product = ParentalKey(Product, related_name='images') + image = models.ForeignKey('wagtailimages.Image', on_delete=models.CASCADE, related_name='+') + caption = models.CharField(blank=True, max_length=255) + + panels = [ + ImageChooserPanel('image'), + FieldPanel('caption') + ] diff --git a/longclaw/tests/products/migrations/0002_remove_productvariant_product.py b/longclaw/tests/products/migrations/0002_remove_productvariant_product.py new file mode 100644 index 0000000..6a38f03 --- /dev/null +++ b/longclaw/tests/products/migrations/0002_remove_productvariant_product.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2017-08-29 16:48 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='productvariant', + name='product', + ), + ] diff --git a/longclaw/tests/products/migrations/0003_productvariant_product.py b/longclaw/tests/products/migrations/0003_productvariant_product.py new file mode 100644 index 0000000..b090f73 --- /dev/null +++ b/longclaw/tests/products/migrations/0003_productvariant_product.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2017-08-29 16:57 +from __future__ import unicode_literals + +from django.db import migrations +import django.db.models.deletion +import modelcluster.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('longclawproducts', '0002_auto_20170219_0804'), + ('products', '0002_remove_productvariant_product'), + ] + + operations = [ + migrations.AddField( + model_name='productvariant', + name='product', + field=modelcluster.fields.ParentalKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='variants', to='longclawproducts.Product'), + preserve_default=False, + ), + ] diff --git a/longclaw/tests/products/migrations/0004_auto_20170903_1132.py b/longclaw/tests/products/migrations/0004_auto_20170903_1132.py new file mode 100644 index 0000000..218e410 --- /dev/null +++ b/longclaw/tests/products/migrations/0004_auto_20170903_1132.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2017-09-03 16:32 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +import modelcluster.fields +import wagtail.wagtailcore.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('wagtailcore', '0032_add_bulk_delete_page_permission'), + ('products', '0003_productvariant_product'), + ] + + operations = [ + migrations.CreateModel( + name='Product', + fields=[ + ('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.Page')), + ('description', wagtail.wagtailcore.fields.RichTextField()), + ], + options={ + 'abstract': False, + }, + bases=('wagtailcore.page',), + ), + migrations.CreateModel( + name='ProductIndex', + fields=[ + ('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.Page')), + ], + options={ + 'abstract': False, + }, + bases=('wagtailcore.page',), + ), + migrations.RemoveField( + model_name='productvariant', + name='slug', + ), + migrations.AlterField( + model_name='productvariant', + name='product', + field=modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='variants', to='products.Product'), + ), + ] diff --git a/longclaw/tests/products/models.py b/longclaw/tests/products/models.py index 17a082c..621a7ac 100644 --- a/longclaw/tests/products/models.py +++ b/longclaw/tests/products/models.py @@ -1,11 +1,30 @@ from django.db import models +from modelcluster.fields import ParentalKey +from wagtail.wagtailcore.models import Page from wagtail.wagtailcore.fields import RichTextField -from longclaw.longclawproducts.models import ProductVariantBase +from wagtail.wagtailadmin.edit_handlers import FieldPanel, InlinePanel +from longclaw.longclawproducts.models import ProductVariantBase, ProductBase + +class ProductIndex(Page): + '''Index page for all products + ''' + subpage_types = ('products.Product', 'products.ProductIndex') + + +class Product(ProductBase): + parent_page_types = ['products.ProductIndex'] + description = RichTextField() + content_panels = ProductBase.content_panels + [ + FieldPanel('description'), + ] + + + @property + def first_image(self): + return self.images.first() class ProductVariant(ProductVariantBase): - - # Enter your custom product variant fields here - # e.g. colour, size, stock and so on. - # Remember, ProductVariantBase provides 'price', 'ref', 'slug' fields - # and the parental key to the Product model. + '''Basic product variant for testing + ''' + product = ParentalKey(Product, related_name='variants') description = RichTextField() diff --git a/longclaw/tests/utils.py b/longclaw/tests/utils.py index c283edc..53aa969 100644 --- a/longclaw/tests/utils.py +++ b/longclaw/tests/utils.py @@ -6,11 +6,11 @@ from rest_framework import status from wagtail_factories import PageFactory -from longclaw.longclawproducts.models import Product + from longclaw.longclawbasket.models import BasketItem from longclaw.longclaworders.models import Order from longclaw.longclawshipping.models import Address, Country, ShippingRate -from longclaw.utils import ProductVariant +from longclaw.utils import ProductVariant, maybe_get_product_model class OrderFactory(factory.django.DjangoModelFactory): class Meta: @@ -60,8 +60,9 @@ class ShippingRateFactory(factory.django.DjangoModelFactory): class ProductFactory(PageFactory): ''' Create a random Product ''' + class Meta: - model = Product + model = maybe_get_product_model() title = factory.Faker('sentence', nb_words=1) description = factory.Faker('text') diff --git a/longclaw/utils.py b/longclaw/utils.py index abf9760..b063caa 100644 --- a/longclaw/utils.py +++ b/longclaw/utils.py @@ -2,3 +2,11 @@ from django.apps import apps from longclaw.settings import PRODUCT_VARIANT_MODEL ProductVariant = apps.get_model(*PRODUCT_VARIANT_MODEL.split('.')) + + +def maybe_get_product_model(): + try: + field = ProductVariant._meta.get_field('product') + return field.rel.to + except: + pass diff --git a/setup.py b/setup.py index 9f6f337..ed2e336 100755 --- a/setup.py +++ b/setup.py @@ -87,7 +87,6 @@ setup( ], include_package_data=True, install_requires=[ - 'django>=1.8,<1.11', 'wagtail>=1.7', 'django-countries>=4.3', 'django-extensions>=1.7.5',