diff --git a/src/core/__init__.py b/src/core/__init__.py index 16798fe..cf1d22b 100644 --- a/src/core/__init__.py +++ b/src/core/__init__.py @@ -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'), ] diff --git a/src/core/admin.py b/src/core/admin.py index 62c9ccc..6e41d90 100644 --- a/src/core/admin.py +++ b/src/core/admin.py @@ -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) diff --git a/src/core/context_processors.py b/src/core/context_processors.py new file mode 100644 index 0000000..eb6f1c8 --- /dev/null +++ b/src/core/context_processors.py @@ -0,0 +1,5 @@ +from .models import SiteSettings + + +def site_settings(request): + return {'site_settings': SiteSettings.load()} diff --git a/src/core/forms.py b/src/core/forms.py index 50990a6..46b1d1f 100644 --- a/src/core/forms.py +++ b/src/core/forms.py @@ -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__' diff --git a/src/core/migrations/0011_productcategory_productoption_productvariant_and_more.py b/src/core/migrations/0011_productcategory_productoption_productvariant_and_more.py new file mode 100644 index 0000000..8dce6ee --- /dev/null +++ b/src/core/migrations/0011_productcategory_productoption_productvariant_and_more.py @@ -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'), + ), + ] diff --git a/src/core/migrations/0012_alter_productvariant_product.py b/src/core/migrations/0012_alter_productvariant_product.py new file mode 100644 index 0000000..52796fb --- /dev/null +++ b/src/core/migrations/0012_alter_productvariant_product.py @@ -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'), + ), + ] diff --git a/src/core/models.py b/src/core/models.py index c286179..e1ed7bd 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -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, diff --git a/src/core/tests/test_models.py b/src/core/tests/test_models.py index 59935f9..841f14f 100644 --- a/src/core/tests/test_models.py +++ b/src/core/tests/test_models.py @@ -7,7 +7,7 @@ from core.models import ( Product, ProductPhoto, Coupon, - ShippingMethod, + ShippingRate, Order, Transaction, OrderLine, diff --git a/src/core/weight.py b/src/core/weight.py index baf9dd0..a3a6dc3 100644 --- a/src/core/weight.py +++ b/src/core/weight.py @@ -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"), ] diff --git a/src/dashboard/forms.py b/src/dashboard/forms.py index 56aaa53..3d75eff 100644 --- a/src/dashboard/forms.py +++ b/src/dashboard/forms.py @@ -5,7 +5,7 @@ from core import OrderStatus from core.models import ( Order, OrderLine, - ShippingMethod, + ShippingRate, TrackingNumber, Coupon, ProductPhoto diff --git a/src/dashboard/tests/test_views.py b/src/dashboard/tests/test_views.py index ab814ad..b054ba7 100644 --- a/src/dashboard/tests/test_views.py +++ b/src/dashboard/tests/test_views.py @@ -20,8 +20,8 @@ from dashboard.forms import ( from dashboard.views import ( DashboardHomeView, DashboardConfigView, - ShippingMethodCreateView, - ShippingMethodDetailView, + ShippingRateCreateView, + ShippingRateDetailView, CouponListView, CouponCreateView, CouponDetailView, diff --git a/src/dashboard/urls.py b/src/dashboard/urls.py index 7798099..df2d7f4 100644 --- a/src/dashboard/urls.py +++ b/src/dashboard/urls.py @@ -15,13 +15,13 @@ urlpatterns = [ path( 'shipping-methods/new/', - views.ShippingMethodCreateView.as_view(), + views.ShippingRateCreateView.as_view(), name='shipmeth-create' ), path('shipping-methods//', include([ path( '', - views.ShippingMethodDetailView.as_view(), + views.ShippingRateDetailView.as_view(), name='shipmeth-detail' ), ])), diff --git a/src/dashboard/views.py b/src/dashboard/views.py index 80f18ef..85f1939 100644 --- a/src/dashboard/views.py +++ b/src/dashboard/views.py @@ -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' diff --git a/src/ptcoffee/settings.py b/src/ptcoffee/settings.py index c8fd473..f92ac4e 100644 --- a/src/ptcoffee/settings.py +++ b/src/ptcoffee/settings.py @@ -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', ], }, diff --git a/src/storefront/cart.py b/src/storefront/cart.py index c783e96..99d0f4d 100644 --- a/src/storefront/cart.py +++ b/src/storefront/cart.py @@ -14,7 +14,6 @@ from core import ( VoucherType, TransactionStatus, OrderStatus, - ShippingMethodType, ShippingService, ShippingContainer, CoffeeGrind diff --git a/src/storefront/context_processors.py b/src/storefront/context_processors.py index f29d3e2..1a1dab4 100644 --- a/src/storefront/context_processors.py +++ b/src/storefront/context_processors.py @@ -1,5 +1,6 @@ from .cart import Cart + def cart(request): return { 'cart': Cart(request) diff --git a/src/storefront/forms.py b/src/storefront/forms.py index 75deefa..e7b9dc0 100644 --- a/src/storefront/forms.py +++ b/src/storefront/forms.py @@ -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) diff --git a/src/storefront/views.py b/src/storefront/views.py index f02994b..085c1d9 100644 --- a/src/storefront/views.py +++ b/src/storefront/views.py @@ -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'