Skip to content

Instantly share code, notes, and snippets.

@stlk
Last active January 23, 2023 16:50
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save stlk/cac2796289d33466a99a09f42c151013 to your computer and use it in GitHub Desktop.
Save stlk/cac2796289d33466a99a09f42c151013 to your computer and use it in GitHub Desktop.
Shopify-like product variants in Django
class ProductAdminForm(forms.ModelForm):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
if self.instance.pk:
self.fields["option1"].queryset = self.instance.options.all()
class Meta:
model = Product
fields = "__all__"
class ProductAdmin(admin.ModelAdmin):
list_display = ("title", "price", "order")
list_display_links = ("title",)
prepopulated_fields = {"slug": ("title",)}
inlines = (ProductOptionInline, ProductVariantInline)
form = ProductAdminForm
def save_related(self, request, form, formsets, change):
super().save_related(request, form, formsets, change)
instance = form.instance
instance.refresh_from_db()
if instance.option1:
for option_value in instance.option1.values:
instance.variants.get_or_create(option1=option_value, defaults={"price": instance.price})
instance.variants.exclude(option1__in=instance.option1.values).delete()
admin.site.register(Product, ProductAdmin)
class Product(models.Model):
title = models.CharField(max_length=200)
slug = models.SlugField(max_length=50, unique=True)
description = models.TextField(blank=True)
meta_description = models.CharField(max_length=300, blank=True)
vat_rate = models.DecimalField("VAT rate (%)", max_digits=10, decimal_places=2)
price = models.DecimalField(max_digits=10, decimal_places=2)
compare_at_price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
option1 = models.ForeignKey("ProductOption", related_name="+", null=True, blank=True, on_delete=models.PROTECT)
def variant_or_product_price(self, variant_id: Optional[int]) -> Decimal:
if variant_id:
with contextlib.suppress(ProductVariant.DoesNotExist):
return self.variants.get(id=variant_id).price
return self.price
class ProductOption(models.Model):
product = models.ForeignKey(Product, related_name="options", on_delete=models.CASCADE)
name = models.CharField(max_length=255)
values = ArrayField(models.CharField(max_length=20))
is_multiselect = models.BooleanField(default=False)
order = models.IntegerField(default=10)
@property
def slug(self):
return slugify(self.name).replace("-", "_")
def __str__(self):
return self.name
class Meta:
ordering = ["order", "id"]
class ProductVariant(models.Model):
product = models.ForeignKey(Product, related_name="variants", on_delete=models.CASCADE)
option1 = models.CharField(max_length=255)
price = models.DecimalField(max_digits=10, decimal_places=2)
compare_at_price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
<form action="{% url 'add-to-cart' %}" method="POST" x-data="productApp()">
{% csrf_token %}
<input type="hidden" name="id" value="{{ product.id }}" />
<input type="hidden" name="variant_id" :value="selectedVariant.id" />
{% with option=product.option1 %}
{% if option %}
<div class="mt-8">
<div class="flex items-center justify-between">
<h2 class="text-sm font-medium text-gray-900">{{option.name}}</h2>
</div>
<fieldset class="mt-2">
<legend class="sr-only">{{option.name}}</legend>
<div class="flex gap-4 flex-wrap">
{% for value in option.values %}
{% if option.is_multiselect %}
{% include 'partials/product/checkbox.html' %}
{% else %}
{% include 'partials/product/radio.html' %}
{% endif %}
{% endfor %}
</div>
</fieldset>
</div>
{% endif %}
{% endwith %}
<div class="mt-10 flex items-baseline text-4xl font-extrabold" x-show="!productOption1Slug">
{% if product.compare_at_price %}
<span class="text-2xl line-through text-gray-500 font-semibold">
{{ product.compare_at_price|floatformat }} Kč
</span>
&nbsp;
{% endif %}
{{ product.price|floatformat }} Kč
</div>
<div class="mt-10 flex items-baseline text-4xl font-extrabold" x-show="productOption1Slug">
<template x-if="selectedVariant.compare_at_price">
<span>
<span class="text-2xl line-through text-gray-500 font-semibold">
<span x-text="selectedVariant.compare_at_price"></span>&nbsp;Kč
</span>
&nbsp;
</span>
</template>
<span x-text="selectedVariant.price"></span>&nbsp;Kč
</div>
<div class="mt-10">
<button
type="button"
class="w-full bg-red-600 border border-transparent rounded-md py-3 px-8 flex items-center justify-center text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-50 focus:ring-red-500"
@click="open = true">
Objednat
</button>
</div>
</form>
function checkFields(object, fields) {
return fields
.filter((field) => field.required)
.every((field) => {
const value = object[field.slug];
return Array.isArray(value) ? value.length : value;
});
}
function optionDefault(option, productOption1) {
if (option.is_multiselect) {
return [];
}
if (option.slug === productOption1) {
return option.values[0];
}
return '';
}
function productApp(fields) {
const productData = JSON.parse(
document.getElementById('product-data').textContent,
);
const productOptions = productData.product_options_values;
const productVariants = productData.product_variants;
const productOption1Slug = productData.option1_slug;
const combinedFields = [...productOptions, ...fields];
return {
productOption1Slug,
productVariants,
open: false,
...combinedFields.reduce(
(aggregate, option) => ({
...aggregate,
[option.slug]: optionDefault(option, productOption1Slug),
}),
{},
),
get selectedVariant() {
return productVariants.find(
(variant) => variant.option1 === this[productOption1Slug],
);
},
get isValid() {
return checkFields(this, combinedFields);
},
isNotValid: false,
handleAddToCart(event) {
const isValid = checkFields(this, combinedFields);
this.isNotValid = !isValid;
console.log({ isNotValid: this.isNotValid });
if (!isValid) {
event.preventDefault();
}
},
};
}
class ProductView(View):
template_name = "product.html"
def get(self, request, product_slug):
product = get_object_or_404(Product.objects.all().prefetch_related("options"), slug=product_slug, enabled=True)
context = {
"product": product,
"json_data": {
"option1_slug": product.option1.slug if product.option1 else None,
"product_options_values": [
{
"name": option.name,
"slug": option.slug,
"is_multiselect": option.is_multiselect,
"required": option.is_required,
"values": option.values,
}
for option in product.options.all()
],
"product_variants": [
{
"id": variant.id,
"option1": variant.option1,
"price": floatformat(variant.price),
"compare_at_price": floatformat(variant.compare_at_price),
}
for variant in product.variants.all()
],
},
}
return render(request, self.template_name, context)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment