Collecting additional information on a per product basis in Cartridge

I recently was working on a project in which I was making use of Cartridge and needed to collect extra info when users added particular products to their order. I wanted this to be toggleable via the backend so that certain products would require extra information and others wouldn't.

My particular case was collecting a student ID, but these techniques should be applicable to many use cases. After some thought I figured out that there were two main areas of Cartridge functionality that I would need to modify. They were:

  1. Adding items to the cart
  2. Copying cart items to an order

I don't think that modifying an external librarie's (in this case Cartridge) source code is a good idea so I didn't. To start out I added fields using Mezzanine's EXTRA_MODEL_FIELDS.

Adding extra fields

Inside the project's settings.py I added:

EXTRA_MODEL_FIELDS = (
    (
        "cartridge.shop.models.Product.require_student_id",
        "BooleanField",
        ("Require student ID",),
        {"help_text": 'Check if a student id should be collected when this product is purchased',
         "default": False},
    ),
    (
        "cartridge.shop.models.CartItem.student_id",
        "CharField",
        ("Student ID",),
        {"blank": True, "max_length": 50},
    ),
    (
        "cartridge.shop.models.OrderItem.student_id",
        "CharField",
        ("Student ID",),
        {"blank": True, "max_length": 50},
    ),
)

Next I created migrations:

python manage.py schemamigration shop --auto --stdout > shop_extras/migrations/0001_student_id_collect.py

Then I ran the migrations:

python manage.py migrate shop_extras

The above assumes that shop_extras is a django app that is in the project's INSTALLED_APPS setting. You will need to manually create the directory migrations and add a blank __init__.py file inside of it.

Next to expose the newly created require_student_id field I monkey patched the shop admin. Inside shop_extras/admin.py I put:

from cartridge.shop.admin import ProductAdmin

ProductAdmin.fieldsets[0][1]["fields"].extend(["require_student_id"])

Now the Product admin has a checkbox that can be used to toggle whether or not a particular product requires a student ID. From there I began to work on collecting the student ID when the product was added to the cart.

Cart modifications

I put all of the monkey patches discussed in the following sections in shop_extras/models.py. They can go anywhere that is imported at the time the site starts.

Collecting extra fields

First I monkey patched an additional field onto the AddProductForm:

from copy import deepcopy

from cartridge.shop.forms import AddProductForm

...

original_product_add_init = deepcopy(AddProductForm.__init__) 
def product_add_init(self, *args, **kwargs):
    """
    Add student ID to add to cart form
    """
    original_product_add_init(self, *args, **kwargs)
    if self._product and self._product.require_student_id:
        self.fields['student_id'] = forms.CharField()
AddProductForm.__init__ = product_add_init

The above makes it so that if a Product requires a student ID an additional field to collect it is added to the AddProductForm. Collecting extra fields is great, but useless if they aren't stored somewhere.

Storing extra fields

Storing the extra fields was a bit more tricky. First I decided to keep a transient copy of the student ID in the variation that is attached to the AddProductForm in its clean method. I copied the original clean method from AddProductForm and added to it ending up with:

def product_add_clean(self):
    """
    Determine the chosen variation, validate it and assign it as
    an attribute to be used in views.

    Store the student ID if it exists
    """
    if not self.is_valid():
        return
    # Posted data will either be a sku, or product options for
    # a variation.
    data = self.cleaned_data.copy()
    quantity = data.pop("quantity")
    student_id = None
    if self._product and self._product.require_student_id:
        student_id = data.pop("student_id")
    # Ensure the product has a price if adding to cart.
    if self._to_cart:
        data["unit_price__isnull"] = False
    error = None
    if self._product is not None:
        # Chosen options will be passed to the product's
        # variations.
        qs = self._product.variations
    else:
        # A product hasn't been given since we have a direct sku.
        qs = ProductVariation.objects
    try:
        variation = qs.get(**data)
    except ProductVariation.DoesNotExist:
        error = "invalid_options"
    else:
        # Validate stock if adding to cart.
        if self._to_cart:
            if not variation.has_stock():
                error = "no_stock"
            elif not variation.has_stock(quantity):
                error = "no_stock_quantity"
    if error is not None:
        raise forms.ValidationError(ADD_PRODUCT_ERRORS[error])
    self.variation = variation
    if student_id:
        self.variation._student_id = student_id
    return self.cleaned_data
AddProductForm.clean = product_add_clean

The parts I added were:

student_id = None
if self._product and self._product.require_student_id:
    student_id = data.pop("student_id")

and near the end:

if student_id:
    self.variation._student_id = student_id

I had to copy the entire clean method (rather than a cleaner monkey patch like I did on the __init__) because I needed to pop student_id out of data which was created inside the clean method to avoid validation errors.

At this point I had a transient copy of the student ID stored in the variation. Ultimately I needed the student ID to be stored in the database with on the CartItem model (remember we added that field using EXTRA_MODEL_FIELDS). To accomplish this I monkey patch the Cart.add_item method:

def add_item_mod(self, variation, quantity):
    """
    Increase quantity of existing item if SKU matches, otherwise create
    new.
    """
    kwargs = {"sku": variation.sku, "unit_price": variation.price()}
    item, created = self.items.get_or_create(**kwargs)
    if created:
        item.description = force_text(variation)
        item.unit_price = variation.price()
        item.url = variation.product.get_absolute_url()
        try:
            item.student_id = variation._student_id
        except AttributeError:
            pass
        image = variation.image
        if image is not None:
            item.image = force_text(image.file)
        variation.product.actions.added_to_cart()
    item.quantity += quantity
    item.save()
Cart.add_item = add_item_mod

In this case I needed to do things inside the if created block so I couldn't do as clean of a patch. As you can see I try to add the student ID to the CartItem catching any AttributeError which would mean that product didn't require a student ID.

At this point I've collected and stored the student ID in the user's cart. The last piece of this puzzle is storing the student ID on the OrderItem when an order is made.

Storing custom fields on OrderItem

When I first looked at Order.setup I thought that the student ID field (which was added to both CartItem and OrderItem) would automatically be copied over. This didn't end up being the case, only fields that both models inherited from SelectedProduct were copied over. To get the field to copy I monkey patched Order.setup:

def setup(self, request):
    """
    Set order fields that are stored in the session, item_total
    and total based on the given cart, and copy the cart items
    to the order. Called in the final step of the checkout process
    prior to the payment handler being called.

    Also copies student IDs
    """
    self.key = request.session.session_key
    self.user_id = request.user.id
    for field in self.session_fields:
        if field in request.session:
            setattr(self, field, request.session[field])
    self.total = self.item_total = request.cart.total_price()
    if self.shipping_total is not None:
        self.shipping_total = Decimal(str(self.shipping_total))
        self.total += self.shipping_total
    if self.discount_total is not None:
        self.total -= Decimal(self.discount_total)
    if self.tax_total is not None:
        self.total += Decimal(self.tax_total)
    self.save()  # We need an ID before we can add related items.
    for item in request.cart:
        product_fields = [f.name for f in SelectedProduct._meta.fields] + ['student_id']
        item = dict([(f, getattr(item, f)) for f in product_fields])
        self.items.create(**item)
Order.setup = setup

The only part I added was:

+ ['student_id']

to:

product_fields = [f.name for f in SelectedProduct._meta.fields]

in the forloop near the end.

End

That's it. At this point when adding products that have require_student_id checked a student ID is collected. If the order is completed that student ID is saved on the OrderItem and visible in the line items at the bottom of the Order admin.

If you have questions/comments or see a typo or something else I did wrong use the comments below!

Comments