kopia lustrzana https://github.com/longclawshop/longclaw
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 productspull/91/head
rodzic
65f4c358cc
commit
fd6cd2eab6
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
),
|
||||
]
|
|
@ -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
|
||||
|
|
|
@ -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__"
|
||||
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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'
|
||||
}
|
||||
|
|
|
@ -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')
|
||||
]
|
||||
|
|
|
@ -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',
|
||||
),
|
||||
]
|
|
@ -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,
|
||||
),
|
||||
]
|
|
@ -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'),
|
||||
),
|
||||
]
|
|
@ -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()
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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
|
||||
|
|
1
setup.py
1
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',
|
||||
|
|
Ładowanie…
Reference in New Issue