diff --git a/src/accounts/models.py b/src/accounts/models.py index 70b657d..1647c59 100644 --- a/src/accounts/models.py +++ b/src/accounts/models.py @@ -18,7 +18,12 @@ class Address(models.Model): postal_code = models.CharField(max_length=20, blank=True) def __str__(self): - return f'{self.street_address_1} — {self.city}' + return f""" + {first_name} {last_name} + {street_address_1} + {street_address_2} + {city}, {state}, {postal_code} + """ class User(AbstractUser): diff --git a/src/accounts/utils.py b/src/accounts/utils.py index 11d4cf4..f1b24a9 100644 --- a/src/accounts/utils.py +++ b/src/accounts/utils.py @@ -23,7 +23,7 @@ def get_or_create_customer(request, form, shipping_address): user.save() else: user, u_created = User.objects.get_or_create( - email=form.cleaned_data['email'], + email=form.cleaned_data['email'].lower(), defaults={ 'username': form.cleaned_data['email'].lower(), 'is_staff': False, diff --git a/src/core/__init__.py b/src/core/__init__.py index 16798fe..f8e65f7 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'), ] @@ -125,3 +127,20 @@ class CoffeeGrind: (PERCOLATOR, 'Percolator'), (CAFE_STYLE, 'BLTC cafe pour over') ] + + +def build_usps_rate_request(weight, container, zip_destination): + return \ + { + 'service': ShippingService.PRIORITY_COMMERCIAL, + 'zip_origination': settings.DEFAULT_ZIP_ORIGINATION, + 'zip_destination': zip_destination, + 'pounds': '0', + 'ounces': weight, + 'container': container, + 'width': '', + 'length': '', + 'height': '', + 'girth': '', + 'machinable': 'TRUE' + } diff --git a/src/core/admin.py b/src/core/admin.py index 62c9ccc..324c630 100644 --- a/src/core/admin.py +++ b/src/core/admin.py @@ -1,19 +1,27 @@ from django.contrib import admin from .models import ( + SiteSettings, + ProductCategory, Product, ProductPhoto, + ProductVariant, + ProductOption, Coupon, - ShippingMethod, + ShippingRate, Order, Transaction, OrderLine, ) +admin.site.register(SiteSettings) +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/fixtures/orders.json b/src/core/fixtures/orders.json index f0c17c2..5b40378 100644 --- a/src/core/fixtures/orders.json +++ b/src/core/fixtures/orders.json @@ -10,7 +10,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "9.55", - "total_net_amount": "13.40", + "subtotal_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T17:18:59.584Z", "updated_at": "2022-03-15T17:18:59.584Z" @@ -26,7 +26,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "9.55", - "total_net_amount": "13.40", + "subtotal_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T17:22:18.440Z", "updated_at": "2022-03-15T17:22:18.440Z" @@ -42,7 +42,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "9.55", - "total_net_amount": "13.40", + "subtotal_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T17:26:27.869Z", "updated_at": "2022-03-15T17:26:27.869Z" 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/migrations/0013_rename_total_net_amount_order_subtotal_amount_and_more.py b/src/core/migrations/0013_rename_total_net_amount_order_subtotal_amount_and_more.py new file mode 100644 index 0000000..975ad85 --- /dev/null +++ b/src/core/migrations/0013_rename_total_net_amount_order_subtotal_amount_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 4.0.2 on 2022-10-01 18:45 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0012_alter_productvariant_product'), + ] + + operations = [ + migrations.RenameField( + model_name='order', + old_name='total_net_amount', + new_name='subtotal_amount', + ), + migrations.RemoveField( + model_name='orderline', + name='product', + ), + migrations.AddField( + model_name='order', + name='coupon_amount', + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name='order', + name='total_amount', + field=models.DecimalField(decimal_places=2, default=0, max_digits=10), + ), + migrations.AddField( + model_name='orderline', + name='variant', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order_lines', to='core.productvariant'), + ), + ] diff --git a/src/core/migrations/0014_alter_productvariant_options_and_more.py b/src/core/migrations/0014_alter_productvariant_options_and_more.py new file mode 100644 index 0000000..44602cf --- /dev/null +++ b/src/core/migrations/0014_alter_productvariant_options_and_more.py @@ -0,0 +1,22 @@ +# Generated by Django 4.0.2 on 2022-10-13 01:23 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0013_rename_total_net_amount_order_subtotal_amount_and_more'), + ] + + operations = [ + migrations.AlterModelOptions( + name='productvariant', + options={'ordering': ['weight']}, + ), + migrations.RenameField( + model_name='productoption', + old_name='product', + new_name='products', + ), + ] diff --git a/src/core/migrations/0015_productcategory_main_product.py b/src/core/migrations/0015_productcategory_main_product.py new file mode 100644 index 0000000..ba0d3e2 --- /dev/null +++ b/src/core/migrations/0015_productcategory_main_product.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.2 on 2022-10-15 22:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0014_alter_productvariant_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='productcategory', + name='main_product', + field=models.BooleanField(default=True), + ), + ] diff --git a/src/core/migrations/0016_rename_main_product_productcategory_main_category.py b/src/core/migrations/0016_rename_main_product_productcategory_main_category.py new file mode 100644 index 0000000..3a6c009 --- /dev/null +++ b/src/core/migrations/0016_rename_main_product_productcategory_main_category.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.2 on 2022-10-15 22:15 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0015_productcategory_main_product'), + ] + + operations = [ + migrations.RenameField( + model_name='productcategory', + old_name='main_product', + new_name='main_category', + ), + ] diff --git a/src/core/models.py b/src/core/models.py index 2e01a09..e5c8bc9 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,44 +23,78 @@ from . import ( VoucherType, TransactionStatus, OrderStatus, - ShippingMethodType + ShippingProvider, + ShippingContainer, + build_usps_rate_request ) 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 ProductManager(models.Manager): - def get_queryset(self): - return super().get_queryset().annotate( - num_ordered=models.Sum('order_lines__quantity') - ) +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) + main_category = models.BooleanField(default=True) + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('dashboard:category-detail', kwargs={'pk': self.pk}) + + class Meta: + verbose_name = 'Product Category' + verbose_name_plural = 'Product Categories' 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, - ) - stripe_price_id = models.CharField(max_length=255, blank=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) @@ -67,8 +103,6 @@ class Product(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) - objects = ProductManager() - def __str__(self): return self.name @@ -88,6 +122,73 @@ class Product(models.Model): ordering = ['sorting', 'name'] +class ProductVariantManager(models.Manager): + def get_queryset(self): + return super().get_queryset().annotate( + num_ordered=models.Sum('order_lines__quantity') + ) + + +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) + + objects = ProductVariantManager() + + def __str__(self): + return f'{self.product}: {self.name}' + + class Meta: + ordering = ['weight'] + + +class ProductOption(models.Model): + """ + Description: Consistent accross all variants + """ + products = models.ManyToManyField( + Product, + related_name='options' + ) + name = models.CharField(max_length=255) + options = ArrayField( + models.CharField(max_length=255) + ) + + def get_absolute_url(self): + return reverse('dashboard:option-detail', kwargs={'pk': self.pk}) + + 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') @@ -150,17 +251,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): @@ -188,7 +304,7 @@ class OrderManager(models.Manager): class Order(models.Model): customer = models.ForeignKey( User, - related_name="orders", + related_name='orders', on_delete=models.SET_NULL, null=True ) @@ -211,14 +327,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, related_name='orders', @@ -226,19 +334,22 @@ class Order(models.Model): blank=True, null=True ) - + subtotal_amount = models.DecimalField( + max_digits=10, + decimal_places=2, + default=0 + ) + coupon_amount = models.CharField(max_length=255, blank=True) shipping_total = models.DecimalField( max_digits=5, decimal_places=2, default=0 ) - - total_net_amount = models.DecimalField( + total_amount = models.DecimalField( max_digits=10, decimal_places=2, default=0 ) - weight = MeasurementField( measurement=Weight, unit_choices=WeightUnits.CHOICES, @@ -260,11 +371,11 @@ class Order(models.Model): if self.coupon.discount_value_type == DiscountValueType.FIXED: return self.coupon.discount_value elif self.coupon.discount_value_type == DiscountValueType.PERCENTAGE: - return (self.coupon.discount_value / Decimal('100')) * self.total_net_amount + return (self.coupon.discount_value / Decimal('100')) * self.subtotal_amount return Decimal('0') def get_total_price_after_discount(self): - return round((self.total_net_amount - self.get_discount()) + self.shipping_total, 2) + return round((self.subtotal_amount - self.get_discount()) + self.shipping_total, 2) def get_absolute_url(self): return reverse('dashboard:order-detail', kwargs={'pk': self.pk}) @@ -294,13 +405,13 @@ class Transaction(models.Model): class OrderLine(models.Model): order = models.ForeignKey( Order, - related_name="lines", + related_name='lines', editable=False, on_delete=models.CASCADE ) - product = models.ForeignKey( - Product, - related_name="order_lines", + variant = models.ForeignKey( + ProductVariant, + related_name='order_lines', on_delete=models.SET_NULL, blank=True, null=True, @@ -309,20 +420,17 @@ class OrderLine(models.Model): quantity_fulfilled = models.IntegerField( validators=[MinValueValidator(0)], default=0 ) - customer_note = models.TextField(blank=True, default="") - + customer_note = models.TextField(blank=True, default='') currency = models.CharField( max_length=settings.DEFAULT_CURRENCY_CODE_LENGTH, default=settings.DEFAULT_CURRENCY, ) - unit_price = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, ) - tax_rate = models.DecimalField( - max_digits=5, decimal_places=2, default=Decimal("0.0") + max_digits=5, decimal_places=2, default=Decimal('0.0') ) def get_total(self): 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/templates/dashboard/catalog.html b/src/dashboard/templates/dashboard/catalog.html new file mode 100644 index 0000000..99bfced --- /dev/null +++ b/src/dashboard/templates/dashboard/catalog.html @@ -0,0 +1,76 @@ +{% extends "dashboard.html" %} +{% load static %} + +{% block content %} +
+
+

Catalog

+
+ + New product option + + New category + + New product +
+
+ {% for category in category_list %} +
+
+ + Category: +

{{ category }}

+
+ Name + Visible in listings +
+ {% for product in category.product_set.all %} + +
+ {{product.get_first_img.image}} +
+ {{product.name}} + {{product.visible_in_listings|yesno:"Yes,No"}} +
+ {% endfor %} +
+ {% endfor %} +
+
+
+

Uncategorized Products

+
+
+
+ + Name + Visible + Price +
+ {% for product in uncategorized_products %} + +
+ {{product.get_first_img.image}} +
+ {{product.name}} + {{product.visible_in_listings|yesno:"Yes,No"}} + ${{product.price}} +
+ {% endfor %} +
+
+
+
+

Product Options

+
+
+
+ + Name +
+ {% for option in option_list %} + + {{option.name}} + {{ option.options }} + + {% endfor %} +
+
+{% endblock content %} diff --git a/src/dashboard/templates/dashboard/category_confirm_delete.html b/src/dashboard/templates/dashboard/category_confirm_delete.html new file mode 100644 index 0000000..2afeb77 --- /dev/null +++ b/src/dashboard/templates/dashboard/category_confirm_delete.html @@ -0,0 +1,19 @@ +{% extends "dashboard.html" %} +{% load static %} + +{% block content %} +
+
+

{{ category }}

+
+
+
{% csrf_token %} +

Are you sure you want to delete "{{ object }}"?

+ {{ form.as_p }} +

+ or cancel +

+
+
+
+{% endblock content %} diff --git a/src/dashboard/templates/dashboard/category_create_form.html b/src/dashboard/templates/dashboard/category_create_form.html new file mode 100644 index 0000000..3cff841 --- /dev/null +++ b/src/dashboard/templates/dashboard/category_create_form.html @@ -0,0 +1,18 @@ +{% extends "dashboard.html" %} + +{% block content %} +
+
+

Create category

+
+
+
+ {% csrf_token %} + {{form.as_p}} +

+ or cancel +

+
+
+
+{% endblock %} diff --git a/src/dashboard/templates/dashboard/category_detail.html b/src/dashboard/templates/dashboard/category_detail.html new file mode 100644 index 0000000..3629f8f --- /dev/null +++ b/src/dashboard/templates/dashboard/category_detail.html @@ -0,0 +1,24 @@ +{% extends "dashboard.html" %} +{% load static %} + +{% block content %} +
+
+
+

{{ category.name }}

+

Is a main category: {{ category.main_category|yesno:"Yes,No" }}

+
+
+ Delete + Edit +
+
+
+ {% for product in category.product_set.all %} + {{ product }} + {% empty %} +

No products

+ {% endfor %} +
+
+{% endblock content %} diff --git a/src/dashboard/templates/dashboard/category_form.html b/src/dashboard/templates/dashboard/category_form.html new file mode 100644 index 0000000..b71aa94 --- /dev/null +++ b/src/dashboard/templates/dashboard/category_form.html @@ -0,0 +1,18 @@ +{% extends "dashboard.html" %} + +{% block content %} +
+
+

Update category

+
+
+
+ {% csrf_token %} + {{form.as_p}} +

+ or cancel +

+
+
+
+{% endblock %} diff --git a/src/dashboard/templates/dashboard/category_list.html b/src/dashboard/templates/dashboard/category_list.html new file mode 100644 index 0000000..62f826d --- /dev/null +++ b/src/dashboard/templates/dashboard/category_list.html @@ -0,0 +1,25 @@ +{% extends "dashboard.html" %} +{% load static %} + +{% block content %} +
+
+

Categories

+ +
+
+
+ Name +
+ {% for category in category_list %} + + {{ category.name }} + + {% empty %} + No categories + {% endfor %} +
+
+{% endblock content %} diff --git a/src/dashboard/templates/dashboard/config.html b/src/dashboard/templates/dashboard/config.html index 0874ed3..5e56eb0 100644 --- a/src/dashboard/templates/dashboard/config.html +++ b/src/dashboard/templates/dashboard/config.html @@ -10,27 +10,18 @@
-

Shipping methods

- + New method +

Shipping rates

+ + New rate
- {% for method in shipping_method_list %} + {% for rate in shipping_rate_list %}

- {{method.name}} | {{method.type}} | {{method.price}} + {{ rate }}

{% empty %} -

No shipping methods yet.

+

No shipping rates yet.

{% endfor %}
- -
-
-

Staff

- + New staff -
-
-
-
{% endblock %} diff --git a/src/dashboard/templates/dashboard/customer_detail.html b/src/dashboard/templates/dashboard/customer_detail.html index aa00d24..3fefbbf 100644 --- a/src/dashboard/templates/dashboard/customer_detail.html +++ b/src/dashboard/templates/dashboard/customer_detail.html @@ -71,7 +71,7 @@
{{order.get_status_display}}
- ${{order.total_net_amount}} + ${{order.total_amount}} {% empty %} No orders diff --git a/src/dashboard/templates/dashboard/option_confirm_delete.html b/src/dashboard/templates/dashboard/option_confirm_delete.html new file mode 100644 index 0000000..fb744f8 --- /dev/null +++ b/src/dashboard/templates/dashboard/option_confirm_delete.html @@ -0,0 +1,19 @@ +{% extends "dashboard.html" %} +{% load static %} + +{% block content %} +
+
+

Option

+
+
+
{% csrf_token %} +

Are you sure you want to delete "{{ object }}"?

+ {{ form.as_p }} +

+ or cancel +

+
+
+
+{% endblock content %} diff --git a/src/dashboard/templates/dashboard/option_create_form.html b/src/dashboard/templates/dashboard/option_create_form.html new file mode 100644 index 0000000..b6ab6db --- /dev/null +++ b/src/dashboard/templates/dashboard/option_create_form.html @@ -0,0 +1,18 @@ +{% extends "dashboard.html" %} + +{% block content %} +
+
+

Create option

+
+
+
+ {% csrf_token %} + {{form.as_p}} +

+ or cancel +

+
+
+
+{% endblock %} diff --git a/src/dashboard/templates/dashboard/option_detail.html b/src/dashboard/templates/dashboard/option_detail.html new file mode 100644 index 0000000..d5e231b --- /dev/null +++ b/src/dashboard/templates/dashboard/option_detail.html @@ -0,0 +1,24 @@ +{% extends "dashboard.html" %} +{% load static %} + +{% block content %} +
+
+

{{ option.name }}

+
+ Delete + Edit +
+
+
+
+

Products

+
+ {% for product in option.products.all %} + + {% endfor %} +
+
+{% endblock content %} diff --git a/src/dashboard/templates/dashboard/option_form.html b/src/dashboard/templates/dashboard/option_form.html new file mode 100644 index 0000000..e69de29 diff --git a/src/dashboard/templates/dashboard/order_detail.html b/src/dashboard/templates/dashboard/order_detail.html index ac5f882..0c325b8 100644 --- a/src/dashboard/templates/dashboard/order_detail.html +++ b/src/dashboard/templates/dashboard/order_detail.html @@ -6,13 +6,7 @@

Order #{{order.pk}}

- + Cancel order {{order.get_status_display}} ({{order.total_quantity_fulfilled}} / {{order.total_quantity_ordered}})
@@ -26,14 +20,14 @@ {% for item in order.lines.all %}
- {% with product=item.product %} + {% with product=item.variant.product %}
{{product.get_first_img.image}} -
{{product.name}}
Grind: {{item.customer_note}}
+
{{item.variant}}
{{item.customer_note}}
{{product.sku}} {{item.quantity}} - ${{product.price}} + ${{item.variant.price}} ${{item.get_total}} {% endwith %}
@@ -103,7 +97,7 @@

- Subtotal: ${{order.total_net_amount}}
+ Subtotal: ${{order.subtotal_amount}}
{% if order.coupon %} Discount: {{order.coupon.discount_value}} {{order.coupon.get_discount_value_type_display}}
{% endif %} diff --git a/src/dashboard/templates/dashboard/order_fulfill.html b/src/dashboard/templates/dashboard/order_fulfill.html index 2e7b351..339947d 100644 --- a/src/dashboard/templates/dashboard/order_fulfill.html +++ b/src/dashboard/templates/dashboard/order_fulfill.html @@ -7,7 +7,6 @@

{% csrf_token %} {{ form.management_form }} -
{% for dict in form.errors %} {% for error in dict.values %} @@ -20,15 +19,15 @@ Product SKU Quantity to fulfill - Grind + Options
{% for form in form %}
- {% with product=form.instance.product %} + {% with product=form.instance.variant.product %} {{form.id}}
{{product.get_first_img.image}} -
{{product.name}}
+
{{form.instance.variant}}
{{product.sku}} {{form.quantity_fulfilled}} / {{form.instance.quantity}} diff --git a/src/dashboard/templates/dashboard/product_detail.html b/src/dashboard/templates/dashboard/product_detail.html index 9fae9a5..4279395 100644 --- a/src/dashboard/templates/dashboard/product_detail.html +++ b/src/dashboard/templates/dashboard/product_detail.html @@ -15,15 +15,46 @@ {{product.get_first_img.image}}
+

Category: {{ product.category }}

{{product.name}}

+
{{ product.subtitle }}

{{product.description}}

-

${{product.price}}

-

{{product.weight.oz}} oz

+

Checkout limit: {{ product.checkout_limit }}

Visible in listings: {{product.visible_in_listings|yesno:"Yes,No"}}

-

Stripe ID: {{ product.stripe_id }}

+

Sorting: {{ product.sorting }}

Ordered {{product.num_ordered|default_if_none:"0"}} time{{ product.num_ordered|default_if_none:"0"|pluralize }}.

+
+
+

Variants

+ + New variant +
+ {% for variant in product.variants.all %} +
+

{{ variant.name }}

+

SKU: {{ variant.sku }}

+

Price: ${{ variant.price }}

+

Weight: {{ variant.weight }}

+ {% if variant.track_inventory %} +

Stock: {{ variant.stock }}

+ {% endif %} +

Edit

+
+ {% endfor %} +
+
+
+

Options

+

To create more product options go to the catalog

+
+ {% for option in product.options.all %} +
+

{{ option.name }}

+

{{ option.options }}

+
+ {% endfor %} +

Photos

diff --git a/src/dashboard/templates/dashboard/product_list.html b/src/dashboard/templates/dashboard/product_list.html index 2f266d3..06e75b1 100644 --- a/src/dashboard/templates/dashboard/product_list.html +++ b/src/dashboard/templates/dashboard/product_list.html @@ -4,24 +4,23 @@ {% block content %}
-

Catalog

+

Catalog

+ + New category + New product
-
+
Name Visible - Price
{% for product in product_list %} - +
{{product.get_first_img.image}}
{{product.name}} {{product.visible_in_listings|yesno:"Yes,No"}} - ${{product.price}}
{% endfor %}
diff --git a/src/dashboard/templates/dashboard/rate_confirm_delete.html b/src/dashboard/templates/dashboard/rate_confirm_delete.html new file mode 100644 index 0000000..446ee6b --- /dev/null +++ b/src/dashboard/templates/dashboard/rate_confirm_delete.html @@ -0,0 +1,19 @@ +{% extends "dashboard.html" %} +{% load static %} + +{% block content %} +
+
+

{{ rate }}

+
+
+ {% csrf_token %} +

Are you sure you want to delete "{{ object }}"?

+ {{ form.as_p }} +

+ or cancel +

+ +
+
+{% endblock content %} diff --git a/src/dashboard/templates/dashboard/shipmeth_create_form.html b/src/dashboard/templates/dashboard/rate_create_form.html similarity index 62% rename from src/dashboard/templates/dashboard/shipmeth_create_form.html rename to src/dashboard/templates/dashboard/rate_create_form.html index 88cd9bc..7fbbca5 100644 --- a/src/dashboard/templates/dashboard/shipmeth_create_form.html +++ b/src/dashboard/templates/dashboard/rate_create_form.html @@ -2,13 +2,13 @@ {% block content %}
-

Create Shipping Method

+

Create Shipping Rate

-
+ {% csrf_token %} {{form.as_p}}

- or cancel + or cancel

diff --git a/src/dashboard/templates/dashboard/rate_detail.html b/src/dashboard/templates/dashboard/rate_detail.html new file mode 100644 index 0000000..421280a --- /dev/null +++ b/src/dashboard/templates/dashboard/rate_detail.html @@ -0,0 +1,22 @@ +{% extends "dashboard.html" %} +{% load static %} + +{% block content %} +
+
+

Shipping Rate

+
+ Delete + Edit +
+
+
+
+

{{rate.name}}

+

Shipping Provider: {{ rate.shipping_provider }}

+

Container: {{ rate.get_container_display }}

+

Weight range: {{ rate.min_order_weight }} – {{ rate.max_order_weight }}

+
+
+
+{% endblock content %} diff --git a/src/dashboard/templates/dashboard/rate_form.html b/src/dashboard/templates/dashboard/rate_form.html new file mode 100644 index 0000000..81e364e --- /dev/null +++ b/src/dashboard/templates/dashboard/rate_form.html @@ -0,0 +1,18 @@ +{% extends "dashboard.html" %} + +{% block content %} +
+
+

Update rate

+
+
+
+ {% csrf_token %} + {{form.as_p}} +

+ or cancel +

+
+
+
+{% endblock %} diff --git a/src/dashboard/templates/dashboard/shipmeth_detail.html b/src/dashboard/templates/dashboard/shipmeth_detail.html deleted file mode 100644 index dad3e49..0000000 --- a/src/dashboard/templates/dashboard/shipmeth_detail.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends "dashboard.html" %} -{% load static %} - -{% block content %} -
-
-

Shipping Method

-
- Delete - Edit -
-
-
-
-

{{shippingmethod.name}}

-

{{shippingmethod.get_type_display}}

-

${{shippingmethod.price}}

-
-
-
-{% endblock content %} diff --git a/src/dashboard/templates/dashboard/variant_confirm_delete.html b/src/dashboard/templates/dashboard/variant_confirm_delete.html new file mode 100644 index 0000000..365b892 --- /dev/null +++ b/src/dashboard/templates/dashboard/variant_confirm_delete.html @@ -0,0 +1,20 @@ +{% extends "dashboard.html" %} +{% load static %} + +{% block content %} +
+
+

Delete Variant

+
+
+
+ {% csrf_token %} +

Are you sure you want to delete "{{ object }}"?

+ {{ form.as_p }} +

+ or cancel +

+
+
+
+{% endblock content %} diff --git a/src/dashboard/templates/dashboard/variant_create_form.html b/src/dashboard/templates/dashboard/variant_create_form.html new file mode 100644 index 0000000..ae94ae7 --- /dev/null +++ b/src/dashboard/templates/dashboard/variant_create_form.html @@ -0,0 +1,18 @@ +{% extends "dashboard.html" %} + +{% block content %} +
+
+

Create variant

+
+
+
+ {% csrf_token %} + {{form.as_p}} +

+ or cancel +

+
+
+
+{% endblock %} diff --git a/src/dashboard/templates/dashboard/variant_form.html b/src/dashboard/templates/dashboard/variant_form.html new file mode 100644 index 0000000..03f496e --- /dev/null +++ b/src/dashboard/templates/dashboard/variant_form.html @@ -0,0 +1,19 @@ +{% extends "dashboard.html" %} + +{% block content %} +
+
+

Update variant

+ Delete +
+
+
+ {% csrf_token %} + {{form.as_p}} +

+ or cancel +

+
+
+
+{% endblock %} 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..b054de5 100644 --- a/src/dashboard/urls.py +++ b/src/dashboard/urls.py @@ -12,17 +12,32 @@ urlpatterns = [ views.DashboardConfigView.as_view(), name='config' ), + path( + 'catalog/', + views.CatalogView.as_view(), + name='catalog' + ), path( - 'shipping-methods/new/', - views.ShippingMethodCreateView.as_view(), - name='shipmeth-create' + 'shipping-rates/new/', + views.ShippingRateCreateView.as_view(), + name='rate-create' ), - path('shipping-methods//', include([ + path('shipping-rates//', include([ path( '', - views.ShippingMethodDetailView.as_view(), - name='shipmeth-detail' + views.ShippingRateDetailView.as_view(), + name='rate-detail' + ), + path( + 'update/', + views.ShippingRateUpdateView.as_view(), + name='rate-update' + ), + path( + 'delete/', + views.ShippingRateDeleteView.as_view(), + name='rate-delete' ), ])), @@ -82,6 +97,37 @@ urlpatterns = [ ), ])), + # Categories + path('categories/', include([ + path( + '', + views.CategoryListView.as_view(), + name='category-list' + ), + path( + 'new/', + views.CategoryCreateView.as_view(), + name='category-create' + ), + path('/', include([ + path( + '', + views.CategoryDetailView.as_view(), + name='category-detail' + ), + path( + 'update/', + views.CategoryUpdateView.as_view(), + name='category-update' + ), + path( + 'delete/', + views.CategoryDeleteView.as_view(), + name='category-delete' + ), + ])), + ])), + path( 'products/', views.ProductListView.as_view(), @@ -121,6 +167,53 @@ urlpatterns = [ name='prodphoto-delete' ), ])), + + # ProductVariants + path('variants/', include([ + path( + 'new/', + views.ProductVariantCreateView.as_view(), + name='variant-create' + ), + path('/', include([ + path( + 'update/', + views.ProductVariantUpdateView.as_view(), + name='variant-update' + ), + path( + 'delete/', + views.ProductVariantDeleteView.as_view(), + name='variant-delete' + ), + ])), + ])), + ])), + + # ProductOptions + path('options/', include([ + path( + 'new/', + views.ProductOptionCreateView.as_view(), + name='option-create' + ), + path('/', include([ + path( + '', + views.ProductOptionDetailView.as_view(), + name='option-detail' + ), + path( + 'update/', + views.ProductOptionUpdateView.as_view(), + name='option-update' + ), + path( + 'delete/', + views.ProductOptionDeleteView.as_view(), + name='option-delete' + ), + ])), ])), path( diff --git a/src/dashboard/views.py b/src/dashboard/views.py index 80f18ef..1da356a 100644 --- a/src/dashboard/views.py +++ b/src/dashboard/views.py @@ -26,11 +26,14 @@ from accounts.models import User from accounts.utils import get_or_create_customer from accounts.forms import AddressForm from core.models import ( + ProductCategory, Product, ProductPhoto, + ProductVariant, + ProductOption, Order, OrderLine, - ShippingMethod, + ShippingRate, Transaction, TrackingNumber, Coupon @@ -39,8 +42,7 @@ from core.models import ( from core import ( DiscountValueType, VoucherType, - OrderStatus, - ShippingMethodType + OrderStatus ) from .forms import ( OrderLineFulfillForm, @@ -72,7 +74,7 @@ class DashboardHomeView(LoginRequiredMixin, TemplateView): status=OrderStatus.DRAFT ).filter( created_at__date=today - ).aggregate(total=Sum('total_net_amount'))['total'] + ).aggregate(total=Sum('total_amount'))['total'] return context @@ -81,23 +83,52 @@ class DashboardConfigView(TemplateView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - today = timezone.localtime(timezone.now()).date() - - context['shipping_method_list'] = ShippingMethod.objects.all() - + context['shipping_rate_list'] = ShippingRate.objects.all() return context -class ShippingMethodCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): - model = ShippingMethod - template_name = 'dashboard/shipmeth_create_form.html' +class CatalogView(ListView): + model = ProductCategory + context_object_name = 'category_list' + template_name = 'dashboard/catalog.html' + + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) + context['uncategorized_products'] = Product.objects.filter( + category=None + ) + context['option_list'] = ProductOption.objects.all() + return context + + +class ShippingRateDetailView(LoginRequiredMixin, DetailView): + model = ShippingRate + context_object_name = 'rate' + template_name = 'dashboard/rate_detail.html' + + +class ShippingRateCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): + model = ShippingRate + context_object_name = 'rate' + template_name = 'dashboard/rate_create_form.html' fields = '__all__' success_message = '%(name)s created.' -class ShippingMethodDetailView(LoginRequiredMixin, DetailView): - model = ShippingMethod - template_name = 'dashboard/shipmeth_detail.html' +class ShippingRateUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): + model = ShippingRate + context_object_name = 'rate' + template_name = 'dashboard/rate_form.html' + success_message = 'ShippingRate saved.' + fields = '__all__' + + +class ShippingRateDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView): + model = ShippingRate + context_object_name = 'rate' + template_name = 'dashboard/rate_confirm_delete.html' + success_message = 'ShippingRate deleted.' + success_url = reverse_lazy('dashboard:config') class CouponListView(LoginRequiredMixin, ListView): @@ -168,10 +199,9 @@ class OrderDetailView(LoginRequiredMixin, DetailView): ).select_related( 'customer', 'billing_address', - 'shipping_address', - 'shipping_method' + 'shipping_address' ).prefetch_related( - 'lines__product__productphoto_set' + 'lines__variant__product__productphoto_set' ) obj = queryset.get() return obj @@ -183,9 +213,9 @@ class OrderDetailView(LoginRequiredMixin, DetailView): class OrderFulfillView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): model = Order - template_name = "dashboard/order_fulfill.html" + template_name = 'dashboard/order_fulfill.html' form_class = OrderLineFormset - success_message = "Order saved." + success_message = 'Order saved.' def form_valid(self, form): form.save() @@ -222,6 +252,42 @@ class OrderTrackingView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): return reverse('dashboard:order-detail', kwargs={'pk': self.object.pk}) +class CategoryListView(ListView): + model = ProductCategory + context_object_name = 'category_list' + template_name = 'dashboard/category_list.html' + + +class CategoryCreateView(SuccessMessageMixin, CreateView): + model = ProductCategory + context_object_name = 'category' + success_message = 'Category created.' + template_name = 'dashboard/category_create_form.html' + fields = '__all__' + + +class CategoryDetailView(DetailView): + model = ProductCategory + context_object_name = 'category' + template_name = 'dashboard/category_detail.html' + + +class CategoryUpdateView(SuccessMessageMixin, UpdateView): + model = ProductCategory + context_object_name = 'category' + success_message = 'Category saved.' + template_name = 'dashboard/category_form.html' + fields = '__all__' + + +class CategoryDeleteView(SuccessMessageMixin, DeleteView): + model = ProductCategory + context_object_name = 'category' + success_message = 'Category deleted.' + template_name = 'dashboard/category_confirm_delete.html' + success_url = reverse_lazy('dashboard:catalog') + + class ProductListView(LoginRequiredMixin, ListView): model = Product template_name = 'dashboard/product_list.html' @@ -240,6 +306,20 @@ class ProductDetailView(LoginRequiredMixin, DetailView): model = Product template_name = 'dashboard/product_detail.html' + def get_object(self): + pk = self.kwargs.get(self.pk_url_kwarg) + queryset = Product.objects.filter( + pk=pk + ).select_related( + 'category', + ).prefetch_related( + 'variants', + 'options', + 'productphoto_set' + ) + obj = queryset.get() + return obj + class ProductUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): model = Product @@ -292,6 +372,113 @@ class ProductPhotoDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView return reverse('dashboard:product-detail', kwargs={'pk': self.kwargs['pk']}) +class ProductVariantCreateView(SuccessMessageMixin, CreateView): + model = ProductVariant + success_message = 'Variant created.' + template_name = 'dashboard/variant_create_form.html' + fields = [ + 'name', + 'sku', + 'price', + 'weight', + 'track_inventory', + 'stock', + ] + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['product'] = Product.objects.get(pk=self.kwargs['pk']) + return context + + def form_valid(self, form): + form.instance.product = Product.objects.get(pk=self.kwargs['pk']) + return super().form_valid(form) + + def get_success_url(self): + return reverse('dashboard:product-detail', kwargs={'pk': self.kwargs['pk']}) + + +class ProductVariantUpdateView(SuccessMessageMixin, UpdateView): + model = ProductVariant + pk_url_kwarg = 'variant_pk' + success_message = 'ProductVariant saved.' + template_name = 'dashboard/variant_form.html' + fields = [ + 'name', + 'sku', + 'price', + 'weight', + 'track_inventory', + 'stock', + ] + context_object_name = 'variant' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['product'] = Product.objects.get(pk=self.kwargs['pk']) + return context + + def form_valid(self, form): + form.instance.product = Product.objects.get(pk=self.kwargs['pk']) + return super().form_valid(form) + + def get_success_url(self): + return reverse('dashboard:product-detail', kwargs={'pk': self.kwargs['pk']}) + + +class ProductVariantDeleteView(SuccessMessageMixin, DeleteView): + model = ProductVariant + pk_url_kwarg = 'variant_pk' + success_message = 'ProductVariant deleted.' + template_name = 'dashboard/variant_confirm_delete.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['product'] = Product.objects.get(pk=self.kwargs['pk']) + return context + + def get_success_url(self): + return reverse('dashboard:product-detail', kwargs={'pk': self.kwargs['pk']}) + + +class ProductOptionDetailView(LoginRequiredMixin, DetailView): + model = ProductOption + template_name = 'dashboard/option_detail.html' + context_object_name = 'option' + + +class ProductOptionCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): + model = ProductOption + template_name = 'dashboard/option_create_form.html' + fields = [ + 'name', + 'options', + 'products', + ] + success_message = '%(name)s created.' + + +class ProductOptionUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): + model = ProductOption + success_message = 'Option saved.' + template_name = 'dashboard/option_form.html' + fields = [ + 'name', + 'options', + 'products', + ] + context_object_name = 'option' + success_url = reverse_lazy('dashboard:catalog') + + +class ProductOptionDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView): + model = ProductOption + success_message = 'ProductOption deleted.' + template_name = 'dashboard/option_confirm_delete.html' + context_object_name = 'option' + success_url = reverse_lazy('dashboard:catalog') + + class CustomerListView(LoginRequiredMixin, ListView): model = User template_name = 'dashboard/customer_list.html' diff --git a/src/fixtures/db.json b/src/fixtures/db.json index f563747..50d5606 100644 --- a/src/fixtures/db.json +++ b/src/fixtures/db.json @@ -1881,7 +1881,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T17:18:59.584Z", "updated_at": "2022-03-15T17:18:59.584Z" @@ -1897,7 +1897,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T17:22:18.440Z", "updated_at": "2022-03-15T17:22:18.440Z" @@ -1913,7 +1913,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T17:26:27.869Z", "updated_at": "2022-03-15T17:26:27.869Z" @@ -1929,7 +1929,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T18:14:16.587Z", "updated_at": "2022-03-15T18:14:16.587Z" @@ -1945,7 +1945,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T18:16:59.460Z", "updated_at": "2022-03-15T18:16:59.460Z" @@ -1961,7 +1961,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T18:23:13.283Z", "updated_at": "2022-03-15T18:23:13.283Z" @@ -1977,7 +1977,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "40.20", + "total_amount": "40.20", "weight": "0.0:oz", "created_at": "2022-03-15T18:29:02.632Z", "updated_at": "2022-03-15T18:29:02.632Z" @@ -1993,7 +1993,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "40.20", + "total_amount": "40.20", "weight": "0.0:oz", "created_at": "2022-03-15T19:13:50.050Z", "updated_at": "2022-03-15T19:13:50.050Z" @@ -2009,7 +2009,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "40.20", + "total_amount": "40.20", "weight": "0.0:oz", "created_at": "2022-03-15T19:15:18.843Z", "updated_at": "2022-03-15T19:15:18.843Z" @@ -2025,7 +2025,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "40.20", + "total_amount": "40.20", "weight": "0.0:oz", "created_at": "2022-03-15T19:17:21.952Z", "updated_at": "2022-03-15T19:17:21.952Z" @@ -2041,7 +2041,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T19:22:34.503Z", "updated_at": "2022-03-15T19:22:34.503Z" @@ -2057,7 +2057,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T19:25:35.313Z", "updated_at": "2022-03-15T19:25:35.313Z" @@ -2073,7 +2073,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T19:26:51.478Z", "updated_at": "2022-03-15T19:26:51.478Z" @@ -2089,7 +2089,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T19:30:28.497Z", "updated_at": "2022-03-15T19:30:28.497Z" @@ -2105,7 +2105,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T19:36:30.561Z", "updated_at": "2022-03-15T19:36:30.561Z" @@ -2121,7 +2121,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T19:54:38.099Z", "updated_at": "2022-03-15T19:54:38.099Z" @@ -2137,7 +2137,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T19:56:49.477Z", "updated_at": "2022-03-15T19:56:49.477Z" @@ -2153,7 +2153,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T20:01:53.848Z", "updated_at": "2022-03-15T20:01:53.848Z" @@ -2169,7 +2169,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T20:09:31.510Z", "updated_at": "2022-03-15T20:09:31.510Z" @@ -2185,7 +2185,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T20:13:16.927Z", "updated_at": "2022-03-15T20:13:16.927Z" @@ -2201,7 +2201,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T20:14:43.333Z", "updated_at": "2022-03-15T20:14:43.333Z" @@ -2217,7 +2217,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T20:16:03.299Z", "updated_at": "2022-03-15T20:16:03.299Z" @@ -2233,7 +2233,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-03-15T20:17:32.842Z", "updated_at": "2022-03-15T20:17:32.842Z" @@ -2249,7 +2249,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "26.80", + "total_amount": "26.80", "weight": "0.0:oz", "created_at": "2022-03-15T20:21:35.974Z", "updated_at": "2022-03-15T20:21:35.974Z" @@ -2265,7 +2265,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "40.20", + "total_amount": "40.20", "weight": "0.0:oz", "created_at": "2022-03-15T20:22:11.717Z", "updated_at": "2022-03-15T20:22:11.717Z" @@ -2281,7 +2281,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "53.60", + "total_amount": "53.60", "weight": "0.0:oz", "created_at": "2022-03-15T20:23:49.392Z", "updated_at": "2022-03-15T20:23:49.392Z" @@ -2297,7 +2297,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "53.60", + "total_amount": "53.60", "weight": "0.0:oz", "created_at": "2022-03-15T20:25:04.787Z", "updated_at": "2022-03-15T20:25:04.787Z" @@ -2313,7 +2313,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "53.60", + "total_amount": "53.60", "weight": "0.0:oz", "created_at": "2022-03-15T20:27:47.933Z", "updated_at": "2022-03-15T20:27:47.933Z" @@ -2329,7 +2329,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "40.20", + "total_amount": "40.20", "weight": "0.0:oz", "created_at": "2022-03-15T20:30:40.141Z", "updated_at": "2022-03-15T20:30:40.141Z" @@ -2345,7 +2345,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "40.20", + "total_amount": "40.20", "weight": "0.0:oz", "created_at": "2022-03-15T20:32:09.015Z", "updated_at": "2022-03-23T16:02:59.305Z" @@ -2361,7 +2361,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "26.80", + "total_amount": "26.80", "weight": "0.0:oz", "created_at": "2022-03-23T16:59:10.471Z", "updated_at": "2022-03-23T17:00:17.128Z" @@ -2377,7 +2377,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "25.46", + "total_amount": "25.46", "weight": "0.0:oz", "created_at": "2022-03-23T21:22:54.950Z", "updated_at": "2022-03-23T21:22:54.950Z" @@ -2393,7 +2393,7 @@ "shipping_method": null, "coupon": 1, "shipping_total": "0.00", - "total_net_amount": "12.73", + "total_amount": "12.73", "weight": "0.0:oz", "created_at": "2022-03-23T21:30:54.290Z", "updated_at": "2022-03-23T21:30:54.290Z" @@ -2409,7 +2409,7 @@ "shipping_method": null, "coupon": 1, "shipping_total": "0.00", - "total_net_amount": "26.80", + "total_amount": "26.80", "weight": "0.0:oz", "created_at": "2022-03-23T21:45:57.399Z", "updated_at": "2022-03-23T21:45:57.399Z" @@ -2425,7 +2425,7 @@ "shipping_method": null, "coupon": 1, "shipping_total": "0.00", - "total_net_amount": "26.80", + "total_amount": "26.80", "weight": "0.0:oz", "created_at": "2022-03-23T21:52:22.463Z", "updated_at": "2022-03-25T16:51:04.837Z" @@ -2441,7 +2441,7 @@ "shipping_method": null, "coupon": 1, "shipping_total": "0.00", - "total_net_amount": "67.00", + "total_amount": "67.00", "weight": "0.0:oz", "created_at": "2022-04-01T17:09:34.892Z", "updated_at": "2022-04-01T17:09:34.892Z" @@ -2457,7 +2457,7 @@ "shipping_method": null, "coupon": 2, "shipping_total": "0.00", - "total_net_amount": "40.20", + "total_amount": "40.20", "weight": "0.0:oz", "created_at": "2022-04-04T00:02:12.247Z", "updated_at": "2022-04-04T00:02:12.247Z" @@ -2473,7 +2473,7 @@ "shipping_method": null, "coupon": 2, "shipping_total": "0.00", - "total_net_amount": "40.20", + "total_amount": "40.20", "weight": "0.0:oz", "created_at": "2022-04-04T00:03:44.789Z", "updated_at": "2022-04-04T00:03:44.789Z" @@ -2489,7 +2489,7 @@ "shipping_method": null, "coupon": 2, "shipping_total": "0.00", - "total_net_amount": "26.80", + "total_amount": "26.80", "weight": "0.0:oz", "created_at": "2022-04-06T01:18:18.633Z", "updated_at": "2022-04-06T01:18:18.633Z" @@ -2505,7 +2505,7 @@ "shipping_method": null, "coupon": 2, "shipping_total": "0.00", - "total_net_amount": "67.00", + "total_amount": "67.00", "weight": "0.0:oz", "created_at": "2022-04-06T17:48:39.005Z", "updated_at": "2022-04-06T18:04:31.040Z" @@ -2521,7 +2521,7 @@ "shipping_method": null, "coupon": 2, "shipping_total": "0.00", - "total_net_amount": "26.80", + "total_amount": "26.80", "weight": "0.0:oz", "created_at": "2022-04-06T18:00:15.976Z", "updated_at": "2022-04-06T18:00:15.976Z" @@ -2537,7 +2537,7 @@ "shipping_method": null, "coupon": 2, "shipping_total": "0.00", - "total_net_amount": "53.60", + "total_amount": "53.60", "weight": "0.0:oz", "created_at": "2022-04-06T18:01:51.206Z", "updated_at": "2022-04-06T18:01:51.206Z" @@ -2553,7 +2553,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-04-15T03:18:58.958Z", "updated_at": "2022-04-15T03:18:58.958Z" @@ -2569,7 +2569,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-04-15T03:19:14.980Z", "updated_at": "2022-04-15T03:19:14.980Z" @@ -2585,7 +2585,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-04-15T03:21:45.918Z", "updated_at": "2022-04-15T03:21:45.918Z" @@ -2601,7 +2601,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-04-15T03:22:58.009Z", "updated_at": "2022-04-15T03:22:58.009Z" @@ -2617,7 +2617,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-04-15T03:24:22.731Z", "updated_at": "2022-04-15T03:24:22.731Z" @@ -2633,7 +2633,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-04-15T03:24:38.585Z", "updated_at": "2022-04-15T03:24:38.585Z" @@ -2649,7 +2649,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-04-15T03:26:19.552Z", "updated_at": "2022-04-15T03:26:19.552Z" @@ -2665,7 +2665,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "26.80", + "total_amount": "26.80", "weight": "0.0:oz", "created_at": "2022-04-23T20:51:39.679Z", "updated_at": "2022-04-23T20:51:39.679Z" @@ -2681,7 +2681,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "26.80", + "total_amount": "26.80", "weight": "0.0:oz", "created_at": "2022-04-23T20:55:39.285Z", "updated_at": "2022-04-23T20:55:39.285Z" @@ -2697,7 +2697,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "26.80", + "total_amount": "26.80", "weight": "0.0:oz", "created_at": "2022-04-23T21:00:39.249Z", "updated_at": "2022-04-24T03:38:54.039Z" @@ -2713,7 +2713,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "40.20", + "total_amount": "40.20", "weight": "0.0:oz", "created_at": "2022-04-24T16:34:28.911Z", "updated_at": "2022-04-24T16:34:28.911Z" @@ -2729,7 +2729,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "40.20", + "total_amount": "40.20", "weight": "0.0:oz", "created_at": "2022-04-24T16:37:32.671Z", "updated_at": "2022-04-24T16:37:32.671Z" @@ -2745,7 +2745,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "40.20", + "total_amount": "40.20", "weight": "0.0:oz", "created_at": "2022-04-24T16:41:55.368Z", "updated_at": "2022-04-24T16:41:55.368Z" @@ -2761,7 +2761,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "40.20", + "total_amount": "40.20", "weight": "0.0:oz", "created_at": "2022-04-24T16:47:43.438Z", "updated_at": "2022-04-24T16:47:43.438Z" @@ -2777,7 +2777,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "40.20", + "total_amount": "40.20", "weight": "0.0:oz", "created_at": "2022-04-24T16:49:10.526Z", "updated_at": "2022-04-24T16:49:10.526Z" @@ -2793,7 +2793,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "40.20", + "total_amount": "40.20", "weight": "0.0:oz", "created_at": "2022-04-24T16:49:18.644Z", "updated_at": "2022-04-24T16:49:18.645Z" @@ -2809,7 +2809,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "53.60", + "total_amount": "53.60", "weight": "0.0:oz", "created_at": "2022-04-24T17:01:14.133Z", "updated_at": "2022-04-24T17:01:14.133Z" @@ -2825,7 +2825,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "53.60", + "total_amount": "53.60", "weight": "0.0:oz", "created_at": "2022-04-24T17:03:50.880Z", "updated_at": "2022-04-24T17:03:50.880Z" @@ -2841,7 +2841,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "53.60", + "total_amount": "53.60", "weight": "0.0:oz", "created_at": "2022-04-24T17:19:22.528Z", "updated_at": "2022-04-24T17:19:22.528Z" @@ -2857,7 +2857,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "53.60", + "total_amount": "53.60", "weight": "0.0:oz", "created_at": "2022-04-24T17:23:48.946Z", "updated_at": "2022-04-24T17:23:48.946Z" @@ -2873,7 +2873,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "40.20", + "total_amount": "40.20", "weight": "0.0:oz", "created_at": "2022-04-24T17:35:04.209Z", "updated_at": "2022-04-24T17:35:04.209Z" @@ -2889,7 +2889,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "40.20", + "total_amount": "40.20", "weight": "0.0:oz", "created_at": "2022-04-24T17:35:40.334Z", "updated_at": "2022-04-24T17:35:40.334Z" @@ -2905,7 +2905,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "40.20", + "total_amount": "40.20", "weight": "0.0:oz", "created_at": "2022-04-24T17:36:27.559Z", "updated_at": "2022-04-24T17:36:46.155Z" @@ -2921,7 +2921,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "9.55", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-04-24T17:52:07.802Z", "updated_at": "2022-04-24T17:52:07.802Z" @@ -2937,7 +2937,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "12.47", - "total_net_amount": "40.20", + "total_amount": "40.20", "weight": "0.0:oz", "created_at": "2022-04-24T17:52:59.926Z", "updated_at": "2022-04-24T17:53:38.188Z" @@ -2953,7 +2953,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "9.55", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-04-24T17:57:18.399Z", "updated_at": "2022-04-24T17:57:18.399Z" @@ -2969,7 +2969,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "9.55", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-04-24T18:36:43.689Z", "updated_at": "2022-04-24T18:37:06.954Z" @@ -2985,7 +2985,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "0.00", - "total_net_amount": "0.00", + "total_amount": "0.00", "weight": "0.0:oz", "created_at": "2022-04-24T20:44:10.464Z", "updated_at": "2022-04-24T20:44:10.464Z" @@ -3001,7 +3001,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "9.55", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-04-24T20:44:28.234Z", "updated_at": "2022-04-24T20:44:44.522Z" @@ -3017,7 +3017,7 @@ "shipping_method": null, "coupon": null, "shipping_total": "9.55", - "total_net_amount": "13.40", + "total_amount": "13.40", "weight": "0.0:oz", "created_at": "2022-04-24T21:06:59.696Z", "updated_at": "2022-04-24T21:07:17.313Z" diff --git a/src/ptcoffee/settings.py b/src/ptcoffee/settings.py index c8fd473..39b2ebf 100644 --- a/src/ptcoffee/settings.py +++ b/src/ptcoffee/settings.py @@ -85,7 +85,9 @@ 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', + 'storefront.context_processors.product_categories', ], }, }, @@ -256,17 +258,19 @@ CELERY_TASK_TRACK_STARTED = True CELERY_TIMEZONE = 'US/Mountain' # Sentry -sentry_sdk.init( - dsn=SENTRY_DSN, - environment=SENTRY_ENV, - integrations=[DjangoIntegration()], - # Set traces_sample_rate to 1.0 to capture 100% - # of transactions for performance monitoring. - # We recommend adjusting this value in production. - traces_sample_rate=1.0, +if not DEBUG: + sentry_sdk.init( + dsn=SENTRY_DSN, + environment=SENTRY_ENV, + integrations=[DjangoIntegration()], - # If you wish to associate users to errors (assuming you are using - # django.contrib.auth) you may enable sending PII data. - send_default_pii=True -) + # Set traces_sample_rate to 1.0 to capture 100% + # of transactions for performance monitoring. + # We recommend adjusting this value in production. + traces_sample_rate=1.0, + + # If you wish to associate users to errors (assuming you are using + # django.contrib.auth) you may enable sending PII data. + send_default_pii=True + ) diff --git a/src/static/styles/dashboard.css b/src/static/styles/dashboard.css index 239348e..672105f 100644 --- a/src/static/styles/dashboard.css +++ b/src/static/styles/dashboard.css @@ -367,7 +367,7 @@ main article { } .product__figure img { - max-height: 400px; + max-height: 200px; } diff --git a/src/static/styles/main.css b/src/static/styles/main.css index 7b4d259..937ad1d 100644 --- a/src/static/styles/main.css +++ b/src/static/styles/main.css @@ -526,6 +526,22 @@ section:not(:last-child) { } +/* Breadcrumbs + ========================================================================== */ +.breadcrumbs { + margin-bottom: 1.5rem; +} +.breadcrumbs menu { + margin: 0; + padding: 0 1rem; + line-height: 1.75; + list-style: none; + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + + /* ========================================================================== Articles ========================================================================== */ @@ -773,6 +789,7 @@ article + article { .item__price { justify-self: end; + text-align: right; } .item__form, @@ -911,3 +928,8 @@ footer > section { text-align: center; } + + +.show-modal { + white-space: unset; +} diff --git a/src/storefront/cart.py b/src/storefront/cart.py index c783e96..65d0f28 100644 --- a/src/storefront/cart.py +++ b/src/storefront/cart.py @@ -6,56 +6,75 @@ from django.conf import settings from django.contrib import messages from django.shortcuts import redirect, reverse from django.urls import reverse_lazy +from django.db.models import OuterRef, Q, Subquery -from core.models import Product, OrderLine, Coupon +from core.models import ( + Product, ProductVariant, OrderLine, Coupon, ShippingRate +) from core.usps import USPSApi from core import ( DiscountValueType, VoucherType, TransactionStatus, OrderStatus, - ShippingMethodType, ShippingService, ShippingContainer, - CoffeeGrind + CoffeeGrind, + build_usps_rate_request ) +from .forms import UpdateCartItemForm from .payments import CreateOrder logger = logging.getLogger(__name__) +class CartItem: + update_form = UpdateCartItemForm + + def __init__(self, item): + self.variant = item['variant'] + self.quantity = item['quantity'] + self.options = item['options'] + + def get_update_form(self, index): + return self.update_form(initial={ + 'item_pk': index, + 'quantity': self.quantity + }) + + def __iter__(self): + yield ('name', str(self.variant)) + yield ('description', self.variant.product.subtitle) + yield ('unit_amount', { + 'currency_code': settings.DEFAULT_CURRENCY, + 'value': f'{self.variant.price}', + }) + yield ('quantity', f'{item["quantity"]}') + + def __str__(self): + return str(self.variant) + + class Cart: + item_class = CartItem + def __init__(self, request): self.request = request self.session = request.session self.coupon_code = self.session.get('coupon_code') - self.container = self.session.get('shipping_container') cart = self.session.get(settings.CART_SESSION_ID) if not cart: - cart = self.session[settings.CART_SESSION_ID] = {} + cart = self.session[settings.CART_SESSION_ID] = [] self.cart = cart - def add( - self, request, product, quantity=1, grind='', update_quantity=False - ): - product_id = str(product.id) - if product_id not in self.cart: - self.cart[product_id] = { - 'variations': {}, - 'price': str(product.price) - } - self.cart[product_id]['variations'][grind] = {'quantity': 0} - + def add(self, request, item, update_quantity=False): if update_quantity: - self.cart[product_id]['variations'][grind]['quantity'] = quantity + self.cart[item['variant']]['quantity'] = item['quantity'] else: - if not grind in self.cart[product_id]['variations']: - # create it - self.cart[product_id]['variations'][grind] = {'quantity': quantity} - else: - # add to it - self.cart[product_id]['variations'][grind]['quantity'] += quantity + self.cart.append(item) + + # TODO: abstract this to a function that will check the max amount of item in the cart if len(self) <= 20: self.save() else: @@ -66,39 +85,32 @@ class Cart: self.session.modified = True logger.info(f'\nCart:\n{self.cart}\n') - def remove(self, product, grind): - product_id = str(product.id) - if product_id in self.cart: - del self.cart[product_id]['variations'][grind] - if not self.cart[product_id]['variations']: - del self.cart[product_id] - self.save() + def remove(self, pk): + self.cart.pop(pk) + self.save() def __iter__(self): - product_ids = self.cart.keys() - products = Product.objects.filter(id__in=product_ids) - for product in products: - self.cart[str(product.id)]['product'] = product - - for item in self.cart.values(): - item['price'] = Decimal(item['price']) - item['total_price'] = Decimal(sum(self.get_item_prices())) - item['quantity'] = self.get_single_item_total_quantity(item) + for item in self.cart: + pk = item['variant'].pk if isinstance(item['variant'], ProductVariant) else item['variant'] + item['variant'] = ProductVariant.objects.get(pk=pk) + item['price_total'] = item['variant'].price * item['quantity'] yield item def __len__(self): - return sum(self.get_all_item_quantities()) + return sum([item['quantity'] for item in self.cart]) def get_all_item_quantities(self): - for item in self.cart.values(): - yield sum([value['quantity'] for value in item['variations'].values()]) + for item in self.cart: + yield item['quantity'] def get_single_item_total_quantity(self, item): return sum([value['quantity'] for value in item['variations'].values()]) def get_item_prices(self): - for item in self.cart.values(): - yield Decimal(item['price']) * sum([value['quantity'] for value in item['variations'].values()]) + for item in self: + yield item['price_total'] + # for item in self.cart.values(): + # yield Decimal(item['price']) * sum([value['quantity'] for value in item['variations'].values()]) def get_total_price(self): return sum(self.get_item_prices()) @@ -106,30 +118,36 @@ class Cart: def get_total_weight(self): if len(self) > 0: for item in self: - return item['product'].weight.value * sum(self.get_all_item_quantities()) + return item['variant'].weight.value * sum(self.get_all_item_quantities()) else: return 0 - def get_shipping_box(self, container=None): - if container: - return container - - if self.container: - return self.container - - if len(self) > 6 and len(self) <= 10: - return ShippingContainer.LG_FLAT_RATE_BOX - elif len(self) > 3 and len(self) <= 6: - return ShippingContainer.REGIONAL_RATE_BOX_B - elif len(self) <= 3: - return ShippingContainer.REGIONAL_RATE_BOX_A - else: - return ShippingContainer.VARIABLE + def get_shipping_container_choices(self): + min_weight_matched = Q( + min_order_weight__lte=self.get_total_weight()) | Q( + min_order_weight__isnull=True + ) + max_weight_matched = Q( + max_order_weight__gte=self.get_total_weight()) | Q( + max_order_weight__isnull=True + ) + containers = ShippingRate.objects.filter( + min_weight_matched & max_weight_matched + ) + return containers def get_shipping_cost(self, container=None): - if len(self) > 0 and self.session.get("shipping_address"): + if container is None: + container = self.session.get('shipping_container').container + + if len(self) > 0 and self.session.get('shipping_address'): + usps_rate_request = build_usps_rate_request( + str(self.get_total_weight()), + container, + str(self.session.get('shipping_address')['postal_code']) + ) try: - usps_rate_request = self.build_usps_rate_request(container) + logger.info('wafd') except TypeError as e: return Decimal('0.00') usps = USPSApi(settings.USPS_USER_ID, test=True) @@ -141,9 +159,10 @@ class Cart: 'Could not connect to USPS, try again.' ) - logger.info(validation.result) - if 'Error' not in validation.result['RateV4Response']['Package']: - rate = validation.result['RateV4Response']['Package']['Postage']['CommercialRate'] + logger.error(validation.result) + package = dict(validation.result['RateV4Response']['Package']) + if 'Error' not in package: + rate = package['Postage']['CommercialRate'] else: logger.error("USPS Rate error") rate = '0.00' @@ -159,22 +178,6 @@ class Cart: pass self.session.modified = True - def build_usps_rate_request(self, container=None): - return \ - { - 'service': ShippingService.PRIORITY_COMMERCIAL, - 'zip_origination': settings.DEFAULT_ZIP_ORIGINATION, - 'zip_destination': f'{self.session.get("shipping_address")["postal_code"]}', - 'pounds': '0', - 'ounces': f'{self.get_total_weight()}', - 'container': f'{self.get_shipping_box(container)}', - 'width': '', - 'length': '', - 'height': '', - 'girth': '', - 'machinable': 'TRUE' - } - def build_order_params(self, container=None): return \ { @@ -187,7 +190,9 @@ class Cart: 'shipping_method': 'US POSTAL SERVICE ' + ( container if container else '' ), - 'shipping_address': self.build_shipping_address(self.session.get('shipping_address')), + 'shipping_address': self.build_shipping_address( + self.session.get('shipping_address') + ), } def create_order(self, container=None): @@ -199,20 +204,22 @@ class Cart: response = CreateOrder().create_order(params) return response + def get_line_options(self, options_dict): + options = '' + for key, value in options_dict.items(): + options += f'{key}: {value}; ' + return options + def build_bulk_list(self, order): bulk_list = [] - for item in self: - for key, value in item['variations'].items(): - bulk_list.append(OrderLine( - order=order, - product=item['product'], - customer_note=next((v[1] for i, v in enumerate(CoffeeGrind.GRIND_CHOICES) if v[0] == key), None), - unit_price=item['price'], - quantity=value['quantity'], - tax_rate=2, - )) - + bulk_list.append(OrderLine( + order=order, + variant=item['variant'], + customer_note=self.get_line_options(item['options']), + unit_price=item['variant'].price, + quantity=item['quantity'] + )) return bulk_list def build_shipping_address(self, address): @@ -232,12 +239,25 @@ class Cart: return Coupon.objects.get(code=self.coupon_code) return None + def get_coupon_total_for_specific_products(self): + for item in self.cart: + if item['variant'].product in self.coupon.products.all(): + yield item['price_total'] + def get_discount(self): + # SHIPPING + # ENTIRE_ORDER + # SPECIFIC_PRODUCT if self.coupon: if self.coupon.discount_value_type == DiscountValueType.FIXED: return round(self.coupon.discount_value, 2) elif self.coupon.discount_value_type == DiscountValueType.PERCENTAGE: - return round((self.coupon.discount_value / Decimal('100')) * self.get_total_price(), 2) + if self.coupon.type == VoucherType.ENTIRE_ORDER: + return round((self.coupon.discount_value / Decimal('100')) * self.get_total_price(), 2) + elif self.coupon.type == VoucherType.SPECIFIC_PRODUCT: + # Get the product in cart quantity + total = sum(self.get_coupon_total_for_specific_products()) + return round((self.coupon.discount_value / Decimal('100')) * total, 2) return Decimal('0') def get_subtotal_price_after_discount(self): diff --git a/src/storefront/context_processors.py b/src/storefront/context_processors.py index f29d3e2..7ae0783 100644 --- a/src/storefront/context_processors.py +++ b/src/storefront/context_processors.py @@ -1,6 +1,14 @@ +from core.models import ProductCategory from .cart import Cart + def cart(request): return { 'cart': Cart(request) } + + +def product_categories(self): + return { + 'category_list': ProductCategory.objects.all() + } diff --git a/src/storefront/forms.py b/src/storefront/forms.py index 75deefa..940fced 100644 --- a/src/storefront/forms.py +++ b/src/storefront/forms.py @@ -18,16 +18,29 @@ logger = logging.getLogger(__name__) class AddToCartForm(forms.Form): - grind = forms.ChoiceField(choices=CoffeeGrind.GRIND_CHOICES) quantity = forms.IntegerField(min_value=1, max_value=20, initial=1) + def __init__(self, variants, options, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.fields['variant'] = forms.ChoiceField( + label='', + choices=[(variant.pk, f'{variant.name} | ${variant.price}') for variant in variants] + ) + + for option in options: + self.fields[option.name] = forms.ChoiceField( + choices=[(opt, opt) for opt in option.options] + ) + class UpdateCartItemForm(forms.Form): + item_pk = forms.IntegerField(widget=forms.HiddenInput()) quantity = forms.IntegerField(min_value=1, max_value=20, initial=1) update = forms.BooleanField( required=False, initial=True, - widget=forms.HiddenInput + widget=forms.HiddenInput() ) @@ -106,15 +119,14 @@ class AddressForm(forms.Form): class CheckoutShippingForm(forms.Form): - SHIPPING_CHOICES = [ - (ShippingContainer.MD_FLAT_RATE_BOX, 'Flate Rate Box - Medium'), - (ShippingContainer.REGIONAL_RATE_BOX_B, 'Regional Rate Box B'), - ] + def __init__(self, containers, *args, **kwargs): + super().__init__(*args, **kwargs) - shipping_method = forms.ChoiceField( - widget=forms.RadioSelect, - choices=SHIPPING_CHOICES - ) + self.fields['shipping_method'] = forms.ChoiceField( + label='', + widget=forms.RadioSelect, + choices=[(container.pk, f'{container.name} ${container.s_cost}') for container in containers] + ) class OrderCreateForm(forms.ModelForm): @@ -125,11 +137,11 @@ class OrderCreateForm(forms.ModelForm): class Meta: model = Order fields = ( - 'total_net_amount', + 'total_amount', 'shipping_total', ) widgets = { - 'total_net_amount': forms.HiddenInput(), + 'total_amount': forms.HiddenInput(), 'shipping_total': forms.HiddenInput() } diff --git a/src/storefront/payments.py b/src/storefront/payments.py index 1d806ae..c55bdf8 100644 --- a/src/storefront/payments.py +++ b/src/storefront/payments.py @@ -93,21 +93,13 @@ class CreateOrder(PayPalClient): processed_items = [ { # Shows within upper-right dropdown during payment approval - "name": f'{item["product"]}: ' + ', '.join([ - next(( - f"{value['quantity']} x {v[1]}" - for i, v in enumerate(CoffeeGrind.GRIND_CHOICES) - if v[0] == key - ), - None, - ) for key, value in item["variations"].items()] - )[:100], + "name": str(item["variant"]), # Item details will also be in the completed paypal.com # transaction view - "description": item["product"].subtitle, + "description": item["variant"].product.subtitle, "unit_amount": { "currency_code": settings.DEFAULT_CURRENCY, - "value": f'{item["price"]}', + "value": f'{item["variant"].price}', }, "quantity": f'{item["quantity"]}', } diff --git a/src/storefront/templates/storefront/cart_detail.html b/src/storefront/templates/storefront/cart_detail.html index 418d1dd..9ff2cc8 100644 --- a/src/storefront/templates/storefront/cart_detail.html +++ b/src/storefront/templates/storefront/cart_detail.html @@ -11,26 +11,30 @@
{% for item in cart %}
- {% with product=item.product %} + {% with product=item.variant.product %}
{{product.get_first_img.image}}

{{product.name}}

-
Grind:
- {% for key, value in item.variations.items %} -

{{ key|get_grind_display }}
-

- {% csrf_token %} - {{ value.update_quantity_form }} - - Remove item -
-

+

{{ item.variant.name }}

+ {% for key, value in item.options.items %} +

{{ key }}: {{ value }}

{% endfor %} +
+ {% csrf_token %} + {{ item.update_quantity_form }} + +
+

Remove item

-

${{item.price}}

+

+ ${{ item.variant.price }} + {% if cart.coupon and cart.coupon.type == 'specific_product' and product in cart.coupon.products.all %} +
Coupon: {{ cart.coupon.name }} ({{cart.coupon.discount_value}} {{cart.coupon.get_discount_value_type_display}}) + {% endif %} +

{% endwith %}
@@ -56,7 +60,7 @@ Subtotal ${{ cart.get_total_price|floatformat:"2" }} - {% if cart.coupon %} + {% if cart.coupon and cart.coupon.type == 'entire_order' %} Coupon {{cart.coupon.discount_value}} {{cart.coupon.get_discount_value_type_display}} diff --git a/src/storefront/templates/storefront/category_detail.html b/src/storefront/templates/storefront/category_detail.html new file mode 100644 index 0000000..1ab698e --- /dev/null +++ b/src/storefront/templates/storefront/category_detail.html @@ -0,0 +1,39 @@ +{% extends 'base.html' %} +{% load static %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+

Welcome to our new website!

+

NEW COOL LOOK, SAME GREAT COFFEE

+
+{# Home > Category > "Coffee/Merchandise" #} + +{% endblock %} + diff --git a/src/storefront/templates/storefront/checkout_shipping_form.html b/src/storefront/templates/storefront/checkout_shipping_form.html index cd71ba3..e2d9c66 100644 --- a/src/storefront/templates/storefront/checkout_shipping_form.html +++ b/src/storefront/templates/storefront/checkout_shipping_form.html @@ -14,20 +14,7 @@ {% csrf_token %} {{ form.non_field_errors }}
- {{ form.shipping_method.label }} - {% for radio in form.shipping_method %} -

- - {{ radio.tag }} -

- {% endfor %} + {{form.as_p}}

diff --git a/src/storefront/templates/storefront/order_detail.html b/src/storefront/templates/storefront/order_detail.html index 7c0d48c..d5eb3ca 100644 --- a/src/storefront/templates/storefront/order_detail.html +++ b/src/storefront/templates/storefront/order_detail.html @@ -21,12 +21,12 @@ {% for item in order.lines.all %} - {% with product=item.product %} + {% with product=item.variant.product %} {{product.get_first_img.image}} - {{product.name}}
+ {{ item.variant }}
{{item.customer_note}} {{item.quantity}} @@ -48,7 +48,7 @@ - + {% if order.coupon %} diff --git a/src/storefront/templates/storefront/order_form.html b/src/storefront/templates/storefront/order_form.html index 3e50ac8..8614c54 100644 --- a/src/storefront/templates/storefront/order_form.html +++ b/src/storefront/templates/storefront/order_form.html @@ -32,18 +32,24 @@

Review items

{% for item in cart %}
- {% with product=item.product %} + {% with product=item.variant.product %}
{{product.get_first_img.image}}
-

{{product.name}}

- {% for key, value in item.variations.items %} -

Grind: {{ key|get_grind_display }}, Qty: {{value.quantity}}

+

{{product.name}}

+

{{ item.variant.name }}

+ {% for key, value in item.options.items %} +

{{ key }}: {{ value }}

{% endfor %}
-

${{item.price}}

+

+ {{ item.quantity }} × ${{ item.variant.price }} + {% if cart.coupon and cart.coupon.type == 'specific_product' and product in cart.coupon.products.all %} +
Coupon: {{ cart.coupon.name }} ({{cart.coupon.discount_value}} {{cart.coupon.get_discount_value_type_display}}) + {% endif %} +

{% endwith %}
@@ -61,7 +67,7 @@ - {% if cart.coupon %} + {% if cart.coupon and cart.coupon.type == 'entire_order' %} diff --git a/src/storefront/templates/storefront/product_detail.html b/src/storefront/templates/storefront/product_detail.html index 3594102..d88686f 100644 --- a/src/storefront/templates/storefront/product_detail.html +++ b/src/storefront/templates/storefront/product_detail.html @@ -21,9 +21,6 @@

{{product.name}}

{{product.subtitle}}

{{product.description}}

-

Fair trade

-

${{product.price}}

-

{{product.weight.oz|floatformat}}oz

{% csrf_token %} {{ form.as_p }} diff --git a/src/storefront/templates/storefront/product_list.html b/src/storefront/templates/storefront/product_list.html index e0eb0c5..ae9b85f 100644 --- a/src/storefront/templates/storefront/product_list.html +++ b/src/storefront/templates/storefront/product_list.html @@ -5,6 +5,14 @@ {% endblock %} +{% block product_categories %} + +{% endblock product_categories %} + {% block content %}

Welcome to our new website!

@@ -21,7 +29,7 @@

{{ product.name }}

{{ product.subtitle }}

{{product.description|truncatewords:20}}

-

${{product.price}} | {{product.weight.oz|floatformat}}oz

+

${{product.variants.first.price}}

{% endfor %} diff --git a/src/storefront/tests/test_cart.py b/src/storefront/tests/test_cart.py index 5ea93de..2aecbd1 100644 --- a/src/storefront/tests/test_cart.py +++ b/src/storefront/tests/test_cart.py @@ -35,7 +35,7 @@ class CartTest(TestCase): ) cls.order = Order.objects.create( customer=cls.customer, - total_net_amount=13.4 + total_amount=13.4 ) def setUp(self): diff --git a/src/storefront/tests/test_views.py b/src/storefront/tests/test_views.py index dbade8b..a680c05 100644 --- a/src/storefront/tests/test_views.py +++ b/src/storefront/tests/test_views.py @@ -78,7 +78,7 @@ class OrderCreateViewTest(TestCase): ) cls.order = Order.objects.create( customer=cls.customer, - total_net_amount=13.4 + total_amount=13.4 ) def setUp(self): diff --git a/src/storefront/urls.py b/src/storefront/urls.py index 9c758be..3512184 100644 --- a/src/storefront/urls.py +++ b/src/storefront/urls.py @@ -6,8 +6,17 @@ urlpatterns = [ path('fair-trade/', views.FairTradeView.as_view(), name='fair-trade'), path('reviews/', views.ReviewListView.as_view(), name='reviews'), path('contact/', views.ContactFormView.as_view(), name='contact'), - path('subscriptions/', views.SubscriptionCreateView.as_view(), name='subscriptions'), + path( + 'subscriptions/', + views.SubscriptionCreateView.as_view(), + name='subscriptions' + ), + path( + 'categories//', + views.ProductCategoryDetailView.as_view(), + name='category-detail' + ), path('', views.ProductListView.as_view(), name='product-list'), path('products//', include([ path('', views.ProductDetailView.as_view(), name='product-detail'), @@ -20,12 +29,12 @@ urlpatterns = [ name='cart-add' ), path( - 'cart//update//', + 'cart//update/', views.CartUpdateProductView.as_view(), name='cart-update', ), path( - 'cart//remove//', + 'cart//remove/', views.cart_remove_product_view, name='cart-remove', ), @@ -39,11 +48,6 @@ urlpatterns = [ views.paypal_order_transaction_capture, name='paypal-capture', ), - path( - 'paypal/webhooks/', - views.paypal_webhook_endpoint, - name='paypal-webhook' - ), path( 'checkout/address/', views.CheckoutAddressView.as_view(), diff --git a/src/storefront/views.py b/src/storefront/views.py index f02994b..b5120f7 100644 --- a/src/storefront/views.py +++ b/src/storefront/views.py @@ -22,6 +22,9 @@ from django.contrib import messages from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST from django.forms.models import model_to_dict +from django.db.models import ( + Exists, OuterRef, Prefetch, Subquery, Count, Sum, Avg, F, Q, Value +) from paypalcheckoutsdk.orders import OrdersCreateRequest, OrdersCaptureRequest from paypalcheckoutsdk.core import PayPalHttpClient, SandboxEnvironment @@ -31,8 +34,11 @@ 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 ( + ProductCategory, Product, ProductOption, + Order, Transaction, OrderLine, Coupon, ShippingRate +) +from core.forms import ShippingRateForm from core import OrderStatus, ShippingContainer from .forms import ( @@ -52,13 +58,13 @@ class CartView(TemplateView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) cart = Cart(self.request) - for item in cart: - for variation in item['variations'].values(): - variation['update_quantity_form'] = UpdateCartItemForm( - initial={ - 'quantity': variation['quantity'] - } - ) + for i, item in enumerate(cart): + item['update_quantity_form'] = UpdateCartItemForm( + initial={ + 'item_pk': i, + 'quantity': item['quantity'] + } + ) context['cart'] = cart context['coupon_apply_form'] = CouponApplyForm() return context @@ -72,23 +78,30 @@ class CartAddProductView(SingleObjectMixin, FormView): def get_success_url(self): return reverse('storefront:cart-detail') + def get_form(self, form_class=None): + variants = self.get_object().variants.all() + options = ProductOption.objects.filter(products__pk=self.get_object().pk) + if form_class is None: + form_class = self.get_form_class() + return form_class(variants, options, **self.get_form_kwargs()) + def post(self, request, *args, **kwargs): cart = Cart(request) form = self.get_form() if form.is_valid(): + cleaned_data = form.cleaned_data cart.add( request=request, - product=self.get_object(), - grind=form.cleaned_data['grind'], - quantity=form.cleaned_data['quantity'] + item={ + 'variant': cleaned_data.pop('variant'), + 'quantity': cleaned_data.pop('quantity'), + 'options': cleaned_data + } ) return self.form_valid(form) else: return self.form_invalid(form) - def form_valid(self, form): - return super().form_valid(form) - class CartUpdateProductView(SingleObjectMixin, FormView): model = Product @@ -104,9 +117,10 @@ class CartUpdateProductView(SingleObjectMixin, FormView): if form.is_valid(): cart.add( request=request, - product=self.get_object(), - grind=kwargs['grind'], - quantity=form.cleaned_data['quantity'], + item={ + 'variant': form.cleaned_data['item_pk'], + 'quantity': form.cleaned_data['quantity'] + }, update_quantity=form.cleaned_data['update'] ) return self.form_valid(form) @@ -117,10 +131,9 @@ class CartUpdateProductView(SingleObjectMixin, FormView): return super().form_valid(form) -def cart_remove_product_view(request, pk, grind): +def cart_remove_product_view(request, pk): cart = Cart(request) - product = get_object_or_404(Product, id=pk) - cart.remove(product, grind) + cart.remove(pk) return redirect('storefront:cart-detail') @@ -145,14 +158,31 @@ class CouponApplyView(FormView): return super().form_valid(form) -class ProductListView(FormMixin, ListView): +class ProductCategoryDetailView(DetailView): + model = ProductCategory + template_name = 'storefront/category_detail.html' + context_object_name = 'category' + + def get_queryset(self): + object_list = ProductCategory.objects.prefetch_related( + Prefetch( + 'product_set', + queryset=Product.objects.filter( + visible_in_listings=True + ) + ) + ) + return object_list + + +class ProductListView(ListView): model = Product template_name = 'storefront/product_list.html' - form_class = AddToCartForm ordering = 'sorting' queryset = Product.objects.filter( - visible_in_listings=True + visible_in_listings=True, + category__main_category=True ) @@ -161,6 +191,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(products__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' @@ -220,38 +257,27 @@ class CheckoutShippingView(FormView): success_url = reverse_lazy('storefront:order-create') def get(self, request, *args, **kwargs): - cart = Cart(request) - if len(cart) != 6: - if 'shipping_container' in self.request.session: - del self.request.session['shipping_container'] - return HttpResponseRedirect( - reverse('storefront:order-create') - ) - - if not self.request.session.get("shipping_address"): + if not self.request.session.get('shipping_address'): messages.warning(request, 'Please add a shipping address.') return HttpResponseRedirect( reverse('storefront:checkout-address') ) - return super().get(request, *args, **kwargs) - def get_context_data(self, **kwargs): + def get_form(self, form_class=None): cart = Cart(self.request) - context = super().get_context_data(**kwargs) - context['MD_FLAT_RATE_BOX'] = cart.get_shipping_cost( - ShippingContainer.MD_FLAT_RATE_BOX - ) - context['REGIONAL_RATE_BOX_B'] = cart.get_shipping_cost( - ShippingContainer.REGIONAL_RATE_BOX_B - ) - return context + containers = cart.get_shipping_container_choices() + for container in containers: + container.s_cost = cart.get_shipping_cost(container.container) + if form_class is None: + form_class = self.get_form_class() + return form_class(containers, **self.get_form_kwargs()) def form_valid(self, form): - cleaned_data = form.cleaned_data - self.request.session['shipping_container'] = cleaned_data.get( - 'shipping_method' + shipping_container = ShippingRate.objects.get( + pk=form.cleaned_data.get('shipping_method') ) + self.request.session['shipping_container'] = shipping_container return super().form_valid(form) @@ -262,17 +288,13 @@ class OrderCreateView(CreateView): success_url = reverse_lazy('storefront:payment-done') def get(self, request, *args, **kwargs): - cart = Cart(request) - if len(cart) != 6 and 'shipping_container' in self.request.session: - del self.request.session['shipping_container'] - - if not self.request.session.get("shipping_address"): + if not self.request.session.get('shipping_address'): messages.warning(request, 'Please add a shipping address.') return HttpResponseRedirect( reverse('storefront:checkout-address') ) elif self.request.session.get('coupon_code'): - address = self.request.session.get("shipping_address") + address = self.request.session.get('shipping_address') coupon = Coupon.objects.get( code=self.request.session.get('coupon_code') ) @@ -283,19 +305,22 @@ class OrderCreateView(CreateView): if user in coupon.users.all(): del self.request.session['coupon_code'] messages.warning(request, 'Coupon already used.') - return super().get(request, *args, **kwargs) def get_initial(self): cart = Cart(self.request) + shipping_container = self.request.session.get( + 'shipping_container' + ).container try: - shipping_cost = cart.get_shipping_cost() + shipping_cost = cart.get_shipping_cost(shipping_container) except Exception as e: - raise e('Could not get shipping information') + logger.error('Could not get shipping information') + raise shipping_cost = Decimal('0.00') initial = { - 'total_net_amount': cart.get_total_price(), + 'total_amount': cart.get_total_price(), 'shipping_total': shipping_cost } if self.request.session.get('shipping_address'): @@ -317,8 +342,12 @@ class OrderCreateView(CreateView): def form_valid(self, form): cart = Cart(self.request) + form.instance.subtotal_amount = cart.get_subtotal_price_after_discount() + form.instance.coupon_amount = cart.get_discount() + form.instance.total_amount = cart.get_total_price_after_discount() + form.instance.weight = cart.get_total_weight() shipping_address = self.request.session.get('shipping_address') - shipping_container = self.request.session.get('shipping_container') + shipping_container = self.request.session.get('shipping_container').container form.instance.customer, form.instance.shipping_address = get_or_create_customer(self.request, form, shipping_address) form.instance.status = OrderStatus.DRAFT self.object = form.save() @@ -364,14 +393,6 @@ def paypal_order_transaction_capture(request, transaction_id): return JsonResponse({'details': 'invalid request'}) -@csrf_exempt -@require_POST -def paypal_webhook_endpoint(request): - data = json.loads(request.body) - logger.info(data) - return JsonResponse(data) - - class PaymentDoneView(TemplateView): template_name = 'storefront/payment_done.html' diff --git a/src/templates/base.html b/src/templates/base.html index 7d2ed20..c8c82d5 100644 --- a/src/templates/base.html +++ b/src/templates/base.html @@ -46,7 +46,14 @@
Subtotal${{order.total_net_amount}}${{order.subtotal}}
Subtotal ${{cart.get_total_price|floatformat:"2"}}
Coupon {{cart.coupon.discount_value}} {{cart.coupon.get_discount_value_type_display}}