Last active
January 23, 2023 16:50
-
-
Save stlk/cac2796289d33466a99a09f42c151013 to your computer and use it in GitHub Desktop.
Shopify-like product variants in Django
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<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> | |
| |
{% 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> Kč | |
</span> | |
| |
</span> | |
</template> | |
<span x-text="selectedVariant.price"></span> 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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | |
} | |
}, | |
}; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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