Skip to content

Commit d83ed8a

Browse files
feat: Enhance attribute model with JSONField and indexing
Changes the `value` field in `ProductAttribute` from `CharField` to `JSONField` to support multiple data types (string, number, boolean). Adds database indexes to the `product` and `attribute` foreign keys in `ProductAttribute` to improve query performance. Includes a data migration to safely convert existing string values to a JSON format. Updates serializers and tests to handle various data types for attribute values.
1 parent c6ed482 commit d83ed8a

10 files changed

+321
-79
lines changed
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# Generated by Django 5.2 on 2025-11-29 18:42
2+
3+
import django.core.validators
4+
import django.db.models.deletion
5+
from django.db import migrations, models
6+
7+
8+
def migrate_product_dimensions_to_attributes(apps, schema_editor):
9+
Product = apps.get_model('shop', 'Product')
10+
Attribute = apps.get_model('shop', 'Attribute')
11+
ProductAttribute = apps.get_model('shop', 'ProductAttribute')
12+
13+
# Create attributes if they don't exist
14+
weight_attr, _ = Attribute.objects.get_or_create(name='Weight')
15+
length_attr, _ = Attribute.objects.get_or_create(name='Length')
16+
width_attr, _ = Attribute.objects.get_or_create(name='Width')
17+
height_attr, _ = Attribute.objects.get_or_create(name='Height')
18+
19+
for product in Product.objects.all():
20+
if product.weight is not None:
21+
ProductAttribute.objects.create(product=product, attribute=weight_attr, value=str(product.weight))
22+
if product.length is not None:
23+
ProductAttribute.objects.create(product=product, attribute=length_attr, value=str(product.length))
24+
if product.width is not None:
25+
ProductAttribute.objects.create(product=product, attribute=width_attr, value=str(product.width))
26+
if product.height is not None:
27+
ProductAttribute.objects.create(product=product, attribute=height_attr, value=str(product.height))
28+
29+
30+
class Migration(migrations.Migration):
31+
32+
dependencies = [
33+
("shop", "0006_product_height_product_length_product_weight_and_more"),
34+
]
35+
36+
operations = [
37+
migrations.CreateModel(
38+
name="Attribute",
39+
fields=[
40+
(
41+
"id",
42+
models.BigAutoField(
43+
auto_created=True,
44+
primary_key=True,
45+
serialize=False,
46+
verbose_name="ID",
47+
),
48+
),
49+
("name", models.CharField(max_length=100, unique=True)),
50+
("description", models.TextField(blank=True, null=True)),
51+
],
52+
),
53+
migrations.CreateModel(
54+
name="ProductAttribute",
55+
fields=[
56+
(
57+
"id",
58+
models.BigAutoField(
59+
auto_created=True,
60+
primary_key=True,
61+
serialize=False,
62+
verbose_name="ID",
63+
),
64+
),
65+
("value", models.CharField(max_length=255)),
66+
(
67+
"attribute",
68+
models.ForeignKey(
69+
on_delete=django.db.models.deletion.CASCADE, to="shop.attribute"
70+
),
71+
),
72+
(
73+
"product",
74+
models.ForeignKey(
75+
on_delete=django.db.models.deletion.CASCADE,
76+
related_name="attributes",
77+
to="shop.product",
78+
),
79+
),
80+
],
81+
options={
82+
"unique_together": {("product", "attribute")},
83+
},
84+
),
85+
migrations.RunPython(migrate_product_dimensions_to_attributes),
86+
migrations.RemoveField(
87+
model_name="product",
88+
name="height",
89+
),
90+
migrations.RemoveField(
91+
model_name="product",
92+
name="length",
93+
),
94+
migrations.RemoveField(
95+
model_name="product",
96+
name="weight",
97+
),
98+
migrations.RemoveField(
99+
model_name="product",
100+
name="width",
101+
),
102+
migrations.AlterField(
103+
model_name="product",
104+
name="price",
105+
field=models.DecimalField(
106+
decimal_places=2,
107+
max_digits=10,
108+
validators=[django.core.validators.MinValueValidator(0.0)],
109+
),
110+
),
111+
migrations.AlterField(
112+
model_name="product",
113+
name="stock",
114+
field=models.IntegerField(
115+
validators=[django.core.validators.MinValueValidator(0)]
116+
),
117+
),
118+
]
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Generated by Django 5.2 on 2025-11-29 18:56
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("shop", "0007_attribute_remove_product_height_and_more"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="category",
15+
name="attributes",
16+
field=models.ManyToManyField(
17+
blank=True, related_name="categories", to="shop.attribute"
18+
),
19+
),
20+
]
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Generated by Django 5.2 on 2025-11-29 19:09
2+
import json
3+
from django.db import migrations, models
4+
5+
def convert_value_to_json(apps, schema_editor):
6+
ProductAttribute = apps.get_model('shop', 'ProductAttribute')
7+
for pa in ProductAttribute.objects.all():
8+
try:
9+
# Attempt to parse as a number first
10+
value = float(pa.value)
11+
except (ValueError, TypeError):
12+
# Fallback to string
13+
value = pa.value
14+
15+
pa.value = json.dumps(value)
16+
pa.save(update_fields=['value'])
17+
18+
class Migration(migrations.Migration):
19+
20+
dependencies = [
21+
("shop", "0008_category_attributes"),
22+
]
23+
24+
operations = [
25+
migrations.RunPython(convert_value_to_json, reverse_code=migrations.RunPython.noop),
26+
migrations.AlterField(
27+
model_name="productattribute",
28+
name="value",
29+
field=models.JSONField(),
30+
),
31+
migrations.AddIndex(
32+
model_name="productattribute",
33+
index=models.Index(
34+
fields=["product"], name="shop_produc_product_979956_idx"
35+
),
36+
),
37+
migrations.AddIndex(
38+
model_name="productattribute",
39+
index=models.Index(
40+
fields=["attribute"], name="shop_produc_attribu_8385ae_idx"
41+
),
42+
),
43+
]

shop/models.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ class Meta:
2020

2121

2222
class Category(SluggedModel):
23+
attributes = models.ManyToManyField('Attribute', blank=True, related_name='categories')
24+
2325
class Meta:
2426
verbose_name = "Category"
2527
verbose_name_plural = "Categories"
@@ -73,10 +75,6 @@ class Product(SluggedModel):
7375
objects = models.Manager()
7476
in_stock = InStockManager()
7577
tags = TaggableManager(through=CustomTaggedItem)
76-
weight = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, validators=[MinValueValidator(0.0)])
77-
length = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, validators=[MinValueValidator(0.0)])
78-
width = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, validators=[MinValueValidator(0.0)])
79-
height = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, validators=[MinValueValidator(0.0)])
8078

8179
class Meta:
8280
verbose_name = "Product"
@@ -171,3 +169,27 @@ def save(self, *args, **kwargs):
171169

172170
def __str__(self):
173171
return f"Review by {self.user} for {self.product} - {self.rating} stars"
172+
173+
174+
class Attribute(models.Model):
175+
name = models.CharField(max_length=100, unique=True)
176+
description = models.TextField(blank=True, null=True)
177+
178+
def __str__(self):
179+
return self.name
180+
181+
182+
class ProductAttribute(models.Model):
183+
product = models.ForeignKey(Product, related_name='attributes', on_delete=models.CASCADE)
184+
attribute = models.ForeignKey(Attribute, on_delete=models.CASCADE)
185+
value = models.JSONField()
186+
187+
class Meta:
188+
unique_together = ('product', 'attribute')
189+
indexes = [
190+
models.Index(fields=['product']),
191+
models.Index(fields=['attribute']),
192+
]
193+
194+
def __str__(self):
195+
return f"{self.product.name} - {self.attribute.name}: {self.value}"

0 commit comments

Comments
 (0)