Merge branch 'release/2.0.3'

This commit is contained in:
Nathan Chapman 2022-11-06 10:36:04 -07:00
commit 951562e971
31 changed files with 1068 additions and 74 deletions

View File

@ -0,0 +1,369 @@
[{
"model": "core.product",
"pk": 10,
"fields": {
"category": 2,
"name": "BLTC Mug",
"subtitle": "Camper Vacuum Mug",
"description": "Camp mug is made of double-wall stainless steel, designed to keep drinks hot or cold for hours.",
"checkout_limit": 20,
"visible_in_listings": false,
"sorting": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
}
}, {
"model": "core.productvariant",
"pk": null,
"fields": {
"product": 10,
"name": "Blue - 12 oz.",
"sku": "REPLACE_4001",
"price": null,
"weight": "00:oz",
"track_inventory": true,
"stock": 0,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
}
}, {
"model": "core.productphoto",
"pk": null,
"fields": {
"product": 10,
"image": "products/images/bltc_mug_blue_12_oz.jpg"
}
},
{
"model": "core.product",
"pk": 11,
"fields": {
"category": 2,
"name": "BLTC Pull-over Hoodie",
"subtitle": "Classic BLTC Logo front left and Steam/Waves on back",
"description": "ComfortBlend Hoodie - Screen. EcoSmart® 7.8-oz poly-cotton blend hoodie features a front pouch pocket and two-ply hood with matching drawcord. Rib-knit cuffs and waistband ensure all-day comfort and warmth.",
"checkout_limit": 20,
"visible_in_listings": false,
"sorting": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
}
}, {
"model": "core.productvariant",
"pk": null,
"fields": {
"product": 11,
"name": "Blue",
"sku": "REPLACE_4002",
"price": null,
"weight": "00:oz",
"track_inventory": true,
"stock": 0,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
}
}, {
"model": "core.productphoto",
"pk": null,
"fields": {
"product": 11,
"image": "products/images/bltc_pullover_hoodie_blue_front.jpg"
}
}, {
"model": "core.productphoto",
"pk": null,
"fields": {
"product": 11,
"image": "products/images/bltc_pullover_hoodie_blue_back.jpg"
}
},
{
"model": "core.product",
"pk": 12,
"fields": {
"category": 2,
"name": "BLTC T-Shirt",
"subtitle": "Logo T-Shirt with Steam/Waves on back",
"description": "5.5 oz. DryBlend 50/50 T-Shirt - Screen. The 5.5-oz 50/50 cotton/polyester blend fabric is moisture-wicking.",
"checkout_limit": 20,
"visible_in_listings": false,
"sorting": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
}
}, {
"model": "core.productvariant",
"pk": null,
"fields": {
"product": 12,
"name": "Heather Red",
"sku": "REPLACE_4003",
"price": null,
"weight": "00:oz",
"track_inventory": true,
"stock": 0,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
}
}, {
"model": "core.productvariant",
"pk": null,
"fields": {
"product": 12,
"name": "Heather Blue",
"sku": "REPLACE_4004",
"price": null,
"weight": "00:oz",
"track_inventory": true,
"stock": 0,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
}
}, {
"model": "core.productphoto",
"pk": null,
"fields": {
"product": 12,
"image": "products/images/bltc_tshirt_blue_front.jpg"
}
}, {
"model": "core.productphoto",
"pk": null,
"fields": {
"product": 12,
"image": "products/images/bltc_tshirt_blue_back.jpg"
}
}, {
"model": "core.productphoto",
"pk": null,
"fields": {
"product": 12,
"image": "products/images/bltc_tshirt_red_front.jpg"
}
}, {
"model": "core.productphoto",
"pk": null,
"fields": {
"product": 12,
"image": "products/images/bltc_tshirt_red_back.jpg"
}
},
{
"model": "core.product",
"pk": 13,
"fields": {
"category": 2,
"name": "BLTC Trucker Cap",
"subtitle": "Washed Cotton Mesh Back Cap with Logo",
"description": "Soft washed cotton gives this trucker hat an appealing vintage look. Designed with ventilated mesh back panels and a front twill sweatband for comfort.",
"checkout_limit": 20,
"visible_in_listings": false,
"sorting": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
}
}, {
"model": "core.productvariant",
"pk": null,
"fields": {
"product": 13,
"name": "Black and White",
"sku": "REPLACE_4005",
"price": null,
"weight": "00:oz",
"track_inventory": true,
"stock": 0,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
}
}, {
"model": "core.productphoto",
"pk": null,
"fields": {
"product": 13,
"image": "products/images/bltc_trucker_cap_black_and_white.jpg"
}
},
{
"model": "core.product",
"pk": 14,
"fields": {
"category": 2,
"name": "BLTC Zip-up Hoodie",
"subtitle": "Classic BLTC Cafe Logo without the extra branding",
"description": "Made from a 7.1-oz, 52/48 cotton/polyester blend. Includes a spacious hood with drawcords and metal grommets. Interior phone pocket with headphone cord port.",
"checkout_limit": 20,
"visible_in_listings": false,
"sorting": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
}
}, {
"model": "core.productvariant",
"pk": null,
"fields": {
"product": 14,
"name": "Forest Green/Silver Embroidered",
"sku": "REPLACE_4006",
"price": null,
"weight": "00:oz",
"track_inventory": true,
"stock": 0,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
}
}, {
"model": "core.productvariant",
"pk": null,
"fields": {
"product": 14,
"name": "Navy Blue/Silver Embroidered",
"sku": "REPLACE_4007",
"price": null,
"weight": "00:oz",
"track_inventory": true,
"stock": 0,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
}
}, {
"model": "core.productvariant",
"pk": null,
"fields": {
"product": 14,
"name": "Black/Gold Embroidered",
"sku": "REPLACE_4008",
"price": null,
"weight": "00:oz",
"track_inventory": true,
"stock": 0,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
}
}, {
"model": "core.productphoto",
"pk": null,
"fields": {
"product": 14,
"image": "products/images/bltc_zipup_hoodie_black_gold.jpg"
}
}, {
"model": "core.productphoto",
"pk": null,
"fields": {
"product": 14,
"image": "products/images/bltc_zipup_hoodie_green_silver.jpg"
}
}, {
"model": "core.productphoto",
"pk": null,
"fields": {
"product": 14,
"image": "products/images/bltc_zipup_hoodie_navy_silver.jpg"
}
},
{
"model": "core.product",
"pk": 15,
"fields": {
"category": 2,
"name": "PT Coffee Cap",
"subtitle": "Embroidered Buttonless Cap",
"description": "These caps feature a buttonless design that won't interfere with headsets/headphones. The low profile fit and adjustable fabric strap with hook-and-loop closure helps create a more comfortable fit.",
"checkout_limit": 20,
"visible_in_listings": false,
"sorting": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
}
}, {
"model": "core.productvariant",
"pk": null,
"fields": {
"product": 15,
"name": "Navy Blue with Red Embroidered Print",
"sku": "REPLACE_4009",
"price": null,
"weight": "00:oz",
"track_inventory": true,
"stock": 0,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
}
}, {
"model": "core.productphoto",
"pk": null,
"fields": {
"product": 15,
"image": "products/images/pt_coffee_cap_navy.jpg"
}
},
{
"model": "core.product",
"pk": 16,
"fields": {
"category": 2,
"name": "PT Coffee Cup",
"subtitle": "Bean/Leaf Logo Neo Vacuum Insulated Cup",
"description": "Designed to keep drinks hot or cold for hours. A great cup for enjoying wine, tea, coffee, hot chocolate, and more!",
"checkout_limit": 20,
"visible_in_listings": false,
"sorting": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
}
}, {
"model": "core.productvariant",
"pk": null,
"fields": {
"product": 16,
"name": "Black and Gold 10 oz.",
"sku": "REPLACE_4010",
"price": null,
"weight": "00:oz",
"track_inventory": true,
"stock": 0,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
}
}, {
"model": "core.productvariant",
"pk": null,
"fields": {
"product": 16,
"name": "White and Black 10 oz.",
"sku": "REPLACE_4011",
"price": null,
"weight": "00:oz",
"track_inventory": true,
"stock": 0,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
}
}, {
"model": "core.productphoto",
"pk": null,
"fields": {
"product": 16,
"image": "products/images/pt_coffee_cup_black_gold_10_oz.jpg"
}
}, {
"model": "core.productphoto",
"pk": null,
"fields": {
"product": 16,
"image": "products/images/pt_coffee_cup_white_black_10_oz.jpg"
}
}]

View File

@ -0,0 +1,289 @@
[{
"model": "core.productvariant",
"pk": 10,
"fields": {
"product": 1,
"name": "12 oz",
"sku": "REPLACE_87431",
"price": "12.00",
"weight": "12.0:oz",
"track_inventory": false,
"stock": null,
"sorting": 1,
"image": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
}
}, {
"model": "core.productvariant",
"pk": 11,
"fields": {
"product": 1,
"name": "5 lb",
"sku": "REPLACE_87430",
"price": "75.00",
"weight": "5.0:lb",
"track_inventory": false,
"stock": null,
"sorting": 3,
"image": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
}
}, {
"model": "core.productvariant",
"pk": 12,
"fields": {
"product": 2,
"name": "12 oz",
"sku": "REPLACE_87429",
"price": "12.00",
"weight": "12.0:oz",
"track_inventory": false,
"stock": null,
"sorting": 1,
"image": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
}
}, {
"model": "core.productvariant",
"pk": 13,
"fields": {
"product": 2,
"name": "5 lb",
"sku": "REPLACE_87428",
"price": "75.00",
"weight": "5.0:lb",
"track_inventory": false,
"stock": null,
"sorting": 3,
"image": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
}
}, {
"model": "core.productvariant",
"pk": 14,
"fields": {
"product": 3,
"name": "12 oz",
"sku": "REPLACE_87427",
"price": "12.00",
"weight": "12.0:oz",
"track_inventory": false,
"stock": null,
"sorting": 1,
"image": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
}
}, {
"model": "core.productvariant",
"pk": 15,
"fields": {
"product": 3,
"name": "5 lb",
"sku": "REPLACE_87426",
"price": "75.00",
"weight": "5.0:lb",
"track_inventory": false,
"stock": null,
"sorting": 3,
"image": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
}
}, {
"model": "core.productvariant",
"pk": 16,
"fields": {
"product": 4,
"name": "12 oz",
"sku": "REPLACE_87425",
"price": "12.00",
"weight": "12.0:oz",
"track_inventory": false,
"stock": null,
"sorting": 1,
"image": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
}
}, {
"model": "core.productvariant",
"pk": 17,
"fields": {
"product": 4,
"name": "5 lb",
"sku": "REPLACE_87424",
"price": "75.00",
"weight": "5.0:lb",
"track_inventory": false,
"stock": null,
"sorting": 3,
"image": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
}
}, {
"model": "core.productvariant",
"pk": 18,
"fields": {
"product": 5,
"name": "12 oz",
"sku": "REPLACE_87423",
"price": "12.00",
"weight": "12.0:oz",
"track_inventory": false,
"stock": null,
"sorting": 1,
"image": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
}
}, {
"model": "core.productvariant",
"pk": 19,
"fields": {
"product": 5,
"name": "5 lb",
"sku": "REPLACE_87422",
"price": "75.00",
"weight": "5.0:lb",
"track_inventory": false,
"stock": null,
"sorting": 3,
"image": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
}
}, {
"model": "core.productvariant",
"pk": 20,
"fields": {
"product": 6,
"name": "12 oz",
"sku": "REPLACE_87421",
"price": "12.00",
"weight": "12.0:oz",
"track_inventory": false,
"stock": null,
"sorting": 1,
"image": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
}
}, {
"model": "core.productvariant",
"pk": 21,
"fields": {
"product": 6,
"name": "5 lb",
"sku": "REPLACE_87420",
"price": "75.00",
"weight": "5.0:lb",
"track_inventory": false,
"stock": null,
"sorting": 3,
"image": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
}
}, {
"model": "core.productvariant",
"pk": 22,
"fields": {
"product": 7,
"name": "12 oz",
"sku": "REPLACE_87419",
"price": "12.00",
"weight": "12.0:oz",
"track_inventory": false,
"stock": null,
"sorting": 1,
"image": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
}
}, {
"model": "core.productvariant",
"pk": 23,
"fields": {
"product": 7,
"name": "5 lb",
"sku": "REPLACE_87418",
"price": "75.00",
"weight": "5.0:lb",
"track_inventory": false,
"stock": null,
"sorting": 3,
"image": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
}
}, {
"model": "core.productvariant",
"pk": 24,
"fields": {
"product": 8,
"name": "12 oz",
"sku": "REPLACE_87417",
"price": "12.00",
"weight": "12.0:oz",
"track_inventory": false,
"stock": null,
"sorting": 1,
"image": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
}
}, {
"model": "core.productvariant",
"pk": 25,
"fields": {
"product": 8,
"name": "5 lb",
"sku": "REPLACE_87416",
"price": "75.00",
"weight": "5.0:lb",
"track_inventory": false,
"stock": null,
"sorting": 3,
"image": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
}
}, {
"model": "core.productvariant",
"pk": 26,
"fields": {
"product": 9,
"name": "12 oz",
"sku": "REPLACE_87467",
"price": "12.00",
"weight": "12.0:oz",
"track_inventory": false,
"stock": null,
"sorting": 1,
"image": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
}
}, {
"model": "core.productvariant",
"pk": 27,
"fields": {
"product": 9,
"name": "5 lb",
"sku": "REPLACE_87486",
"price": "75.00",
"weight": "5.0:lb",
"track_inventory": false,
"stock": null,
"sorting": 3,
"image": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
}
}]

View File

@ -0,0 +1,19 @@
[{
"model": "core.shippingrate",
"pk": 1,
"fields": {
"shipping_provider": "USPS",
"name": "Variable",
"container": "VARIABLE",
"min_order_weight": null,
"max_order_weight": null,
"is_selectable": false
}
}, {
"model": "core.sitesettings",
"pk": 1,
"fields": {
"usps_user_id": "012BETTE1249",
"default_shipping_rate": 1
}
}]

View File

@ -0,0 +1,17 @@
# Generated by Django 4.0.2 on 2022-11-02 00:30
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('core', '0002_shippingrate_is_selectable_and_more'),
]
operations = [
migrations.AlterModelOptions(
name='coupon',
options={'ordering': ['valid_from', 'valid_to', 'code']},
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 4.0.2 on 2022-11-02 00:31
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('core', '0003_alter_coupon_options'),
]
operations = [
migrations.AlterModelOptions(
name='coupon',
options={'ordering': ['-valid_from', '-valid_to', 'code']},
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.0.2 on 2022-11-05 00:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0004_alter_coupon_options'),
]
operations = [
migrations.AddField(
model_name='sitesettings',
name='free_shipping_min',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 4.0.2 on 2022-11-05 15:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0005_sitesettings_free_shipping_min'),
]
operations = [
migrations.AddField(
model_name='productvariant',
name='sorting',
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name='sitesettings',
name='free_shipping_min',
field=models.DecimalField(blank=True, decimal_places=2, help_text='Minimum dollar amount in the cart subtotal to qualify for free shipping', max_digits=12, null=True),
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 4.0.2 on 2022-11-05 16:13
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('core', '0006_productvariant_sorting_and_more'),
]
operations = [
migrations.AlterModelOptions(
name='productvariant',
options={'ordering': ['sorting', 'weight']},
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 4.0.2 on 2022-11-05 17:26
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('core', '0007_alter_productvariant_options'),
]
operations = [
migrations.AddField(
model_name='productvariant',
name='image',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='core.productphoto'),
),
]

View File

@ -71,6 +71,14 @@ class ProductCategory(models.Model):
verbose_name_plural = 'Product Categories' verbose_name_plural = 'Product Categories'
class ProductManager(models.Manager):
def get_queryset(self):
return super().get_queryset().prefetch_related(
'productphoto_set',
'options'
)
class Product(models.Model): class Product(models.Model):
category = models.ForeignKey( category = models.ForeignKey(
ProductCategory, ProductCategory,
@ -92,6 +100,8 @@ class Product(models.Model):
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
objects = ProductManager()
def __str__(self): def __str__(self):
return self.name return self.name
@ -111,6 +121,29 @@ class Product(models.Model):
ordering = ['sorting', 'name'] ordering = ['sorting', 'name']
class ProductPhoto(models.Model):
product = models.ForeignKey(Product, on_delete=models.CASCADE)
image = models.ImageField(upload_to='products/images')
def __str__(self):
return f'{self.product.name} {self.image}'
def delete(self, *args, **kwargs):
storage, path = self.image.storage, self.image.path
super(ProductPhoto, self).delete(*args, **kwargs)
storage.delete(path)
# def save(self, *args, **kwargs):
# super().save(*args, **kwargs)
# img = Image.open(self.image.path)
# if img.height > 400 or img.width > 400:
# output_size = (400, 400)
# img.thumbnail(output_size)
# img.save(self.image.path)
class ProductVariantManager(models.Manager): class ProductVariantManager(models.Manager):
def get_queryset(self): def get_queryset(self):
return super().get_queryset().annotate( return super().get_queryset().annotate(
@ -124,6 +157,12 @@ class ProductVariant(models.Model):
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='variants' related_name='variants'
) )
image = models.ForeignKey(
ProductPhoto,
on_delete=models.SET_NULL,
related_name='+',
null=True
)
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
sku = models.CharField(max_length=255, unique=True) sku = models.CharField(max_length=255, unique=True)
stripe_id = models.CharField(max_length=255, blank=True) stripe_id = models.CharField(max_length=255, blank=True)
@ -145,6 +184,7 @@ class ProductVariant(models.Model):
null=True, null=True,
validators=[MinValueValidator(0)] validators=[MinValueValidator(0)]
) )
sorting = models.PositiveIntegerField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
@ -155,7 +195,7 @@ class ProductVariant(models.Model):
return f'{self.product}: {self.name}' return f'{self.product}: {self.name}'
class Meta: class Meta:
ordering = ['weight'] ordering = ['sorting', 'weight']
class ProductOption(models.Model): class ProductOption(models.Model):
@ -178,29 +218,6 @@ class ProductOption(models.Model):
return f'{self.name}' return f'{self.name}'
class ProductPhoto(models.Model):
product = models.ForeignKey(Product, on_delete=models.CASCADE)
image = models.ImageField(upload_to='products/images')
def __str__(self):
return self.product.name
def delete(self, *args, **kwargs):
storage, path = self.image.storage, self.image.path
super(ProductPhoto, self).delete(*args, **kwargs)
storage.delete(path)
# def save(self, *args, **kwargs):
# super().save(*args, **kwargs)
# img = Image.open(self.image.path)
# if img.height > 400 or img.width > 400:
# output_size = (400, 400)
# img.thumbnail(output_size)
# img.save(self.image.path)
class Coupon(models.Model): class Coupon(models.Model):
type = models.CharField( type = models.CharField(
max_length=20, max_length=20,
@ -226,7 +243,7 @@ class Coupon(models.Model):
users = models.ManyToManyField(User, blank=True) users = models.ManyToManyField(User, blank=True)
class Meta: class Meta:
ordering = ("code",) ordering = ['-valid_from', '-valid_to', 'code']
def __str__(self): def __str__(self):
return self.name return self.name
@ -491,6 +508,13 @@ class SiteSettings(SingletonBase):
related_name='+', related_name='+',
on_delete=models.SET_NULL on_delete=models.SET_NULL
) )
free_shipping_min = models.DecimalField(
max_digits=settings.DEFAULT_MAX_DIGITS,
decimal_places=settings.DEFAULT_DECIMAL_PLACES,
blank=True,
null=True,
help_text='Minimum dollar amount in the cart subtotal to qualify for free shipping'
)
def __str__(self): def __str__(self):
return 'Site Settings' return 'Site Settings'

View File

@ -23,10 +23,10 @@ class ProductModelTest(TestCase):
Product.objects.create( Product.objects.create(
name='Pantomime', name='Pantomime',
subtitle='Very Dark French Roast', subtitle='Very Dark French Roast',
description='Our darkest drip. A blend of five different beans roasted two ways. Organic Africa, Indonesia, and South and Central America.', description='Our darkest drip. A blend of five different \
sku='565656', beans roasted two ways. Organic Africa, Indonesia, and \
price=Decimal('15.00'), South and Central America.',
weight=Weight(oz=16), checkout_limit=10,
visible_in_listings=True, visible_in_listings=True,
sorting=1, sorting=1,
) )

View File

@ -3,6 +3,7 @@ from django import forms
from core import OrderStatus from core import OrderStatus
from core.models import ( from core.models import (
ProductVariant,
Order, Order,
OrderLine, OrderLine,
ShippingRate, ShippingRate,
@ -14,6 +15,27 @@ from core.models import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class ProductVariantUpdateForm(forms.ModelForm):
class Meta:
model = ProductVariant
fields = [
'name',
'sku',
'price',
'weight',
'track_inventory',
'stock',
'image'
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance:
self.fields['image'].queryset = ProductPhoto.objects.filter(
product=self.instance.product
)
class CouponForm(forms.ModelForm): class CouponForm(forms.ModelForm):
class Meta: class Meta:
model = Coupon model = Coupon

View File

@ -23,5 +23,17 @@
{% endfor %} {% endfor %}
</div> </div>
</section> </section>
<section class="object__panel">
<div class="object__item panel__header panel__header--flex">
<h4>Site Settings</h4>
<a href="{% url 'dashboard:settings-update' site_settings.pk %}" class="action-button order__fulfill">Edit</a>
</div>
<div class="panel__item">
<p>USPS User ID: {{ site_settings.usps_user_id }}</p>
<p>Default shipping rate: {{ site_settings.default_shipping_rate }}</p>
<p>Free shipping min: ${{ site_settings.free_shipping_min }}</p>
</div>
</section>
</article> </article>
{% endblock %} {% endblock %}

View File

@ -25,12 +25,16 @@
<div class="object__item object__item--col5"> <div class="object__item object__item--col5">
{% with product=item.variant.product %} {% with product=item.variant.product %}
<figure class="item__figure"> <figure class="item__figure">
<img class="product__image product__image--small" src="{{product.get_first_img.image.url}}" alt="{{product.get_first_img.image}}"> {% if item.variant.image %}
<img class="item__image" src="{{item.variant.image.image.url}}" alt="{{item.variant.image.image}}">
{% else %}
<img class="item__image" src="{{product.get_first_img.image.url}}" alt="{{product.get_first_img.image}}">
{% endif %}
<figcaption><strong>{{item.variant}}</strong><br>{{item.customer_note}}</figcaption> <figcaption><strong>{{item.variant}}</strong><br>{{item.customer_note}}</figcaption>
</figure> </figure>
<span>{{product.sku}}</span> <span>{{product.sku}}</span>
<span>{{item.quantity}}</span> <span>{{item.quantity}}</span>
<span>${{item.variant.price}}</span> <span>${{item.unit_price}}</span>
<span>${{item.get_total}}</span> <span>${{item.get_total}}</span>
{% endwith %} {% endwith %}
</div> </div>

View File

@ -10,7 +10,7 @@
{% csrf_token %} {% csrf_token %}
{{form.as_p}} {{form.as_p}}
<p class="form__submit"> <p class="form__submit">
<input class="action-button" type="submit" value="Create product"> or <a href="{% url 'dashboard:product-list' %}">cancel</a> <input class="action-button" type="submit" value="Create product"> or <a href="{% url 'dashboard:catalog' %}">cancel</a>
</p> </p>
</form> </form>
</section> </section>

View File

@ -0,0 +1,18 @@
{% extends "dashboard.html" %}
{% block content %}
<article class="product">
<header class="object__header">
<h1>Update Site Settings</h1>
</header>
<section class="object__panel">
<form class="panel__item" method="POST" action="{% url 'dashboard:settings-update' settings.pk %}">
{% csrf_token %}
{{form.as_p}}
<p class="form__submit">
<input class="action-button" type="submit" value="Save changes"> or <a href="{% url 'dashboard:config' %}">cancel</a>
</p>
</form>
</section>
</article>
{% endblock %}

View File

@ -19,7 +19,11 @@
<div class="object__item object__item--col5"> <div class="object__item object__item--col5">
{% with product=variant.product %} {% with product=variant.product %}
<figure class="item__figure"> <figure class="item__figure">
{% if variant.image %}
<img class="item__image" src="{{variant.image.image.url}}" alt="{{variant.image.image}}">
{% else %}
<img class="product__image product__image--small" src="{{product.get_first_img.image.url}}" alt="{{product.get_first_img.image}}"> <img class="product__image product__image--small" src="{{product.get_first_img.image.url}}" alt="{{product.get_first_img.image}}">
{% endif %}
<figcaption><strong>{{variant}}</strong></figcaption> <figcaption><strong>{{variant}}</strong></figcaption>
</figure> </figure>
<span>{{ variant.sku }}</span> <span>{{ variant.sku }}</span>

View File

@ -20,8 +20,12 @@ from dashboard.forms import (
from dashboard.views import ( from dashboard.views import (
DashboardHomeView, DashboardHomeView,
DashboardConfigView, DashboardConfigView,
ShippingRateCreateView, CatalogView,
StockView,
ShippingRateDetailView, ShippingRateDetailView,
ShippingRateCreateView,
ShippingRateUpdateView,
ShippingRateDeleteView,
CouponListView, CouponListView,
CouponCreateView, CouponCreateView,
CouponDetailView, CouponDetailView,
@ -30,24 +34,74 @@ from dashboard.views import (
OrderListView, OrderListView,
OrderDetailView, OrderDetailView,
OrderFulfillView, OrderFulfillView,
OrderCancelView,
OrderTrackingView, OrderTrackingView,
CategoryListView,
CategoryCreateView,
CategoryDetailView,
CategoryUpdateView,
CategoryDeleteView,
ProductListView, ProductListView,
ProductDetailView, ProductDetailView,
ProductUpdateView,
ProductCreateView, ProductCreateView,
ProductUpdateView,
ProductDeleteView, ProductDeleteView,
ProductPhotoCreateView, ProductPhotoCreateView,
ProductPhotoDeleteView, ProductPhotoDeleteView,
ProductVariantCreateView,
ProductVariantUpdateView,
ProductVariantDeleteView,
ProductVariantStockUpdateView,
ProductOptionDetailView,
ProductOptionCreateView,
ProductOptionUpdateView,
ProductOptionDeleteView,
CustomerListView, CustomerListView,
CustomerDetailView, CustomerDetailView,
CustomerUpdateView CustomerUpdateView,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class ProductCreateViewTests(TestCase):
fixtures = [
'shipping_rates.json',
'accounts.json',
'coupons.json',
'products.json',
'orders.json'
]
@classmethod
def setUpTestData(cls):
cls.admin_user = User.objects.get(pk=1)
def setUp(self):
self.client = Client()
self.client.force_login(self.admin_user)
def test_view_url_exists_at_desired_location(self):
response = self.client.get('/dashboard/products/new/')
self.assertEqual(response.status_code, 200)
def test_view_url_accesible_by_name(self):
response = self.client.get(
reverse('dashboard:product-create')
)
self.assertEqual(response.status_code, 200)
def test_view_uses_correct_template(self):
response = self.client.get(
reverse('dashboard:product-create')
)
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'dashboard/product_create_form.html')
class OrderCancelViewTests(TestCase): class OrderCancelViewTests(TestCase):
fixtures = [ fixtures = [
'shipping_rates.json',
'accounts.json', 'accounts.json',
'coupons.json', 'coupons.json',
'products.json', 'products.json',

View File

@ -12,6 +12,11 @@ urlpatterns = [
views.DashboardConfigView.as_view(), views.DashboardConfigView.as_view(),
name='config' name='config'
), ),
path(
'settings/<int:pk>/update/',
views.SiteSettingsUpdateView.as_view(),
name='settings-update'
),
path( path(
'catalog/', 'catalog/',
views.CatalogView.as_view(), views.CatalogView.as_view(),

View File

@ -2,6 +2,7 @@ import logging
from datetime import datetime from datetime import datetime
from django.conf import settings from django.conf import settings
from django.utils import timezone from django.utils import timezone
from django import forms
from django.shortcuts import render, reverse, redirect, get_object_or_404 from django.shortcuts import render, reverse, redirect, get_object_or_404
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
@ -37,7 +38,8 @@ from core.models import (
ShippingRate, ShippingRate,
Transaction, Transaction,
TrackingNumber, TrackingNumber,
Coupon Coupon,
SiteSettings
) )
from core import ( from core import (
@ -46,6 +48,7 @@ from core import (
OrderStatus OrderStatus
) )
from .forms import ( from .forms import (
ProductVariantUpdateForm,
OrderLineFulfillForm, OrderLineFulfillForm,
OrderLineFormset, OrderLineFormset,
OrderCancelForm, OrderCancelForm,
@ -88,6 +91,15 @@ class DashboardConfigView(TemplateView):
return context return context
class SiteSettingsUpdateView(UpdateView):
model = SiteSettings
context_object_name = 'settings'
template_name = 'dashboard/settings_form.html'
fields = '__all__'
success_url = reverse_lazy('dashboard:config')
success_message = 'Settings saved.'
class CatalogView(ListView): class CatalogView(ListView):
model = ProductCategory model = ProductCategory
context_object_name = 'category_list' context_object_name = 'category_list'
@ -346,13 +358,6 @@ class ProductDetailView(LoginRequiredMixin, DetailView):
return obj return obj
class ProductUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
model = Product
template_name = 'dashboard/product_update_form.html'
fields = '__all__'
success_message = '%(name)s saved.'
class ProductCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): class ProductCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
model = Product model = Product
template_name = 'dashboard/product_create_form.html' template_name = 'dashboard/product_create_form.html'
@ -360,10 +365,17 @@ class ProductCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
success_message = '%(name)s created.' success_message = '%(name)s created.'
class ProductUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
model = Product
template_name = 'dashboard/product_update_form.html'
fields = '__all__'
success_message = '%(name)s saved.'
class ProductDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView): class ProductDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView):
model = Product model = Product
template_name = 'dashboard/product_confirm_delete.html' template_name = 'dashboard/product_confirm_delete.html'
success_url = reverse_lazy('dashboard:product-list') success_url = reverse_lazy('dashboard:catalog')
success_message = 'Product deleted.' success_message = 'Product deleted.'
@ -428,14 +440,7 @@ class ProductVariantUpdateView(SuccessMessageMixin, UpdateView):
pk_url_kwarg = 'variant_pk' pk_url_kwarg = 'variant_pk'
success_message = 'ProductVariant saved.' success_message = 'ProductVariant saved.'
template_name = 'dashboard/variant_form.html' template_name = 'dashboard/variant_form.html'
fields = [ form_class = ProductVariantUpdateForm
'name',
'sku',
'price',
'weight',
'track_inventory',
'stock',
]
context_object_name = 'variant' context_object_name = 'variant'
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):

View File

@ -12,7 +12,7 @@ from django.contrib.staticfiles.testing import StaticLiveServerTestCase
class AddressTests(StaticLiveServerTestCase): class AddressTests(StaticLiveServerTestCase):
fixtures = ['products.json'] fixtures = ['shipping_rates.json', 'products.json']
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):

View File

@ -19,7 +19,9 @@ logger = logging.getLogger(__name__)
class CouponTests(StaticLiveServerTestCase): class CouponTests(StaticLiveServerTestCase):
fixtures = ['products.json', 'accounts.json', 'coupons.json'] fixtures = [
'shipping_rates.json', 'products.json', 'accounts.json', 'coupons.json'
]
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
@ -87,7 +89,8 @@ class CouponTests(StaticLiveServerTestCase):
state_select.select_by_value('UT') state_select.select_by_value('UT')
postal_code_input = self.browser.find_element(By.NAME, 'postal_code') postal_code_input = self.browser.find_element(By.NAME, 'postal_code')
postal_code_input.send_keys('84321') postal_code_input.send_keys('84321')
self.browser.find_element(By.XPATH, self.browser.find_element(
By.XPATH,
'//input[@value="Continue"]' '//input[@value="Continue"]'
).click() ).click()
@ -106,7 +109,8 @@ class CouponTests(StaticLiveServerTestCase):
def test_apply_used_coupon_to_order_returns_message(self): def test_apply_used_coupon_to_order_returns_message(self):
# Add item to cart # Add item to cart
self.browser.get(self.live_server_url + '/products/1/') self.browser.get(self.live_server_url + '/products/1/')
self.browser.find_element(By.XPATH, self.browser.find_element(
By.XPATH,
'//input[@value="Add to cart"]' '//input[@value="Add to cart"]'
).click() ).click()
self.assertEqual( self.assertEqual(
@ -118,7 +122,8 @@ class CouponTests(StaticLiveServerTestCase):
coupon_input = self.browser.find_element(By.ID, 'id_code') coupon_input = self.browser.find_element(By.ID, 'id_code')
coupon_input.send_keys('MAY2022') coupon_input.send_keys('MAY2022')
self.browser.find_element(By.XPATH, '//input[@value="Apply"]').click() self.browser.find_element(By.XPATH, '//input[@value="Apply"]').click()
self.browser.find_element(By.XPATH, self.browser.find_element(
By.XPATH,
'//a[contains(text(), "Proceed to Checkout")]' '//a[contains(text(), "Proceed to Checkout")]'
).click() ).click()

View File

@ -5,13 +5,21 @@ load_dotenv()
DEBUG = os.environ.get('DEBUG', 'True') == 'True' DEBUG = os.environ.get('DEBUG', 'True') == 'True'
DATABASE_CONFIG = { if DEBUG:
'ENGINE': 'django.db.backends.postgresql', DATABASE_CONFIG = {
'OPTIONS': { 'ENGINE': 'django.db.backends.postgresql',
'service': 'pg_service', 'USER': 'django',
'passfile': '.pgpass' 'NAME': 'ptcoffee_dev'
} }
} else:
DATABASE_CONFIG = {
'ENGINE': 'django.db.backends.postgresql',
'OPTIONS': {
'service': 'pg_service',
'passfile': '.pgpass'
}
}
SECRET_KEY = os.environ.get('SECRET_KEY', '') SECRET_KEY = os.environ.get('SECRET_KEY', '')
CACHE_CONFIG = { CACHE_CONFIG = {
'LOCATION': 'redis://127.0.0.1:6379', 'LOCATION': 'redis://127.0.0.1:6379',

View File

@ -9,9 +9,13 @@ const productItemImages = document.querySelectorAll('.product__with-img-swap')
productItemImages.forEach(productItemImage => { productItemImages.forEach(productItemImage => {
productItemImage.addEventListener('mouseover', event => { productItemImage.addEventListener('mouseover', event => {
[event.target.src, event.target.dataset.altimgSrc] = valueSwap(event.target.src, event.target.dataset.altimgSrc) if (event.target.dataset.altimgSrc != '') {
[event.target.src, event.target.dataset.altimgSrc] = valueSwap(event.target.src, event.target.dataset.altimgSrc)
}
}) })
productItemImage.addEventListener('mouseout', event => { productItemImage.addEventListener('mouseout', event => {
[event.target.src, event.target.dataset.altimgSrc] = valueSwap(event.target.src, event.target.dataset.altimgSrc) if (event.target.dataset.altimgSrc != '') {
[event.target.src, event.target.dataset.altimgSrc] = valueSwap(event.target.src, event.target.dataset.altimgSrc)
}
}) })
}) })

View File

@ -441,13 +441,14 @@ section:not(:last-child) {
/* Site Banner /* Site Banner
========================================================================== */ ========================================================================== */
.site__banner { .site__banner {
background-color: rgba(0, 0, 0, 0.44); background-color: rgba(0, 0, 0, 0.6);
background-blend-mode: multiply; background-blend-mode: multiply;
background-size: cover; background-size: cover;
background-position: center; background-position: center;
color: #f1e8e2; color: #f1e8e2;
text-align: center; text-align: center;
padding: 2rem 1rem; padding: 2rem 1rem;
text-shadow: 1px 1px 2px black;
font-family: 'Vollkorn', serif; font-family: 'Vollkorn', serif;
} }
@ -792,6 +793,10 @@ article + article {
text-align: right; text-align: right;
} }
.item__variant {
color: var(--red-color);
}
.item__form, .item__form,
.coupon__form p { .coupon__form p {
display: flex; display: flex;

View File

@ -6,10 +6,12 @@ from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.shortcuts import redirect, reverse from django.shortcuts import redirect, reverse
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.core.cache import cache
from django.db.models import OuterRef, Q, Subquery from django.db.models import OuterRef, Q, Subquery
from core.models import ( from core.models import (
Product, ProductVariant, OrderLine, Coupon, ShippingRate Product, ProductVariant, OrderLine, Coupon, ShippingRate,
SiteSettings
) )
from core.usps import USPSApi from core.usps import USPSApi
from core import ( from core import (
@ -58,6 +60,7 @@ class CartItem:
class Cart: class Cart:
item_class = CartItem item_class = CartItem
# site_settings = SiteSettings.load()
def __init__(self, request): def __init__(self, request):
self.request = request self.request = request
@ -101,8 +104,15 @@ class Cart:
for item in self: for item in self:
if item['variant'].track_inventory: if item['variant'].track_inventory:
if item['quantity'] > item['variant'].stock: if item['quantity'] > item['variant'].stock:
messages.warning(request, 'Quantity added exceeds available stock.') if item['quantity'] > item['variant'].product.checkout_limit:
messages.warning(request, 'Quantity exceeds checkout limit.')
item['quantity'] = item['variant'].product.checkout_limit
continue
messages.warning(request, 'Quantity exceeds available stock.')
item['quantity'] = item['variant'].stock item['quantity'] = item['variant'].stock
elif item['quantity'] > item['variant'].product.checkout_limit:
messages.warning(request, 'Quantity exceeds checkout limit.')
item['quantity'] = item['variant'].product.checkout_limit
self.save() self.save()
def remove(self, pk): def remove(self, pk):
@ -160,6 +170,10 @@ class Cart:
return containers return containers
def get_shipping_cost(self, container=None): def get_shipping_cost(self, container=None):
# free_shipping_min = self.site_settings.free_shipping_min
# if self.get_total_price() >= free_shipping_min:
# return Decimal('0.00')
if container is None: if container is None:
container = self.session.get('shipping_container').container container = self.session.get('shipping_container').container

View File

@ -13,11 +13,15 @@
<div class="cart__item"> <div class="cart__item">
{% with product=item.variant.product %} {% with product=item.variant.product %}
<figure class="item__figure"> <figure class="item__figure">
{% if item.variant.image %}
<img class="item__image" src="{{item.variant.image.image.url}}" alt="{{item.variant.image.image}}">
{% else %}
<img class="item__image" src="{{product.get_first_img.image.url}}" alt="{{product.get_first_img.image}}"> <img class="item__image" src="{{product.get_first_img.image.url}}" alt="{{product.get_first_img.image}}">
{% endif %}
</figure> </figure>
<div class="item__info"> <div class="item__info">
<h3>{{product.name}}</h3> <h3>{{product.name}}</h3>
<h4>{{ item.variant.name }}</h4> <h2 class="item__variant">{{ item.variant.name }}</h2>
{% for key, value in item.options.items %} {% for key, value in item.options.items %}
<p><strong>{{ key }}</strong>: {{ value }}</p> <p><strong>{{ key }}</strong>: {{ value }}</p>
{% endfor %} {% endfor %}
@ -54,6 +58,7 @@
<input type="submit" value="Apply" class="action-button"> <input type="submit" value="Apply" class="action-button">
</p> </p>
</form> </form>
<!-- <h5>Free shipping on orders over ${{ site_settings.free_shipping_min|floatformat:"2" }}</h5> -->
<div class="cart__table-wrapper"> <div class="cart__table-wrapper">
<table class="cart__totals"> <table class="cart__totals">
<tr> <tr>

View File

@ -23,14 +23,18 @@
<tr> <tr>
{% with product=item.variant.product %} {% with product=item.variant.product %}
<td> <td>
{% if item.variant.image %}
<img class="line__image" src="{{item.variant.image.image.url}}" alt="{{item.variant.image.image}}">
{% else %}
<img class="line__image" src="{{product.get_first_img.image.url}}" alt="{{product.get_first_img.image}}"> <img class="line__image" src="{{product.get_first_img.image.url}}" alt="{{product.get_first_img.image}}">
{% endif %}
</td> </td>
<td> <td>
<strong>{{ item.variant }}</strong><br> <strong>{{ item.variant }}</strong><br>
{{item.customer_note}} {{item.customer_note}}
</td> </td>
<td>{{item.quantity}}</td> <td>{{item.quantity}}</td>
<td>${{product.price}}</td> <td>${{item.unit_price}}</td>
<td>${{item.get_total}}</td> <td>${{item.get_total}}</td>
{% endwith %} {% endwith %}
</tr> </tr>

View File

@ -34,7 +34,11 @@
<div class="cart__item"> <div class="cart__item">
{% with product=item.variant.product %} {% with product=item.variant.product %}
<figure> <figure>
{% if item.variant.image %}
<img src="{{item.variant.image.image.url}}" alt="{{item.variant.image.image}}">
{% else %}
<img src="{{product.get_first_img.image.url}}" alt="{{product.get_first_img.image}}"> <img src="{{product.get_first_img.image.url}}" alt="{{product.get_first_img.image}}">
{% endif %}
</figure> </figure>
<div> <div>
<h3>{{product.name}}</h3> <h3>{{product.name}}</h3>
@ -75,7 +79,7 @@
{% endif %} {% endif %}
<tr> <tr>
<td>Shipping</td> <td>Shipping</td>
<td>${{ cart.get_shipping_cost }}</small></td> <td>${{ cart.get_shipping_cost }}</td>
</tr> </tr>
<tr> <tr>
<th>Total</th> <th>Total</th>

View File

@ -15,8 +15,7 @@
{% block content %} {% block content %}
<div class="site__banner site__banner--site"> <div class="site__banner site__banner--site">
<h1>Welcome to our new website!</h1> <h1>Now three different size bags to choose from!</h1>
<h4>NEW COOL LOOK, SAME GREAT COFFEE</h4>
</div> </div>
<article> <article>
<section class="product__list"> <section class="product__list">

View File

@ -178,6 +178,11 @@ class ProductCategoryDetailView(DetailView):
Q(visible_in_listings=True), Q(visible_in_listings=True),
Q(variants__track_inventory=False) | Q(variants__track_inventory=False) |
Q(variants__track_inventory=True) & Q(variants__stock__gt=0) Q(variants__track_inventory=True) & Q(variants__stock__gt=0)
).prefetch_related(
Prefetch(
'variants',
queryset=ProductVariant.objects.all().order_by('sorting', 'weight')
)
).distinct() ).distinct()
) )
) )
@ -192,6 +197,11 @@ class ProductListView(ListView):
queryset = Product.objects.filter( queryset = Product.objects.filter(
visible_in_listings=True, visible_in_listings=True,
category__main_category=True category__main_category=True
).prefetch_related(
Prefetch(
'variants',
queryset=ProductVariant.objects.all().order_by('sorting', 'weight')
)
) )
@ -206,7 +216,7 @@ class ProductDetailView(FormMixin, DetailView):
track_inventory=True, track_inventory=True,
stock__gt=0 stock__gt=0
) )
) ).order_by('name')
options = ProductOption.objects.filter(products__pk=self.object.pk) options = ProductOption.objects.filter(products__pk=self.object.pk)
if form_class is None: if form_class is None:
form_class = self.get_form_class() form_class = self.get_form_class()