diff --git a/src/accounts/models.py b/src/accounts/models.py index 1647c59..bdda95d 100644 --- a/src/accounts/models.py +++ b/src/accounts/models.py @@ -19,10 +19,10 @@ class Address(models.Model): def __str__(self): return f""" - {first_name} {last_name} - {street_address_1} - {street_address_2} - {city}, {state}, {postal_code} + {self.first_name} {self.last_name} + {self.street_address_1} + {self.street_address_2} + {self.city}, {self.state}, {self.postal_code} """ diff --git a/src/core/migrations/0002_shippingrate_is_selectable_and_more.py b/src/core/migrations/0002_shippingrate_is_selectable_and_more.py new file mode 100644 index 0000000..130ee1b --- /dev/null +++ b/src/core/migrations/0002_shippingrate_is_selectable_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 4.0.2 on 2022-10-29 14:44 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='shippingrate', + name='is_selectable', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='sitesettings', + name='default_shipping_rate', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='core.shippingrate'), + ), + ] diff --git a/src/core/models.py b/src/core/models.py index d4de40b..3e290e7 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -56,17 +56,6 @@ class SingletonBase(models.Model): abstract = True -class SiteSettings(SingletonBase): - usps_user_id = models.CharField(max_length=255) - - def __str__(self): - return 'Site Settings' - - class Meta: - verbose_name = 'Site Settings' - verbose_name_plural = 'Site Settings' - - class ProductCategory(models.Model): name = models.CharField(max_length=255) main_category = models.BooleanField(default=True) @@ -271,6 +260,7 @@ class ShippingRate(models.Model): blank=True, null=True ) + is_selectable = models.BooleanField(default=True) def get_absolute_url(self): return reverse('dashboard:rate-detail', kwargs={'pk': self.pk}) @@ -366,6 +356,14 @@ class Order(models.Model): objects = OrderManager() + def minus_stock(self): + for line in self.lines.all(): + line.minus_stock() + + def add_stock(self): + for line in self.lines.all(): + line.add_stock() + def get_total_quantity(self): return sum([line.quantity for line in self]) @@ -443,6 +441,16 @@ class OrderLine(models.Model): def quantity_unfulfilled(self): return self.quantity - self.quantity_fulfilled + def minus_stock(self): + if self.variant.track_inventory: + self.variant.stock -= self.quantity + self.variant.save() + + def add_stock(self): + if self.variant.track_inventory: + self.variant.stock += self.quantity + self.variant.save() + class TrackingNumber(models.Model): order = models.ForeignKey( @@ -472,3 +480,21 @@ class Subscription(models.Model): on_delete=models.SET_NULL, null=True ) + + +class SiteSettings(SingletonBase): + usps_user_id = models.CharField(max_length=255) + default_shipping_rate = models.ForeignKey( + ShippingRate, + blank=True, + null=True, + related_name='+', + on_delete=models.SET_NULL + ) + + def __str__(self): + return 'Site Settings' + + class Meta: + verbose_name = 'Site Settings' + verbose_name_plural = 'Site Settings' diff --git a/src/dashboard/templates/dashboard/customer_list.html b/src/dashboard/templates/dashboard/customer_list.html index 5387de4..d7aac40 100644 --- a/src/dashboard/templates/dashboard/customer_list.html +++ b/src/dashboard/templates/dashboard/customer_list.html @@ -22,5 +22,24 @@ No customers {% endfor %} +
+ +
{% endblock content %} diff --git a/src/dashboard/templates/dashboard/option_form.html b/src/dashboard/templates/dashboard/option_form.html index e69de29..84f5538 100644 --- a/src/dashboard/templates/dashboard/option_form.html +++ b/src/dashboard/templates/dashboard/option_form.html @@ -0,0 +1,18 @@ +{% extends "dashboard.html" %} + +{% block content %} +
+
+

Update option

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

+ or cancel +

+
+
+
+{% endblock %} diff --git a/src/dashboard/templates/dashboard/order_detail.html b/src/dashboard/templates/dashboard/order_detail.html index 0c325b8..c530b70 100644 --- a/src/dashboard/templates/dashboard/order_detail.html +++ b/src/dashboard/templates/dashboard/order_detail.html @@ -4,7 +4,10 @@ {% block content %}
-

Order #{{order.pk}}

+
+

Order #{{order.pk}}

+

Date: {{ order.created_at }}

+
Cancel order {{order.get_status_display}} ({{order.total_quantity_fulfilled}} / {{order.total_quantity_ordered}}) @@ -102,7 +105,7 @@ Discount: {{order.coupon.discount_value}} {{order.coupon.get_discount_value_type_display}}
{% endif %} Shipping: ${{order.shipping_total}}
- Total: ${{order.get_total_price_after_discount}} + Total: ${{order.total_amount}}

diff --git a/src/dashboard/templates/dashboard/rate_form.html b/src/dashboard/templates/dashboard/rate_form.html index 81e364e..0947361 100644 --- a/src/dashboard/templates/dashboard/rate_form.html +++ b/src/dashboard/templates/dashboard/rate_form.html @@ -10,7 +10,7 @@ {% csrf_token %} {{form.as_p}}

- or cancel + or cancel

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

Stock

+

Total in warehouse = available stock + unfulfilled

+
+ +
+
+ Product + SKU + Available Stock + Total in warehouse +
+ {% for variant in variant_list %} +
+ {% with product=variant.product %} +
+ {{product.get_first_img.image}} +
{{variant}}
+
+ {{ variant.sku }} + {{ variant.stock }} + {{ variant.total_in_warehouse }} + Restock → + {% endwith %} +
+ {% endfor %} +
+
+{% endblock %} diff --git a/src/dashboard/templates/dashboard/variant_form.html b/src/dashboard/templates/dashboard/variant_form.html index 03f496e..44107d8 100644 --- a/src/dashboard/templates/dashboard/variant_form.html +++ b/src/dashboard/templates/dashboard/variant_form.html @@ -11,7 +11,7 @@ {% csrf_token %} {{form.as_p}}

- or cancel + or cancel

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

Restock variant

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

Total in warehouse: {{ variant.total_in_warehouse }}

+

+ or cancel +

+
+
+
+{% endblock %} diff --git a/src/dashboard/urls.py b/src/dashboard/urls.py index b054de5..dc2d70c 100644 --- a/src/dashboard/urls.py +++ b/src/dashboard/urls.py @@ -17,6 +17,11 @@ urlpatterns = [ views.CatalogView.as_view(), name='catalog' ), + path( + 'stock/', + views.StockView.as_view(), + name='stock' + ), path( 'shipping-rates/new/', @@ -186,6 +191,11 @@ urlpatterns = [ views.ProductVariantDeleteView.as_view(), name='variant-delete' ), + path( + 'restock/', + views.ProductVariantStockUpdateView.as_view(), + name='variant-restock' + ), ])), ])), ])), diff --git a/src/dashboard/views.py b/src/dashboard/views.py index 1da356a..1ceb8cd 100644 --- a/src/dashboard/views.py +++ b/src/dashboard/views.py @@ -18,7 +18,8 @@ from django.contrib import messages from django.contrib.messages.views import SuccessMessageMixin from django.db.models import ( - Exists, OuterRef, Prefetch, Subquery, Count, Sum, Avg, F, Q, Value + Exists, OuterRef, Prefetch, Subquery, Count, Sum, Avg, F, Q, Value, + ExpressionWrapper, IntegerField ) from django.db.models.functions import Coalesce @@ -101,6 +102,25 @@ class CatalogView(ListView): return context +class StockView(ListView): + model = ProductVariant + context_object_name = 'variant_list' + template_name = 'dashboard/stock.html' + + def get_queryset(self): + object_list = ProductVariant.objects.filter( + track_inventory=True + ).prefetch_related('order_lines', 'product').annotate( + total_in_warehouse=F('stock') + Coalesce(Sum('order_lines__quantity', filter=Q( + order_lines__order__status=OrderStatus.UNFULFILLED) | Q( + order_lines__order__status=OrderStatus.PARTIALLY_FULFILLED) + ) - Sum('order_lines__quantity_fulfilled', filter=Q( + order_lines__order__status=OrderStatus.UNFULFILLED) | Q( + order_lines__order__status=OrderStatus.PARTIALLY_FULFILLED)), 0) + ).order_by('product') + return object_list + + class ShippingRateDetailView(LoginRequiredMixin, DetailView): model = ShippingRate context_object_name = 'rate' @@ -234,6 +254,11 @@ class OrderCancelView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): 'status': OrderStatus.CANCELED } + def form_valid(self, form): + form.instance.add_stock() + form.instance.save() + return super().form_valid(form) + def get_success_url(self): return reverse('dashboard:order-detail', kwargs={'pk': self.object.pk}) @@ -441,6 +466,38 @@ class ProductVariantDeleteView(SuccessMessageMixin, DeleteView): return reverse('dashboard:product-detail', kwargs={'pk': self.kwargs['pk']}) +class ProductVariantStockUpdateView(LoginRequiredMixin, UpdateView): + model = ProductVariant + pk_url_kwarg = 'variant_pk' + success_message = 'ProductVariant saved.' + success_url = reverse_lazy('dashboard:stock') + template_name = 'dashboard/variant_restock.html' + fields = [ + 'stock', + ] + context_object_name = 'variant' + + def get_queryset(self): + queryset = ProductVariant.objects.annotate( + total_in_warehouse=F('stock') + Coalesce(Sum('order_lines__quantity', filter=Q( + order_lines__order__status=OrderStatus.UNFULFILLED) | Q( + order_lines__order__status=OrderStatus.PARTIALLY_FULFILLED) + ) - Sum('order_lines__quantity_fulfilled', filter=Q( + order_lines__order__status=OrderStatus.UNFULFILLED) | Q( + order_lines__order__status=OrderStatus.PARTIALLY_FULFILLED)), 0) + ).prefetch_related('order_lines', 'product') + return queryset + + 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) + + class ProductOptionDetailView(LoginRequiredMixin, DetailView): model = ProductOption template_name = 'dashboard/option_detail.html' @@ -482,6 +539,7 @@ class ProductOptionDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteVie class CustomerListView(LoginRequiredMixin, ListView): model = User template_name = 'dashboard/customer_list.html' + paginate_by = 100 def get_queryset(self): object_list = User.objects.filter( @@ -492,7 +550,7 @@ class CustomerListView(LoginRequiredMixin, ListView): 'orders' ).annotate( num_orders=Count('orders') - ) + ).order_by('first_name', 'last_name') return object_list diff --git a/src/static/images/warehouse.png b/src/static/images/warehouse.png new file mode 100644 index 0000000..bbede28 Binary files /dev/null and b/src/static/images/warehouse.png differ diff --git a/src/storefront/cart.py b/src/storefront/cart.py index 65d0f28..ff0c03e 100644 --- a/src/storefront/cart.py +++ b/src/storefront/cart.py @@ -72,19 +72,39 @@ class Cart: if update_quantity: self.cart[item['variant']]['quantity'] = item['quantity'] else: - self.cart.append(item) + self.add_or_update_item(item) # TODO: abstract this to a function that will check the max amount of item in the cart if len(self) <= 20: + self.check_item_stock_quantities(request) self.save() else: messages.warning(request, "Cart is full: 20 items or less.") + def add_or_update_item(self, new_item): + new_item_pk = int(new_item['variant']) + for item in self: + if new_item_pk == item['variant'].pk: + if new_item['options'] == item['options']: + item['quantity'] += new_item['quantity'] + return + else: + continue + self.cart.append(new_item) + def save(self): self.session[settings.CART_SESSION_ID] = self.cart self.session.modified = True logger.info(f'\nCart:\n{self.cart}\n') + def check_item_stock_quantities(self, request): + for item in self: + if item['variant'].track_inventory: + if item['quantity'] > item['variant'].stock: + messages.warning(request, 'Quantity added exceeds available stock.') + item['quantity'] = item['variant'].stock + self.save() + def remove(self, pk): self.cart.pop(pk) self.save() @@ -123,6 +143,9 @@ class Cart: return 0 def get_shipping_container_choices(self): + is_selectable = Q( + is_selectable=True + ) min_weight_matched = Q( min_order_weight__lte=self.get_total_weight()) | Q( min_order_weight__isnull=True @@ -132,7 +155,7 @@ class Cart: max_order_weight__isnull=True ) containers = ShippingRate.objects.filter( - min_weight_matched & max_weight_matched + is_selectable & min_weight_matched & max_weight_matched ) return containers @@ -146,10 +169,7 @@ class Cart: container, str(self.session.get('shipping_address')['postal_code']) ) - try: - logger.info('wafd') - except TypeError as e: - return Decimal('0.00') + usps = USPSApi(settings.USPS_USER_ID, test=True) try: @@ -159,12 +179,12 @@ class Cart: 'Could not connect to USPS, try again.' ) - logger.error(validation.result) + logger.info(validation.result) package = dict(validation.result['RateV4Response']['Package']) if 'Error' not in package: rate = package['Postage']['CommercialRate'] else: - logger.error("USPS Rate error") + logger.error('USPS Rate error') rate = '0.00' return Decimal(rate) else: diff --git a/src/storefront/forms.py b/src/storefront/forms.py index 940fced..fbe7a1c 100644 --- a/src/storefront/forms.py +++ b/src/storefront/forms.py @@ -11,7 +11,7 @@ from localflavor.us.us_states import USPS_CHOICES from usps import USPSApi, Address from captcha.fields import CaptchaField -from core.models import Order +from core.models import Order, ProductVariant from core import CoffeeGrind, ShippingContainer logger = logging.getLogger(__name__) diff --git a/src/storefront/templates/storefront/category_detail.html b/src/storefront/templates/storefront/category_detail.html index 1ab698e..c19c4fb 100644 --- a/src/storefront/templates/storefront/category_detail.html +++ b/src/storefront/templates/storefront/category_detail.html @@ -10,7 +10,6 @@

Welcome to our new website!

NEW COOL LOOK, SAME GREAT COFFEE

-{# Home > Category > "Coffee/Merchandise" #}