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
pull/91/head
James Ramm 2017-09-17 14:01:12 +02:00 zatwierdzone przez GitHub
rodzic 65f4c358cc
commit fd6cd2eab6
17 zmienionych plików z 320 dodań i 88 usunięć

Wyświetl plik

@ -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 <http://docs.wagtail.io/en/v1.8.1/topics/writing_templates.html>`_ for further details.
Basic example templates are provided in ``your_project/templates/longclawproducts/`` when creating a project
with the longclaw project template.

Wyświetl plik

@ -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.

Wyświetl plik

@ -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

Wyświetl plik

@ -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',
),
]

Wyświetl plik

@ -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

Wyświetl plik

@ -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__"

Wyświetl plik

@ -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)

Wyświetl plik

@ -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'
}

Wyświetl plik

@ -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')
]

Wyświetl plik

@ -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',
),
]

Wyświetl plik

@ -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,
),
]

Wyświetl plik

@ -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'),
),
]

Wyświetl plik

@ -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()

Wyświetl plik

@ -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')

Wyświetl plik

@ -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

Wyświetl plik

@ -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',