Skip to content
This repository

Support for custom Product models #57

Open
wants to merge 16 commits into from

9 participants

Alex Hill Don't Add Me To Your Organization a.k.a The Travis Bot joshcartme Brian Schott Stephen McDonald Lorin Hochstein stepmr Jason Robinson Paul Walsh
Alex Hill

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.

Don't Add Me To Your Organization a.k.a The Travis Bot

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

Don't Add Me To Your Organization a.k.a The Travis Bot

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

joshcartme

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?

Alex Hill AlexHill closed this September 04, 2012
Alex Hill AlexHill reopened this September 04, 2012
Alex Hill

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.

Don't Add Me To Your Organization a.k.a The Travis Bot

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

Alex Hill AlexHill commented on the diff September 04, 2012
cartridge/shop/admin.py
@@ -227,6 +236,21 @@ def save_formset(self, request, form, formset, change):
227 236
             # variation to the product.
228 237
             self._product.copy_default_variation()
229 238
 
  239
+    def change_view(self, request, object_id, extra_context=None):
  240
+        """
  241
+        As in Mezzanine's ``Page`` model, check ``product.get_content_model()``
  242
+        for a subclass and redirect to its admin change view.
  243
+        """
  244
+        if self.model is Product:
  245
+            product = get_object_or_404(Product, pk=object_id)
  246
+            content_model = product.get_content_model()
  247
+            if content_model is not None:
  248
+                change_url = admin_url(content_model.__class__, "change",
  249
+                                       content_model.id)
  250
+                return HttpResponseRedirect(change_url)
1

Here's where the redirection happens.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Don't Add Me To Your Organization a.k.a The Travis Bot

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

joshcartme

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.

Don't Add Me To Your Organization a.k.a The Travis Bot

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

Alex Hill

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?

Alex Hill

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.

Alex Hill

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
Don't Add Me To Your Organization a.k.a The Travis Bot

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

Don't Add Me To Your Organization a.k.a The Travis Bot

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

Alex Hill

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.

Don't Add Me To Your Organization a.k.a The Travis Bot

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

joshcartme
joshcartme

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

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

Alex Hill

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

Don't Add Me To Your Organization a.k.a The Travis Bot

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

Brian Schott

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.

Stephen McDonald
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.

Brian Schott
joshcartme
joshcartme
Alex Hill
Brian Schott
Brian Schott

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 Hochstein

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.

added some commits January 10, 2013
Alex Hill Merge remote-tracking branch 'upstream/master' into custom_products 542689c
Alex Hill 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
Alex Hill

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

Alex Hill

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

Brian Schott

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?

Brian Schott
Jason Robinson

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.

Paul Walsh

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

Stephen McDonald
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
This page is out of date. Refresh to see the latest.
60  cartridge/shop/admin.py
@@ -30,11 +30,14 @@
30 30
 
31 31
 from django.contrib import admin
32 32
 from django.db.models import ImageField
  33
+from django.http import HttpResponseRedirect
  34
+from django.shortcuts import get_object_or_404
33 35
 from django.utils.translation import ugettext_lazy as _
34 36
 
35 37
 from mezzanine.conf import settings
36 38
 from mezzanine.core.admin import DisplayableAdmin, TabularDynamicInlineAdmin
37 39
 from mezzanine.pages.admin import PageAdmin
  40
+from mezzanine.utils.urls import admin_url
38 41
 
39 42
 from cartridge.shop.fields import MoneyField
40 43
 from cartridge.shop.forms import ProductAdminForm, ProductVariationAdminForm
@@ -156,6 +159,42 @@ class Media:
156 159
     form = ProductAdminForm
157 160
     fieldsets = product_fieldsets
158 161
 
  162
+    def __init__(self, *args, **kwargs):
  163
+        """
  164
+        For ``Product`` subclasses that are registered with an Admin class
  165
+        that doesn't implement fieldsets, add any extra model fields
  166
+        to this instance's fieldsets. This mimics Django's behaviour of
  167
+        adding all model fields when no fieldsets are defined on the
  168
+        Admin class.
  169
+        """
  170
+
  171
+        super(ProductAdmin, self).__init__(*args, **kwargs)
  172
+
  173
+        # Test that the fieldsets don't differ from ProductAdmin's.
  174
+        if (self.model is not Product and
  175
+                self.fieldsets == ProductAdmin.fieldsets):
  176
+
  177
+            # Make a copy so that we aren't modifying other Admin
  178
+            # classes' fieldsets.
  179
+            self.fieldsets = deepcopy(self.fieldsets)
  180
+
  181
+            # Insert each field between the publishing fields and nav
  182
+            # fields. Do so in reverse order to retain the order of
  183
+            # the model's fields.
  184
+            for field in reversed(self.model._meta.fields):
  185
+                check_fields = [f.name for f in Product._meta.fields]
  186
+                check_fields.append("product_ptr")
  187
+                try:
  188
+                    check_fields.extend(self.exclude)
  189
+                except (AttributeError, TypeError):
  190
+                    pass
  191
+                try:
  192
+                    check_fields.extend(self.form.Meta.exclude)
  193
+                except (AttributeError, TypeError):
  194
+                    pass
  195
+                if field.name not in check_fields and field.editable:
  196
+                    self.fieldsets[0][1]["fields"].insert(3, field.name)
  197
+
159 198
     def save_model(self, request, obj, form, change):
160 199
         """
161 200
         Store the product object for creating variations in save_formset.
@@ -163,6 +202,12 @@ def save_model(self, request, obj, form, change):
163 202
         super(ProductAdmin, self).save_model(request, obj, form, change)
164 203
         self._product = obj
165 204
 
  205
+    def in_menu(self):
  206
+        """
  207
+        Hide subclasses from the admin menu.
  208
+        """
  209
+        return self.model is Product
  210
+
166 211
     def save_formset(self, request, form, formset, change):
167 212
         """
168 213
 
@@ -229,6 +274,21 @@ def save_formset(self, request, form, formset, change):
229 274
             # variation to the product.
230 275
             self._product.copy_default_variation()
231 276
 
  277
+    def change_view(self, request, object_id, extra_context=None):
  278
+        """
  279
+        As in Mezzanine's ``Page`` model, check ``product.get_content_model()``
  280
+        for a subclass and redirect to its admin change view.
  281
+        """
  282
+        if self.model is Product:
  283
+            product = get_object_or_404(Product, pk=object_id)
  284
+            content_model = product.get_content_model()
  285
+            if content_model is not None:
  286
+                change_url = admin_url(content_model.__class__, "change",
  287
+                                       content_model.id)
  288
+                return HttpResponseRedirect(change_url)
  289
+        return super(ProductAdmin, self).change_view(request, object_id,
  290
+            extra_context=extra_context)
  291
+
232 292
 
233 293
 class ProductOptionAdmin(admin.ModelAdmin):
234 294
     ordering = ("type", "name")
38  cartridge/shop/models.py
@@ -87,12 +87,27 @@ def copy_price_fields_to(self, obj_to):
87 87
         obj_to.save()
88 88
 
89 89
 
90  
-class Product(Displayable, Priced, RichText, AdminThumbMixin):
  90
+class BaseProduct(Displayable):
  91
+    """
  92
+    Exists solely to store ``DisplayableManager`` as the main manager.
  93
+    If it's defined on ``Product``, a concrete model, then each
  94
+    ``Product`` subclass loses the custom manager.
  95
+    """
  96
+
  97
+    objects = DisplayableManager()
  98
+
  99
+    class Meta:
  100
+        abstract = True
  101
+
  102
+
  103
+class Product(BaseProduct, Priced, RichText, AdminThumbMixin):
91 104
     """
92 105
     Container model for a product that stores information common to
93 106
     all of its variations such as the product's title and description.
94 107
     """
95 108
 
  109
+    content_model = models.CharField(editable=False, max_length=50, null=True)
  110
+
96 111
     available = models.BooleanField(_("Available for purchase"),
97 112
                                     default=False)
98 113
     image = CharField(_("Image"), max_length=100, blank=True, null=True)
@@ -106,14 +121,29 @@ class Product(Displayable, Priced, RichText, AdminThumbMixin):
106 121
                              verbose_name=_("Upsell products"), blank=True)
107 122
     rating = RatingField(verbose_name=_("Rating"))
108 123
 
109  
-    objects = DisplayableManager()
110  
-
111 124
     admin_thumb_field = "image"
112 125
 
113 126
     class Meta:
114 127
         verbose_name = _("Product")
115 128
         verbose_name_plural = _("Products")
116 129
 
  130
+    @classmethod
  131
+    def get_content_models(cls):
  132
+        """
  133
+        Return all ``Product`` subclasses.
  134
+        """
  135
+        is_product_subclass = lambda cls: issubclass(cls, Product)
  136
+        cmp = lambda a, b: (int(b is Product) - int(a is Product) or
  137
+                            a._meta.verbose_name < b._meta.verbose_name)
  138
+        return sorted(filter(is_product_subclass, models.get_models()), cmp)
  139
+
  140
+    def get_content_model(self):
  141
+        """
  142
+        Provides a generic method of retrieving the instance of the custom
  143
+        product's model, if there is one.
  144
+        """
  145
+        return getattr(self, self.content_model, None)
  146
+
117 147
     def save(self, *args, **kwargs):
118 148
         """
119 149
         Copies the price fields to the default variation when
@@ -125,6 +155,8 @@ def save(self, *args, **kwargs):
125 155
         if updating and not settings.SHOP_USE_VARIATIONS:
126 156
             default = self.variations.get(default=True)
127 157
             self.copy_price_fields_to(default)
  158
+        else:
  159
+            self.content_model = self._meta.object_name.lower()
128 160
 
129 161
     @models.permalink
130 162
     def get_absolute_url(self):
26  cartridge/shop/templates/admin/shop/product/change_list.html
... ...
@@ -0,0 +1,26 @@
  1
+{% extends "admin/change_list.html" %}
  2
+{% load shop_tags pages_tags i18n adminmedia %}
  3
+
  4
+{% block extrahead %}
  5
+{{ block.super }}
  6
+
  7
+<script src="{{ STATIC_URL }}mezzanine/js/jquery-ui-1.8.14.custom.min.js"></script>
  8
+<script src="{{ STATIC_URL }}mezzanine/js/admin/page_tree.js"></script>
  9
+{% endblock %}
  10
+
  11
+{% block object-tools-items %}
  12
+    <li>
  13
+        <div id="addlist-primary">
  14
+            <select class="addlist">
  15
+                <option value="">{% trans "Add" %} ...</option>
  16
+                {% models_for_products as product_models %}
  17
+                {% for model in product_models %}
  18
+                    {% set_model_permissions model %}
  19
+                    {% if model.perms.add %}
  20
+                        <option value="{{ model.add_url }}">{{ model.name }}</option>
  21
+                    {% endif %}
  22
+                {% endfor %}
  23
+            </select>
  24
+        </div>
  25
+    </li>
  26
+{% endblock %}
25  cartridge/shop/templatetags/shop_tags.py
@@ -2,8 +2,12 @@
2 2
 import locale
3 3
 import platform
4 4
 
5  
-from django import template
  5
+from django.core.urlresolvers import NoReverseMatch
6 6
 
  7
+from mezzanine import template
  8
+from mezzanine.utils.urls import admin_url
  9
+
  10
+from cartridge.shop.models import Product
7 11
 from cartridge.shop.utils import set_locale
8 12
 
9 13
 
@@ -79,3 +83,22 @@ def order_totals_text(context):
79 83
     Text version of order_totals.
80 84
     """
81 85
     return _order_totals(context)
  86
+
  87
+
  88
+@register.as_tag
  89
+def models_for_products(*args):
  90
+    """
  91
+    Create a select list containing each of the models that subclass the
  92
+    ``Product`` model, plus the ``Product`` model itself.
  93
+    """
  94
+    product_models = []
  95
+    for model in Product.get_content_models():
  96
+        try:
  97
+            admin_add_url = admin_url(model, "add")
  98
+        except NoReverseMatch:
  99
+            continue
  100
+        else:
  101
+            setattr(model, "name", model._meta.verbose_name)
  102
+            setattr(model, "add_url", admin_add_url)
  103
+            product_models.append(model)
  104
+    return product_models
9  cartridge/shop/views.py
@@ -80,7 +80,14 @@ def product(request, slug, template="shop/product.html"):
80 80
                                                       for_user=request.user),
81 81
         "add_product_form": add_product_form
82 82
     }
83  
-    return render(request, template, context)
  83
+
  84
+    templates = []
  85
+    # Check for a template matching the page's content model.
  86
+    if product.content_model is not None:
  87
+        templates.append(u"shop/products/%s.html" % product.content_model)
  88
+    templates.append(template)
  89
+
  90
+    return render(request, templates, context)
84 91
 
85 92
 
86 93
 @never_cache
Commit_comment_tip

Tip: You can add notes to lines in a file. Hover to the left of a line to make a note

Something went wrong with that request. Please try again.