Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP

Loading…

Support for custom Product models #57

Open
wants to merge 16 commits into from
@AlexHill
Collaborator

This is not intended to be merged immediately.

This adds support for subclassing Product, using the same strategy used in Mezzanine for subclassing Page:

  • A content_model field and corresponding method Product.get_content_model() are added.
  • ProductAdmin.change_view() is added which redirects to the subclass's change view.
  • The product view looks for a template called "shop/products/customproduct.html", where customproduct is the lower-cased name of the Product subclass.

Things that need doing:

  • tests
  • the names of content_model and get_content_model() are taken directly from Mezzanine and don't really make sense here. I think they should either be changed, or this functionality should be factored out into a mixin in Mezzanine core.
  • the template names and directory structure might warrant some attention
  • we should think about variations and how that's going to work

In the long term I'd like Category models to be able to be limited to a single Product subclass. That would allow templates, sort options, and custom page processors that are specific to a single Product type.

@travisbot

This pull request fails (merged 6ec055d into 635f267).

@travisbot

This pull request passes (merged 14d7866 into 635f267).

@joshcartme
Collaborator

I merged your changes into a fork of Cartridge of mine and I'm giving them a try. A few questions:

How is an admin user supposed to access the custom products since they are removed from the menus? If I type in the correct URL it works, but this doesn't show in any menus.

When accessing a custom product how do you access custom fields, or would a new admin class need to be created?

@AlexHill AlexHill closed this
@AlexHill AlexHill reopened this
@AlexHill
Collaborator

Hey Josh,

I've overridden change_view in ProductAdmin, so when you click on a subclassed product in /admin/shop/product/, you should be redirected to the custom product's change view. This works the same way as custom content types in Mezzanine.

As yet there's no support for automatic detection of custom fields in ProductAdmin - as you say you need to create a new admin class.

@travisbot

This pull request passes (merged 14d7866 into 635f267).

@AlexHill AlexHill commented on the diff
cartridge/shop/admin.py
@@ -227,6 +236,21 @@ def save_formset(self, request, form, formset, change):
# variation to the product.
self._product.copy_default_variation()
+ def change_view(self, request, object_id, extra_context=None):
+ """
+ As in Mezzanine's ``Page`` model, check ``product.get_content_model()``
+ for a subclass and redirect to its admin change view.
+ """
+ if self.model is Product:
+ product = get_object_or_404(Product, pk=object_id)
+ content_model = product.get_content_model()
+ if content_model is not None:
+ change_url = admin_url(content_model.__class__, "change",
+ content_model.id)
+ return HttpResponseRedirect(change_url)
@AlexHill Collaborator
AlexHill added a note

Here's where the redirection happens.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@travisbot

This pull request fails (merged 9e06c69 into 635f267).

@joshcartme
Collaborator

Thanks for the reply Alex, that bit of redirection works great!

I am wondering though, how would you recommend going about creating a new object that is a subclass of Product. Since the subclasses are removed from the menus I can't get to the screen to add a new subclass, unless I know and construct the URL myself. As far as I can tell there is no dropdown similar to pages (or other method) that allows you to pick the type of subclass you want.

Once I create an object that is of a type that is a subclass of Product it shows up under products and works great, it is just getting to that point that is proving a bit difficult.

Also I modified ProductAdmin so that it automatically pulls custom fields if custom fieldsets aren't specified (I took what PageAdmin does and modified it to work with products). You can see that here: https://bitbucket.org/joshcartme/cartridge_custom_product/changeset/6cc3adaf18b66cc982a31db6b8bfbd0c Feel free to pull it into your repo if you like it.
edit: just noticed that you already pulled in my ProductAdmin changes, I think I was typing when you committed it and didn't notice it in the thread =)

Sorry that it's on bitbucket, I haven't jumped on the Github train yet.

@travisbot

This pull request passes (merged 75004e2 into 635f267).

@AlexHill
Collaborator

Thanks for the __init__ code Josh. For now I'm happy to copy and paste as long as you are.

You're right, adding custom products is completely missing at the moment. The simple reason is that for the project I'm working on, I import everything automatically from another database.

Any ideas as to how we could fix this? I think a drop-down box next to the "add product" button on the product changelist page would work well, but what about on the dashboard?

@AlexHill
Collaborator

I also don't necessarily think that having all products lumped into one in the admin is the right way to go. It might be good to have this as a per-product-type option somehow.

@AlexHill
Collaborator

Latest commit adds a get_content_models class method and a models_for_products templatetag. We should be able to use those to insert a selection box on the product changelist page.

@joshcartme
Collaborator
@travisbot

This pull request fails (merged 4dc2df7 into 635f267).

@travisbot

This pull request fails (merged ccc3ed7 into 635f267).

@AlexHill
Collaborator

Hah - that's uncanny!

If we feel that this is a good way forward for Cartridge, I think it would be best if Steve pulled these changes into a branch on his repo. Then we can submit smaller pull requests to that branch from either Mercurial or Git.

@travisbot

This pull request passes (merged f62da50 into 635f267).

@joshcartme
Collaborator
@joshcartme
Collaborator

Here is a final commits that create the drop down which works like the one on the Page change_list.
https://bitbucket.org/joshcartme/cartridge_custom_product/changeset/9594fc8da885cab1b5b168852d11d2847a8d89f7

@joshcartme
Collaborator

I decided to give git a try. Here is a branch that has the custom change_list.html https://github.com/joshcartme/cartridge/compare/custom_product

@AlexHill
Collaborator

Cheers Josh. I took your change_list.html and tweaked it slightly so that the select box replaces the "Add Product" button.

@travisbot

This pull request passes (merged 53d1b0a into 635f267).

@electroniceagle

Hey guys, what is the status of this pull request? I'm in a refactoring mode (mood?) for the next few days for our own internal digital product subclasses and was thinking about using this instead. Are you using it actively in your sites? Cheers, Brian.

@stephenmcd
Owner

Hey Brian,

The intention is to merge it in - it's a big change so I need to go over it very thoroughly though, which I haven't had time to do.

If you'd like to run with it that'd at least go towards verifying that it works well.

@electroniceagle
@joshcartme
Collaborator
@joshcartme
Collaborator
@AlexHill
Collaborator
@electroniceagle
@electroniceagle

We're using this on our internal alpha site and it is working well. One thing I want to add is a ProductBase similar to PageBase that defines the DisplayableManager in an abstract class so it gets inherited. Otherwise, search breaks unless the user defines the manager manually. I'll try to post some code.

@lorin

We also have a different version that supports multiple levels of inheritance (if you're curious, see https://github.com/lorin/django-model-utils)

Unfortunately, the multi-level-inheritance solution doesn't work with the stock version of Django because of bug #16572. There's a proposed fix attached to that ticket that is working for us, but it would involve patching your local version of Django.

AlexHill added some commits
@AlexHill AlexHill Merge remote-tracking branch 'upstream/master' into custom_products 542689c
@AlexHill AlexHill Added abstract base class for Product.
Defining the manager on an abstract base class means subclasses will
inherit it instead of having to explicitly define their own.
2941f55
@AlexHill
Collaborator

@electroniceagle I've added an abstract BaseProduct mirroring Mezzanine's BasePage as you suggested.

@AlexHill
Collaborator

@joshcartme regarding getting the subclass instance into templates: I agree this would be nice, and all it would take is a call to get_product_model in the product view. That means an extra trip to the database. On the one hand it seems wrong to do that by default; on the other hand, a single PK lookup is very cheap. If the content_model approach is replaced by select_subclasses, that will work there too.

Also if your custom product has its own custom template, you can always use product.subclass in the template.

@electroniceagle

If you update mezzanine, you will come across this change to mezzanine jquery-ui suport. It has moved from 1.8.14 to 1.9.1.

diff --git a/cartridge/shop/templates/admin/shop/product/change_list.html b/cartridge/shop/templates/admin/shop/product/change_list.html
index f8b7adc..41e7e08 100644
--- a/cartridge/shop/templates/admin/shop/product/change_list.html
+++ b/cartridge/shop/templates/admin/shop/product/change_list.html
@@ -4,7 +4,8 @@
 {% block extrahead %}
 {{ block.super }}

-<script src="{{ STATIC_URL }}mezzanine/js/jquery-ui-1.8.14.custom.min.js"></script>
+<script src="{{ STATIC_URL }}mezzanine/js/jquery-ui-1.9.1.custom.min.js"></script>
+<script src="{{ STATIC_URL }}mezzanine/js/admin/jquery.mjs.nestedSortable.js"></script>
 <script src="{{ STATIC_URL }}mezzanine/js/admin/page_tree.js"></script>
 {% endblock %}

PS: sorry for the multiple edits if that cause multiple emails. I still think entering obscure varying markup standards like trying to memorize wordstar and epson printer codes. If you don't know what that is, ask your father :-)

@stepmr

Any updates on this?

@electroniceagle
@jaywink

Any idea if this is still intended to land in master at some point in the future? Now that Django 1.6 is supported by Cartridge.

@pwalsh

I'm interested in this. Have there been any more developments with the inheritance model approach + django 1.6?

@stephenmcd
Owner

No, nothing has really changed architecturally since this was done, so it should still apply at a high level - at a low level it looks like there are probably some code conflicts that need resolving still, given how long it's been.

@aleksey-zhigulin

Does ratings work with this feature?

@maxshilov

Hello,
any plans to merge this branch to mainstream?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Commits on Aug 30, 2012
  1. @AlexHill
  2. @AlexHill
  3. @AlexHill

    Add missing blank line.

    AlexHill authored
Commits on Sep 4, 2012
  1. @AlexHill

    ProductAdmin automatically adds custom Product fields.

    AlexHill authored
    Thanks to Josh Cartmell.
  2. @AlexHill
Commits on Sep 5, 2012
  1. @AlexHill
  2. @AlexHill
  3. @AlexHill

    pep8 fixes.

    AlexHill authored
  4. @AlexHill
Commits on Jan 10, 2013
  1. @AlexHill
  2. @AlexHill

    Added abstract base class for Product.

    AlexHill authored
    Defining the manager on an abstract base class means subclasses will
    inherit it instead of having to explicitly define their own.
  3. @AlexHill
Commits on Jan 28, 2013
  1. @AlexHill
Commits on Feb 4, 2013
  1. @AlexHill
Commits on Feb 17, 2013
  1. @AlexHill
  2. @AlexHill
This page is out of date. Refresh to see the latest.
View
60 cartridge/shop/admin.py
@@ -30,11 +30,14 @@
from django.contrib import admin
from django.db.models import ImageField
+from django.http import HttpResponseRedirect
+from django.shortcuts import get_object_or_404
from django.utils.translation import ugettext_lazy as _
from mezzanine.conf import settings
from mezzanine.core.admin import DisplayableAdmin, TabularDynamicInlineAdmin
from mezzanine.pages.admin import PageAdmin
+from mezzanine.utils.urls import admin_url
from cartridge.shop.fields import MoneyField
from cartridge.shop.forms import ProductAdminForm, ProductVariationAdminForm
@@ -156,6 +159,42 @@ class Media:
form = ProductAdminForm
fieldsets = product_fieldsets
+ def __init__(self, *args, **kwargs):
+ """
+ For ``Product`` subclasses that are registered with an Admin class
+ that doesn't implement fieldsets, add any extra model fields
+ to this instance's fieldsets. This mimics Django's behaviour of
+ adding all model fields when no fieldsets are defined on the
+ Admin class.
+ """
+
+ super(ProductAdmin, self).__init__(*args, **kwargs)
+
+ # Test that the fieldsets don't differ from ProductAdmin's.
+ if (self.model is not Product and
+ self.fieldsets == ProductAdmin.fieldsets):
+
+ # Make a copy so that we aren't modifying other Admin
+ # classes' fieldsets.
+ self.fieldsets = deepcopy(self.fieldsets)
+
+ # Insert each field between the publishing fields and nav
+ # fields. Do so in reverse order to retain the order of
+ # the model's fields.
+ for field in reversed(self.model._meta.fields):
+ check_fields = [f.name for f in Product._meta.fields]
+ check_fields.append("product_ptr")
+ try:
+ check_fields.extend(self.exclude)
+ except (AttributeError, TypeError):
+ pass
+ try:
+ check_fields.extend(self.form.Meta.exclude)
+ except (AttributeError, TypeError):
+ pass
+ if field.name not in check_fields and field.editable:
+ self.fieldsets[0][1]["fields"].insert(3, field.name)
+
def save_model(self, request, obj, form, change):
"""
Store the product object for creating variations in save_formset.
@@ -163,6 +202,12 @@ def save_model(self, request, obj, form, change):
super(ProductAdmin, self).save_model(request, obj, form, change)
self._product = obj
+ def in_menu(self):
+ """
+ Hide subclasses from the admin menu.
+ """
+ return self.model is Product
+
def save_formset(self, request, form, formset, change):
"""
@@ -229,6 +274,21 @@ def save_formset(self, request, form, formset, change):
# variation to the product.
self._product.copy_default_variation()
+ def change_view(self, request, object_id, extra_context=None):
+ """
+ As in Mezzanine's ``Page`` model, check ``product.get_content_model()``
+ for a subclass and redirect to its admin change view.
+ """
+ if self.model is Product:
+ product = get_object_or_404(Product, pk=object_id)
+ content_model = product.get_content_model()
+ if content_model is not None:
+ change_url = admin_url(content_model.__class__, "change",
+ content_model.id)
+ return HttpResponseRedirect(change_url)
@AlexHill Collaborator
AlexHill added a note

Here's where the redirection happens.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
+ return super(ProductAdmin, self).change_view(request, object_id,
+ extra_context=extra_context)
+
class ProductOptionAdmin(admin.ModelAdmin):
ordering = ("type", "name")
View
38 cartridge/shop/models.py
@@ -87,12 +87,27 @@ def copy_price_fields_to(self, obj_to):
obj_to.save()
-class Product(Displayable, Priced, RichText, AdminThumbMixin):
+class BaseProduct(Displayable):
+ """
+ Exists solely to store ``DisplayableManager`` as the main manager.
+ If it's defined on ``Product``, a concrete model, then each
+ ``Product`` subclass loses the custom manager.
+ """
+
+ objects = DisplayableManager()
+
+ class Meta:
+ abstract = True
+
+
+class Product(BaseProduct, Priced, RichText, AdminThumbMixin):
"""
Container model for a product that stores information common to
all of its variations such as the product's title and description.
"""
+ content_model = models.CharField(editable=False, max_length=50, null=True)
+
available = models.BooleanField(_("Available for purchase"),
default=False)
image = CharField(_("Image"), max_length=100, blank=True, null=True)
@@ -106,14 +121,29 @@ class Product(Displayable, Priced, RichText, AdminThumbMixin):
verbose_name=_("Upsell products"), blank=True)
rating = RatingField(verbose_name=_("Rating"))
- objects = DisplayableManager()
-
admin_thumb_field = "image"
class Meta:
verbose_name = _("Product")
verbose_name_plural = _("Products")
+ @classmethod
+ def get_content_models(cls):
+ """
+ Return all ``Product`` subclasses.
+ """
+ is_product_subclass = lambda cls: issubclass(cls, Product)
+ cmp = lambda a, b: (int(b is Product) - int(a is Product) or
+ a._meta.verbose_name < b._meta.verbose_name)
+ return sorted(filter(is_product_subclass, models.get_models()), cmp)
+
+ def get_content_model(self):
+ """
+ Provides a generic method of retrieving the instance of the custom
+ product's model, if there is one.
+ """
+ return getattr(self, self.content_model, None)
+
def save(self, *args, **kwargs):
"""
Copies the price fields to the default variation when
@@ -125,6 +155,8 @@ def save(self, *args, **kwargs):
if updating and not settings.SHOP_USE_VARIATIONS:
default = self.variations.get(default=True)
self.copy_price_fields_to(default)
+ else:
+ self.content_model = self._meta.object_name.lower()
@models.permalink
def get_absolute_url(self):
View
26 cartridge/shop/templates/admin/shop/product/change_list.html
@@ -0,0 +1,26 @@
+{% extends "admin/change_list.html" %}
+{% load shop_tags pages_tags i18n adminmedia %}
+
+{% block extrahead %}
+{{ block.super }}
+
+<script src="{{ STATIC_URL }}mezzanine/js/jquery-ui-1.8.14.custom.min.js"></script>
+<script src="{{ STATIC_URL }}mezzanine/js/admin/page_tree.js"></script>
+{% endblock %}
+
+{% block object-tools-items %}
+ <li>
+ <div id="addlist-primary">
+ <select class="addlist">
+ <option value="">{% trans "Add" %} ...</option>
+ {% models_for_products as product_models %}
+ {% for model in product_models %}
+ {% set_model_permissions model %}
+ {% if model.perms.add %}
+ <option value="{{ model.add_url }}">{{ model.name }}</option>
+ {% endif %}
+ {% endfor %}
+ </select>
+ </div>
+ </li>
+{% endblock %}
View
25 cartridge/shop/templatetags/shop_tags.py
@@ -2,8 +2,12 @@
import locale
import platform
-from django import template
+from django.core.urlresolvers import NoReverseMatch
+from mezzanine import template
+from mezzanine.utils.urls import admin_url
+
+from cartridge.shop.models import Product
from cartridge.shop.utils import set_locale
@@ -79,3 +83,22 @@ def order_totals_text(context):
Text version of order_totals.
"""
return _order_totals(context)
+
+
+@register.as_tag
+def models_for_products(*args):
+ """
+ Create a select list containing each of the models that subclass the
+ ``Product`` model, plus the ``Product`` model itself.
+ """
+ product_models = []
+ for model in Product.get_content_models():
+ try:
+ admin_add_url = admin_url(model, "add")
+ except NoReverseMatch:
+ continue
+ else:
+ setattr(model, "name", model._meta.verbose_name)
+ setattr(model, "add_url", admin_add_url)
+ product_models.append(model)
+ return product_models
View
9 cartridge/shop/views.py
@@ -80,7 +80,14 @@ def product(request, slug, template="shop/product.html"):
for_user=request.user),
"add_product_form": add_product_form
}
- return render(request, template, context)
+
+ templates = []
+ # Check for a template matching the page's content model.
+ if product.content_model is not None:
+ templates.append(u"shop/products/%s.html" % product.content_model)
+ templates.append(template)
+
+ return render(request, templates, context)
@never_cache
Something went wrong with that request. Please try again.