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:
- Adding items to the cart
- 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