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:
PRICE_BASED = 'price'
WEIGHT_BASED = 'weight'
class ShippingProvider:
USPS = 'USPS'
# UPS = 'UPS'
# FEDEX = 'FEDEX'
CHOICES = [
(PRICE_BASED, 'Price based shipping'),
(WEIGHT_BASED, 'Weight based shipping'),
(USPS, 'USPS'),
# (UPS, 'UPS'),
# (FEDEX, 'FedEx'),
]

View File

@ -1,19 +1,25 @@
from django.contrib import admin
from .models import (
ProductCategory,
Product,
ProductPhoto,
ProductVariant,
ProductOption,
Coupon,
ShippingMethod,
ShippingRate,
Order,
Transaction,
OrderLine,
)
admin.site.register(ProductCategory)
admin.site.register(Product)
admin.site.register(ProductPhoto)
admin.site.register(ProductVariant)
admin.site.register(ProductOption)
admin.site.register(Coupon)
admin.site.register(ShippingMethod)
admin.site.register(ShippingRate)
admin.site.register(Order)
admin.site.register(Transaction)
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.core.mail import EmailMessage
from core.models import Order, OrderLine, ShippingMethod
from core.models import Order, OrderLine, ShippingRate
logger = logging.getLogger(__name__)
class ShippingMethodForm(forms.ModelForm):
class ShippingRateForm(forms.ModelForm):
class Meta:
model = ShippingMethod
model = ShippingRate
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.utils import timezone
from django.urls import reverse
from django.core.cache import cache
from django.core.validators import MinValueValidator, MaxValueValidator
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_measurement.models import MeasurementField
@ -21,17 +23,58 @@ from . import (
VoucherType,
TransactionStatus,
OrderStatus,
ShippingMethodType
ShippingProvider,
ShippingContainer
)
from .weight import WeightUnits, zero_weight
logger = logging.getLogger(__name__)
class ProductEncoder(DjangoJSONEncoder):
def default(self, obj):
logger.info(f"\n{obj}\n")
return super().default(obj)
class SingletonBase(models.Model):
def set_cache(self):
cache.set(self.__class__.__name__, self)
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):
@ -42,22 +85,18 @@ class ProductManager(models.Manager):
class Product(models.Model):
category = models.ForeignKey(
ProductCategory,
blank=True,
null=True,
on_delete=models.SET_NULL
)
name = models.CharField(max_length=250)
subtitle = models.CharField(max_length=250, blank=True)
description = models.TextField(blank=True)
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
checkout_limit = models.IntegerField(
default=0,
validators=[MinValueValidator(0)]
)
visible_in_listings = models.BooleanField(default=False)
@ -87,6 +126,61 @@ class Product(models.Model):
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):
product = models.ForeignKey(Product, on_delete=models.CASCADE)
image = models.ImageField(upload_to='products/images')
@ -149,17 +243,32 @@ class Coupon(models.Model):
return reverse('dashboard:coupon-detail', kwargs={'pk': self.pk})
class ShippingMethod(models.Model):
name = models.CharField(max_length=100)
type = models.CharField(max_length=30, choices=ShippingMethodType.CHOICES)
price = models.DecimalField(
max_digits=settings.DEFAULT_MAX_DIGITS,
decimal_places=settings.DEFAULT_DECIMAL_PLACES,
default=0,
class ShippingRate(models.Model):
shipping_provider = models.CharField(
max_length=255,
choices=ShippingProvider.CHOICES,
default=ShippingProvider.USPS
)
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):
return reverse('dashboard:shipmeth-detail', kwargs={'pk': self.pk})
def __str__(self):
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):
@ -210,13 +319,6 @@ class Order(models.Model):
null=True,
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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -18,9 +18,20 @@ logger = logging.getLogger(__name__)
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)
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):
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 (
AddressForm as AccountAddressForm, CustomerUpdateForm
)
from core.models import Product, Order, Transaction, OrderLine, Coupon
from core.forms import ShippingMethodForm
from core.models import (
Product, ProductOption, Order, Transaction, OrderLine, Coupon
)
from core.forms import ShippingRateForm
from core import OrderStatus, ShippingContainer
from .forms import (
@ -161,6 +163,13 @@ class ProductDetailView(FormMixin, DetailView):
template_name = 'storefront/product_detail.html'
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):
template_name = 'storefront/checkout_address.html'