Add basic product variant display to detail page

This commit is contained in:
Nathan Chapman 2022-09-07 15:26:37 -06:00
parent 27a0f94c4e
commit 8dfce8e92b
18 changed files with 349 additions and 64 deletions

View File

@ -66,13 +66,15 @@ class TransactionStatus:
] ]
class ShippingMethodType: class ShippingProvider:
PRICE_BASED = 'price' USPS = 'USPS'
WEIGHT_BASED = 'weight' # UPS = 'UPS'
# FEDEX = 'FEDEX'
CHOICES = [ CHOICES = [
(PRICE_BASED, 'Price based shipping'), (USPS, 'USPS'),
(WEIGHT_BASED, 'Weight based shipping'), # (UPS, 'UPS'),
# (FEDEX, 'FedEx'),
] ]

View File

@ -1,19 +1,25 @@
from django.contrib import admin from django.contrib import admin
from .models import ( from .models import (
ProductCategory,
Product, Product,
ProductPhoto, ProductPhoto,
ProductVariant,
ProductOption,
Coupon, Coupon,
ShippingMethod, ShippingRate,
Order, Order,
Transaction, Transaction,
OrderLine, OrderLine,
) )
admin.site.register(ProductCategory)
admin.site.register(Product) admin.site.register(Product)
admin.site.register(ProductPhoto) admin.site.register(ProductPhoto)
admin.site.register(ProductVariant)
admin.site.register(ProductOption)
admin.site.register(Coupon) admin.site.register(Coupon)
admin.site.register(ShippingMethod) admin.site.register(ShippingRate)
admin.site.register(Order) admin.site.register(Order)
admin.site.register(Transaction) admin.site.register(Transaction)
admin.site.register(OrderLine) admin.site.register(OrderLine)

View File

@ -0,0 +1,5 @@
from .models import SiteSettings
def site_settings(request):
return {'site_settings': SiteSettings.load()}

View File

@ -2,11 +2,11 @@ import logging
from django import forms from django import forms
from django.core.mail import EmailMessage from django.core.mail import EmailMessage
from core.models import Order, OrderLine, ShippingMethod from core.models import Order, OrderLine, ShippingRate
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class ShippingMethodForm(forms.ModelForm): class ShippingRateForm(forms.ModelForm):
class Meta: class Meta:
model = ShippingMethod model = ShippingRate
fields = '__all__' fields = '__all__'

View File

@ -0,0 +1,130 @@
# Generated by Django 4.0.2 on 2022-09-07 20:34
from django.conf import settings
import django.contrib.postgres.fields
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import django_measurement.models
import measurement.measures.mass
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('core', '0010_product_stripe_id'),
]
operations = [
migrations.CreateModel(
name='ProductCategory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
],
options={
'verbose_name': 'Product Category',
'verbose_name_plural': 'Product Categories',
},
),
migrations.CreateModel(
name='ProductOption',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('options', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=255), size=None)),
],
),
migrations.CreateModel(
name='ProductVariant',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('sku', models.CharField(max_length=255, unique=True)),
('stripe_id', models.CharField(blank=True, max_length=255)),
('price', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
('weight', django_measurement.models.MeasurementField(blank=True, measurement=measurement.measures.mass.Mass, null=True)),
('track_inventory', models.BooleanField(default=False)),
('stock', models.IntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(0)])),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
),
migrations.CreateModel(
name='ShippingRate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('shipping_provider', models.CharField(choices=[('USPS', 'USPS')], default='USPS', max_length=255)),
('name', models.CharField(max_length=255)),
('container', models.CharField(choices=[('LG FLAT RATE BOX', 'Flate Rate Box - Large'), ('MD FLAT RATE BOX', 'Flate Rate Box - Medium'), ('REGIONALRATEBOXA', 'Regional Rate Box A'), ('REGIONALRATEBOXB', 'Regional Rate Box B'), ('VARIABLE', 'Variable')], default='VARIABLE', max_length=255)),
('min_order_weight', models.PositiveIntegerField(blank=True, null=True)),
('max_order_weight', models.PositiveIntegerField(blank=True, null=True)),
],
options={
'ordering': ['min_order_weight'],
},
),
migrations.CreateModel(
name='SiteSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('usps_user_id', models.CharField(max_length=255)),
],
options={
'verbose_name': 'Site Settings',
'verbose_name_plural': 'Site Settings',
},
),
migrations.CreateModel(
name='Subscription',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('stripe_id', models.CharField(blank=True, max_length=255)),
('customer', models.OneToOneField(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='subscription', to=settings.AUTH_USER_MODEL)),
],
),
migrations.RemoveField(
model_name='order',
name='shipping_method',
),
migrations.RemoveField(
model_name='product',
name='price',
),
migrations.RemoveField(
model_name='product',
name='sku',
),
migrations.RemoveField(
model_name='product',
name='stripe_id',
),
migrations.RemoveField(
model_name='product',
name='weight',
),
migrations.AddField(
model_name='product',
name='checkout_limit',
field=models.IntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)]),
),
migrations.DeleteModel(
name='ShippingMethod',
),
migrations.AddField(
model_name='productvariant',
name='product',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.product'),
),
migrations.AddField(
model_name='productoption',
name='product',
field=models.ManyToManyField(related_name='options', to='core.Product'),
),
migrations.AddField(
model_name='product',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='core.productcategory'),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 4.0.2 on 2022-09-07 21:24
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('core', '0011_productcategory_productoption_productvariant_and_more'),
]
operations = [
migrations.AlterField(
model_name='productvariant',
name='product',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='variants', to='core.product'),
),
]

View File

@ -8,8 +8,10 @@ from django.db.models.functions import Coalesce
from django.conf import settings from django.conf import settings
from django.utils import timezone from django.utils import timezone
from django.urls import reverse from django.urls import reverse
from django.core.cache import cache
from django.core.validators import MinValueValidator, MaxValueValidator from django.core.validators import MinValueValidator, MaxValueValidator
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
from django.contrib.postgres.fields import ArrayField, HStoreField
from django.forms.models import model_to_dict from django.forms.models import model_to_dict
from django_measurement.models import MeasurementField from django_measurement.models import MeasurementField
@ -21,17 +23,58 @@ from . import (
VoucherType, VoucherType,
TransactionStatus, TransactionStatus,
OrderStatus, OrderStatus,
ShippingMethodType ShippingProvider,
ShippingContainer
) )
from .weight import WeightUnits, zero_weight from .weight import WeightUnits, zero_weight
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class ProductEncoder(DjangoJSONEncoder): class SingletonBase(models.Model):
def default(self, obj): def set_cache(self):
logger.info(f"\n{obj}\n") cache.set(self.__class__.__name__, self)
return super().default(obj)
def save(self, *args, **kwargs):
self.pk = 1
super(SingletonBase, self).save(*args, **kwargs)
self.set_cache()
def delete(self, *args, **kwargs):
pass
@classmethod
def load(cls):
if cache.get(cls.__name__) is None:
obj, created = cls.objects.get_or_create(pk=1)
if not created:
obj.set_cache()
return cache.get(cls.__name__)
class Meta:
abstract = True
class SiteSettings(SingletonBase):
usps_user_id = models.CharField(max_length=255)
def __str__(self):
return 'Site Settings'
class Meta:
verbose_name = 'Site Settings'
verbose_name_plural = 'Site Settings'
class ProductCategory(models.Model):
name = models.CharField(max_length=255)
def __str__(self):
return self.name
class Meta:
verbose_name = 'Product Category'
verbose_name_plural = 'Product Categories'
class ProductManager(models.Manager): class ProductManager(models.Manager):
@ -42,22 +85,18 @@ class ProductManager(models.Manager):
class Product(models.Model): class Product(models.Model):
category = models.ForeignKey(
ProductCategory,
blank=True,
null=True,
on_delete=models.SET_NULL
)
name = models.CharField(max_length=250) name = models.CharField(max_length=250)
subtitle = models.CharField(max_length=250, blank=True) subtitle = models.CharField(max_length=250, blank=True)
description = models.TextField(blank=True) description = models.TextField(blank=True)
sku = models.CharField(max_length=255, unique=True) checkout_limit = models.IntegerField(
stripe_id = models.CharField(max_length=255, blank=True) default=0,
price = models.DecimalField( validators=[MinValueValidator(0)]
max_digits=settings.DEFAULT_MAX_DIGITS,
decimal_places=settings.DEFAULT_DECIMAL_PLACES,
blank=True,
null=True,
)
weight = MeasurementField(
measurement=Weight,
unit_choices=WeightUnits.CHOICES,
blank=True,
null=True
) )
visible_in_listings = models.BooleanField(default=False) visible_in_listings = models.BooleanField(default=False)
@ -87,6 +126,61 @@ class Product(models.Model):
ordering = ['sorting', 'name'] ordering = ['sorting', 'name']
class ProductVariant(models.Model):
product = models.ForeignKey(
Product,
on_delete=models.CASCADE,
related_name='variants'
)
name = models.CharField(max_length=255)
sku = models.CharField(max_length=255, unique=True)
stripe_id = models.CharField(max_length=255, blank=True)
price = models.DecimalField(
max_digits=settings.DEFAULT_MAX_DIGITS,
decimal_places=settings.DEFAULT_DECIMAL_PLACES,
blank=True,
null=True,
)
weight = MeasurementField(
measurement=Weight,
unit_choices=WeightUnits.CHOICES,
blank=True,
null=True
)
track_inventory = models.BooleanField(default=False)
stock = models.IntegerField(
blank=True,
null=True,
validators=[MinValueValidator(0)]
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f'{self.product}: {self.name}'
class Meta:
pass
class ProductOption(models.Model):
"""
Description: Consistent accross all variants
"""
product = models.ManyToManyField(
Product,
related_name='options'
)
name = models.CharField(max_length=255)
options = ArrayField(
models.CharField(max_length=255)
)
def __str__(self):
return f'{self.name}'
class ProductPhoto(models.Model): class ProductPhoto(models.Model):
product = models.ForeignKey(Product, on_delete=models.CASCADE) product = models.ForeignKey(Product, on_delete=models.CASCADE)
image = models.ImageField(upload_to='products/images') image = models.ImageField(upload_to='products/images')
@ -149,17 +243,32 @@ class Coupon(models.Model):
return reverse('dashboard:coupon-detail', kwargs={'pk': self.pk}) return reverse('dashboard:coupon-detail', kwargs={'pk': self.pk})
class ShippingMethod(models.Model): class ShippingRate(models.Model):
name = models.CharField(max_length=100) shipping_provider = models.CharField(
type = models.CharField(max_length=30, choices=ShippingMethodType.CHOICES) max_length=255,
price = models.DecimalField( choices=ShippingProvider.CHOICES,
max_digits=settings.DEFAULT_MAX_DIGITS, default=ShippingProvider.USPS
decimal_places=settings.DEFAULT_DECIMAL_PLACES, )
default=0, name = models.CharField(max_length=255)
container = models.CharField(
max_length=255,
choices=ShippingContainer.CHOICES,
default=ShippingContainer.VARIABLE
)
min_order_weight = models.PositiveIntegerField(
blank=True,
null=True
)
max_order_weight = models.PositiveIntegerField(
blank=True,
null=True
) )
def get_absolute_url(self): def __str__(self):
return reverse('dashboard:shipmeth-detail', kwargs={'pk': self.pk}) return f'{self.shipping_provider}: {self.name} ({self.min_order_weight}{self.max_order_weight})'
class Meta:
ordering = ['min_order_weight']
class OrderManager(models.Manager): class OrderManager(models.Manager):
@ -210,13 +319,6 @@ class Order(models.Model):
null=True, null=True,
on_delete=models.SET_NULL on_delete=models.SET_NULL
) )
shipping_method = models.ForeignKey(
ShippingMethod,
blank=True,
null=True,
related_name="orders",
on_delete=models.SET_NULL
)
coupon = models.ForeignKey( coupon = models.ForeignKey(
Coupon, Coupon,

View File

@ -7,7 +7,7 @@ from core.models import (
Product, Product,
ProductPhoto, ProductPhoto,
Coupon, Coupon,
ShippingMethod, ShippingRate,
Order, Order,
Transaction, Transaction,
OrderLine, OrderLine,

View File

@ -1,14 +1,15 @@
from measurement.measures import Weight from measurement.measures import Weight
class WeightUnits: class WeightUnits:
# KILOGRAM = "kg" # KILOGRAM = "kg"
# POUND = "lb" POUND = "lb"
OUNCE = "oz" OUNCE = "oz"
# GRAM = "g" # GRAM = "g"
CHOICES = [ CHOICES = [
# (KILOGRAM, "kg"), # (KILOGRAM, "kg"),
# (POUND, "lb"), (POUND, "lb"),
(OUNCE, "oz"), (OUNCE, "oz"),
# (GRAM, "g"), # (GRAM, "g"),
] ]

View File

@ -5,7 +5,7 @@ from core import OrderStatus
from core.models import ( from core.models import (
Order, Order,
OrderLine, OrderLine,
ShippingMethod, ShippingRate,
TrackingNumber, TrackingNumber,
Coupon, Coupon,
ProductPhoto ProductPhoto

View File

@ -20,8 +20,8 @@ from dashboard.forms import (
from dashboard.views import ( from dashboard.views import (
DashboardHomeView, DashboardHomeView,
DashboardConfigView, DashboardConfigView,
ShippingMethodCreateView, ShippingRateCreateView,
ShippingMethodDetailView, ShippingRateDetailView,
CouponListView, CouponListView,
CouponCreateView, CouponCreateView,
CouponDetailView, CouponDetailView,

View File

@ -15,13 +15,13 @@ urlpatterns = [
path( path(
'shipping-methods/new/', 'shipping-methods/new/',
views.ShippingMethodCreateView.as_view(), views.ShippingRateCreateView.as_view(),
name='shipmeth-create' name='shipmeth-create'
), ),
path('shipping-methods/<int:pk>/', include([ path('shipping-methods/<int:pk>/', include([
path( path(
'', '',
views.ShippingMethodDetailView.as_view(), views.ShippingRateDetailView.as_view(),
name='shipmeth-detail' name='shipmeth-detail'
), ),
])), ])),

View File

@ -30,7 +30,7 @@ from core.models import (
ProductPhoto, ProductPhoto,
Order, Order,
OrderLine, OrderLine,
ShippingMethod, ShippingRate,
Transaction, Transaction,
TrackingNumber, TrackingNumber,
Coupon Coupon
@ -39,8 +39,7 @@ from core.models import (
from core import ( from core import (
DiscountValueType, DiscountValueType,
VoucherType, VoucherType,
OrderStatus, OrderStatus
ShippingMethodType
) )
from .forms import ( from .forms import (
OrderLineFulfillForm, OrderLineFulfillForm,
@ -83,20 +82,20 @@ class DashboardConfigView(TemplateView):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
today = timezone.localtime(timezone.now()).date() today = timezone.localtime(timezone.now()).date()
context['shipping_method_list'] = ShippingMethod.objects.all() context['shipping_method_list'] = ShippingRate.objects.all()
return context return context
class ShippingMethodCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): class ShippingRateCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
model = ShippingMethod model = ShippingRate
template_name = 'dashboard/shipmeth_create_form.html' template_name = 'dashboard/shipmeth_create_form.html'
fields = '__all__' fields = '__all__'
success_message = '%(name)s created.' success_message = '%(name)s created.'
class ShippingMethodDetailView(LoginRequiredMixin, DetailView): class ShippingRateDetailView(LoginRequiredMixin, DetailView):
model = ShippingMethod model = ShippingRate
template_name = 'dashboard/shipmeth_detail.html' template_name = 'dashboard/shipmeth_detail.html'

View File

@ -85,6 +85,7 @@ TEMPLATES = [
'django.template.context_processors.request', 'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth', 'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages', 'django.contrib.messages.context_processors.messages',
'core.context_processors.site_settings',
'storefront.context_processors.cart', 'storefront.context_processors.cart',
], ],
}, },

View File

@ -14,7 +14,6 @@ from core import (
VoucherType, VoucherType,
TransactionStatus, TransactionStatus,
OrderStatus, OrderStatus,
ShippingMethodType,
ShippingService, ShippingService,
ShippingContainer, ShippingContainer,
CoffeeGrind CoffeeGrind

View File

@ -1,5 +1,6 @@
from .cart import Cart from .cart import Cart
def cart(request): def cart(request):
return { return {
'cart': Cart(request) 'cart': Cart(request)

View File

@ -18,9 +18,20 @@ logger = logging.getLogger(__name__)
class AddToCartForm(forms.Form): class AddToCartForm(forms.Form):
grind = forms.ChoiceField(choices=CoffeeGrind.GRIND_CHOICES) # grind = forms.ChoiceField(choices=CoffeeGrind.GRIND_CHOICES)
variants = forms.ChoiceField(widget=forms.RadioSelect())
quantity = forms.IntegerField(min_value=1, max_value=20, initial=1) quantity = forms.IntegerField(min_value=1, max_value=20, initial=1)
def __init__(self, variants, options, *args, **kwargs):
super().__init__(*args, **kwargs)
for option in options:
self.fields[option.name] = forms.ChoiceField(
choices=[(opt, opt) for opt in option.options]
)
self.fields['variants'].widget.choices = [(variant.pk, variant.name) for variant in variants]
class UpdateCartItemForm(forms.Form): class UpdateCartItemForm(forms.Form):
quantity = forms.IntegerField(min_value=1, max_value=20, initial=1) quantity = forms.IntegerField(min_value=1, max_value=20, initial=1)

View File

@ -31,8 +31,10 @@ from accounts.utils import get_or_create_customer
from accounts.forms import ( from accounts.forms import (
AddressForm as AccountAddressForm, CustomerUpdateForm AddressForm as AccountAddressForm, CustomerUpdateForm
) )
from core.models import Product, Order, Transaction, OrderLine, Coupon from core.models import (
from core.forms import ShippingMethodForm Product, ProductOption, Order, Transaction, OrderLine, Coupon
)
from core.forms import ShippingRateForm
from core import OrderStatus, ShippingContainer from core import OrderStatus, ShippingContainer
from .forms import ( from .forms import (
@ -161,6 +163,13 @@ class ProductDetailView(FormMixin, DetailView):
template_name = 'storefront/product_detail.html' template_name = 'storefront/product_detail.html'
form_class = AddToCartForm form_class = AddToCartForm
def get_form(self, form_class=None):
variants = self.object.variants.all()
options = ProductOption.objects.filter(product__pk=self.object.pk)
if form_class is None:
form_class = self.get_form_class()
return form_class(variants, options, **self.get_form_kwargs())
class CheckoutAddressView(FormView): class CheckoutAddressView(FormView):
template_name = 'storefront/checkout_address.html' template_name = 'storefront/checkout_address.html'