From 775df2501a7b6dbe320eb0fb455232ba5fc1b55f Mon Sep 17 00:00:00 2001 From: Nathan Chapman Date: Fri, 1 Apr 2022 11:11:50 -0600 Subject: [PATCH] Add coupon functionality --- src/core/migrations/0004_order_coupon.py | 19 ++++ ...5_alter_product_options_product_sorting.py | 22 +++++ src/core/models.py | 27 +++++- src/dashboard/forms.py | 31 ++++++- src/dashboard/templates/dashboard/config.html | 4 +- .../dashboard/coupon_confirm_delete.html | 19 ++++ .../dashboard/coupon_create_form.html | 18 ++++ .../templates/dashboard/coupon_detail.html | 23 +++++ .../templates/dashboard/coupon_form.html | 18 ++++ .../templates/dashboard/coupon_list.html | 33 +++++++ .../templates/dashboard/customer_detail.html | 10 +- .../templates/dashboard/customer_form.html | 8 +- .../templates/dashboard/customer_list.html | 8 +- .../templates/dashboard/order_detail.html | 27 ++++-- .../templates/dashboard/order_fulfill.html | 6 +- .../templates/dashboard/order_list.html | 4 +- .../dashboard/order_tracking_form.html | 13 ++- .../dashboard/prodphoto_create_form.html | 18 ++++ .../templates/dashboard/product_detail.html | 19 ++++ .../templates/dashboard/product_list.html | 4 +- src/dashboard/urls.py | 13 +++ src/dashboard/views.py | 92 +++++++++++++++++-- src/ptcoffee/config.py | 1 + src/static/scripts/product_form.js | 14 +++ src/static/styles/dashboard.css | 51 +++++++++- src/static/styles/main.css | 52 +++++++---- src/storefront/cart.py | 40 +++++--- src/storefront/forms.py | 56 +++++++++++ src/storefront/payments.py | 8 +- src/storefront/tasks.py | 36 ++++---- .../templates/storefront/cart_detail.html | 17 +++- .../storefront/checkout_address.html | 4 +- .../templates/storefront/contact_form.html | 14 +++ .../templates/storefront/customer_detail.html | 8 +- .../templates/storefront/order_form.html | 8 +- .../templates/storefront/product_detail.html | 7 +- .../templates/storefront/product_list.html | 3 - src/storefront/urls.py | 4 + src/storefront/views.py | 50 +++++++++- src/templates/account/email.html | 4 +- src/templates/base.html | 7 +- src/templates/dashboard.html | 2 +- .../storefront/contact_form.email | 16 ++++ 43 files changed, 714 insertions(+), 124 deletions(-) create mode 100644 src/core/migrations/0004_order_coupon.py create mode 100644 src/core/migrations/0005_alter_product_options_product_sorting.py create mode 100644 src/dashboard/templates/dashboard/coupon_confirm_delete.html create mode 100644 src/dashboard/templates/dashboard/coupon_create_form.html create mode 100644 src/dashboard/templates/dashboard/coupon_detail.html create mode 100644 src/dashboard/templates/dashboard/coupon_form.html create mode 100644 src/dashboard/templates/dashboard/coupon_list.html create mode 100644 src/dashboard/templates/dashboard/prodphoto_create_form.html create mode 100644 src/static/scripts/product_form.js create mode 100644 src/storefront/templates/storefront/contact_form.html create mode 100644 src/templates/templated_email/storefront/contact_form.email diff --git a/src/core/migrations/0004_order_coupon.py b/src/core/migrations/0004_order_coupon.py new file mode 100644 index 0000000..f33dc45 --- /dev/null +++ b/src/core/migrations/0004_order_coupon.py @@ -0,0 +1,19 @@ +# Generated by Django 4.0.2 on 2022-03-23 21:30 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0003_trackingnumber_created_at_trackingnumber_updated_at'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='coupon', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='core.coupon'), + ), + ] diff --git a/src/core/migrations/0005_alter_product_options_product_sorting.py b/src/core/migrations/0005_alter_product_options_product_sorting.py new file mode 100644 index 0000000..a7eca60 --- /dev/null +++ b/src/core/migrations/0005_alter_product_options_product_sorting.py @@ -0,0 +1,22 @@ +# Generated by Django 4.0.2 on 2022-03-28 17:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0004_order_coupon'), + ] + + operations = [ + migrations.AlterModelOptions( + name='product', + options={'ordering': ['sorting', 'name']}, + ), + migrations.AddField( + model_name='product', + name='sorting', + field=models.PositiveIntegerField(blank=True, null=True), + ), + ] diff --git a/src/core/models.py b/src/core/models.py index 54224b0..9e9082c 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -49,6 +49,7 @@ class Product(models.Model): ) visible_in_listings = models.BooleanField(default=False) + sorting = models.PositiveIntegerField(blank=True, null=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -61,6 +62,9 @@ class Product(models.Model): def get_absolute_url(self): return reverse('dashboard:product-detail', kwargs={'pk': self.pk}) + class Meta: + ordering = ['sorting', 'name'] + class ProductPhoto(models.Model): product = models.ForeignKey(Product, on_delete=models.CASCADE) @@ -107,9 +111,12 @@ class Coupon(models.Model): @property def is_valid(self): - today = timezone.localtime(timezone.now()).date() + today = timezone.localtime(timezone.now()) return True if today >= self.valid_from and today <= self.valid_to else False + def get_absolute_url(self): + return reverse('dashboard:coupon-detail', kwargs={'pk': self.pk}) + @@ -175,6 +182,13 @@ class Order(models.Model): on_delete=models.SET_NULL, ) + coupon = models.ForeignKey( + Coupon, + related_name='orders', + on_delete=models.SET_NULL, + null=True + ) + total_net_amount = models.DecimalField( max_digits=10, decimal_places=2, @@ -196,6 +210,17 @@ class Order(models.Model): def get_total_quantity(self): return sum([line.quantity for line in self]) + def get_discount(self): + if self.coupon: + 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 Decimal('0') + + def get_total_price_after_discount(self): + return round(self.total_net_amount - self.get_discount(), 2) + def get_absolute_url(self): return reverse('dashboard:order-detail', kwargs={'pk': self.pk}) diff --git a/src/dashboard/forms.py b/src/dashboard/forms.py index 272ddfa..e716884 100644 --- a/src/dashboard/forms.py +++ b/src/dashboard/forms.py @@ -1,10 +1,33 @@ import logging from django import forms -from core.models import Order, OrderLine, ShippingMethod, TrackingNumber +from core.models import Order, OrderLine, ShippingMethod, TrackingNumber, Coupon, ProductPhoto logger = logging.getLogger(__name__) +class CouponForm(forms.ModelForm): + class Meta: + model = Coupon + fields = ( + 'type', + 'name', + 'code', + 'valid_from', + 'valid_to', + 'discount_value_type', + 'discount_value', + 'products', + ) + widgets = { + 'valid_from': forms.DateInput(attrs = { + 'type': 'date' + }), + 'valid_to': forms.DateInput(attrs = { + 'type': 'date' + }), + } + + class OrderLineFulfillForm(forms.ModelForm): # send_shipment_details_to_customer = forms.BooleanField(initial=True) @@ -38,3 +61,9 @@ OrderTrackingFormset = forms.inlineformset_factory( Order, TrackingNumber, form=OrderTrackingForm, extra=1, can_delete=False ) + + +class ProductPhotoForm(forms.ModelForm): + class Meta: + model = ProductPhoto + fields = ('image',) diff --git a/src/dashboard/templates/dashboard/config.html b/src/dashboard/templates/dashboard/config.html index 1dcf8fb..0874ed3 100644 --- a/src/dashboard/templates/dashboard/config.html +++ b/src/dashboard/templates/dashboard/config.html @@ -9,7 +9,7 @@
-
+

Shipping methods

+ New method
@@ -25,7 +25,7 @@
-
+

Staff

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

Coupon

+
+
+
{% csrf_token %} +

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

+ {{ form.as_p }} +

+ or cancel +

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

Create coupon

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

+ or cancel +

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

{{ coupon.name }}

+
+ Delete + Edit +
+
+
+
+

{{ coupon.get_type_display }}

+

{{ coupon.code }}

+

{{ coupon.valid_from }}

+

{{ coupon.valid_to }}

+

{{ coupon.discount_value }} {{ coupon.get_discount_value_type_display }}

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

Update Coupon

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

+ or cancel +

+
+
+
+{% endblock %} diff --git a/src/dashboard/templates/dashboard/coupon_list.html b/src/dashboard/templates/dashboard/coupon_list.html new file mode 100644 index 0000000..6984349 --- /dev/null +++ b/src/dashboard/templates/dashboard/coupon_list.html @@ -0,0 +1,33 @@ +{% extends "dashboard.html" %} +{% load static %} + +{% block content %} + +{% endblock content %} diff --git a/src/dashboard/templates/dashboard/customer_detail.html b/src/dashboard/templates/dashboard/customer_detail.html index 88c7534..e724a28 100644 --- a/src/dashboard/templates/dashboard/customer_detail.html +++ b/src/dashboard/templates/dashboard/customer_detail.html @@ -5,10 +5,12 @@

Customer: {{customer.get_full_name}}

- Edit +
+ Edit +
-
+

Info

@@ -52,14 +54,14 @@
{% with order_list=customer.orders.all %}
-
+
Order # Date Status Total
{% for order in order_list %} - + #{{order.pk}} {{order.created_at|date:"D, M j Y"}} diff --git a/src/dashboard/templates/dashboard/customer_form.html b/src/dashboard/templates/dashboard/customer_form.html index 7df14ac..0c73630 100644 --- a/src/dashboard/templates/dashboard/customer_form.html +++ b/src/dashboard/templates/dashboard/customer_form.html @@ -2,9 +2,11 @@ {% block content %}
-

Update Customer

-
-
+
+

Update Customer

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

diff --git a/src/dashboard/templates/dashboard/customer_list.html b/src/dashboard/templates/dashboard/customer_list.html index 223eaa8..5387de4 100644 --- a/src/dashboard/templates/dashboard/customer_list.html +++ b/src/dashboard/templates/dashboard/customer_list.html @@ -3,15 +3,17 @@ {% block content %}

-

Customers

+
+

Customers

+
-
-
-
+

Shipping

Ship order →
@@ -66,7 +66,7 @@
-
+

Customer

{% with customer=order.customer %} @@ -98,7 +98,22 @@
-
+
+

Payment

+
+
+

+ Subtotal: {{order.total_net_amount}}
+ {% if order.coupon %} + Discount: {{order.coupon.discount_value}} {{order.coupon.get_discount_value_type_display}}
+ {% endif %} + Total: {{order.get_total_price_after_discount}} +

+
+
+ +
+

Transaction

diff --git a/src/dashboard/templates/dashboard/order_fulfill.html b/src/dashboard/templates/dashboard/order_fulfill.html index 81e3027..f5f7620 100644 --- a/src/dashboard/templates/dashboard/order_fulfill.html +++ b/src/dashboard/templates/dashboard/order_fulfill.html @@ -16,14 +16,14 @@
{% endfor %} {% endfor %} -
+
Product SKU Quantity to fulfill Grind
{% for form in form %} -
+
{% with product=form.instance.product %} {{form.id}}
@@ -36,7 +36,7 @@ {% endwith %}
{% endfor %} -
+
diff --git a/src/dashboard/templates/dashboard/order_list.html b/src/dashboard/templates/dashboard/order_list.html index 6c08a12..b620945 100644 --- a/src/dashboard/templates/dashboard/order_list.html +++ b/src/dashboard/templates/dashboard/order_list.html @@ -7,7 +7,7 @@

Orders

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

Add photo

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

+ or cancel +

+ +
+
+{% endblock %} diff --git a/src/dashboard/templates/dashboard/product_detail.html b/src/dashboard/templates/dashboard/product_detail.html index 73f320a..f367505 100644 --- a/src/dashboard/templates/dashboard/product_detail.html +++ b/src/dashboard/templates/dashboard/product_detail.html @@ -23,5 +23,24 @@

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

+
+
+

Photos

+ + Upload new photo +
+ +
{% endblock content %} diff --git a/src/dashboard/templates/dashboard/product_list.html b/src/dashboard/templates/dashboard/product_list.html index a22e198..002ed80 100644 --- a/src/dashboard/templates/dashboard/product_list.html +++ b/src/dashboard/templates/dashboard/product_list.html @@ -8,14 +8,14 @@ + New product
-
+
Name Visible Price
{% for product in product_list %} - +
{{product.productphoto_set.first.image}}
diff --git a/src/dashboard/urls.py b/src/dashboard/urls.py index a86ab55..5f511f4 100644 --- a/src/dashboard/urls.py +++ b/src/dashboard/urls.py @@ -10,6 +10,14 @@ urlpatterns = [ path('', views.ShippingMethodDetailView.as_view(), name='shipmeth-detail'), ])), + path('coupons/', views.CouponListView.as_view(), name='coupon-list'), + path('coupons/new/', views.CouponCreateView.as_view(), name='coupon-create'), + path('coupons//', include([ + path('', views.CouponDetailView.as_view(), name='coupon-detail'), + path('update/', views.CouponUpdateView.as_view(), name='coupon-update'), + path('delete/', views.CouponDeleteView.as_view(), name='coupon-delete'), + ])), + path('orders/', views.OrderListView.as_view(), name='order-list'), path('orders//', include([ path('', views.OrderDetailView.as_view(), name='order-detail'), @@ -25,6 +33,11 @@ urlpatterns = [ path('', views.ProductDetailView.as_view(), name='product-detail'), path('update/', views.ProductUpdateView.as_view(), name='product-update'), path('delete/', views.ProductDeleteView.as_view(), name='product-delete'), + + path('photos/new/', views.ProductPhotoCreateView.as_view(), name='prodphoto-create'), + path('photos//', include([ + path('delete/', views.ProductPhotoDeleteView.as_view(), name='prodphoto-delete'), + ])), ])), path('customers/', views.CustomerListView.as_view(), name='customer-list'), diff --git a/src/dashboard/views.py b/src/dashboard/views.py index 19599be..29d798a 100644 --- a/src/dashboard/views.py +++ b/src/dashboard/views.py @@ -23,10 +23,19 @@ from django.db.models.functions import Coalesce from accounts.models import User from accounts.utils import get_or_create_customer from accounts.forms import AddressForm -from core.models import Product, Order, OrderLine, ShippingMethod, Transaction, TrackingNumber +from core.models import ( + Product, + ProductPhoto, + Order, + OrderLine, + ShippingMethod, + Transaction, + TrackingNumber, + Coupon +) from core import DiscountValueType, VoucherType, OrderStatus, ShippingMethodType -from .forms import OrderLineFulfillForm, OrderLineFormset, OrderTrackingFormset +from .forms import OrderLineFulfillForm, OrderLineFormset, OrderTrackingFormset, CouponForm, ProductPhotoForm logger = logging.getLogger(__name__) @@ -59,6 +68,8 @@ class DashboardConfigView(TemplateView): return context + + class ShippingMethodCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): model = ShippingMethod template_name = 'dashboard/shipmeth_create_form.html' @@ -70,6 +81,35 @@ class ShippingMethodDetailView(LoginRequiredMixin, DetailView): template_name = 'dashboard/shipmeth_detail.html' + +class CouponListView(LoginRequiredMixin, ListView): + model = Coupon + template_name = 'dashboard/coupon_list.html' + +class CouponCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): + model = Coupon + template_name = 'dashboard/coupon_create_form.html' + form_class = CouponForm + success_message = '%(name)s created.' + +class CouponDetailView(LoginRequiredMixin, DetailView): + model = Coupon + template_name = 'dashboard/coupon_detail.html' + +class CouponUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): + model = Coupon + template_name = 'dashboard/coupon_form.html' + success_message = '%(name)s saved.' + form_class = CouponForm + +class CouponDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView): + model = Coupon + template_name = 'dashboard/coupon_confirm_delete.html' + success_url = reverse_lazy('dashboard:coupon-list') + success_message = 'Coupon deleted.' + + + class OrderListView(LoginRequiredMixin, ListView): model = Order template_name = 'dashboard/order_list.html' @@ -147,6 +187,7 @@ class OrderTrackingView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): class ProductListView(LoginRequiredMixin, ListView): model = Product template_name = 'dashboard/product_list.html' + ordering = 'sorting' # def get_queryset(self): # object_list = Product.objects.filter( @@ -164,19 +205,52 @@ class ProductUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): model = Product template_name = 'dashboard/product_update_form.html' fields = '__all__' - success_message = "%(name)s saved." + success_message = '%(name)s saved.' + +class ProductCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): + model = Product + template_name = 'dashboard/product_create_form.html' + fields = '__all__' + success_message = '%(name)s created.' class ProductDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView): model = Product template_name = 'dashboard/product_confirm_delete.html' success_url = reverse_lazy('dashboard:product-list') - success_message = "Product deleted." + success_message = 'Product deleted.' + + +class ProductPhotoCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): + model = ProductPhoto + pk_url_kwarg = 'photo_pk' + template_name = 'dashboard/prodphoto_create_form.html' + form_class = ProductPhotoForm + success_message = 'Photo added.' + + 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 ProductPhotoDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView): + model = ProductPhoto + pk_url_kwarg = 'photo_pk' + template_name = 'dashboard/prodphoto_confirm_delete.html' + success_message = 'Photo deleted.' + + def get_success_url(self): + return reverse('dashboard:product-detail', kwargs={'pk': self.kwargs['pk']}) + + + -class ProductCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): - model = Product - template_name = "dashboard/product_create_form.html" - fields = '__all__' - success_message = "%(name)s created." class CustomerListView(LoginRequiredMixin, ListView): diff --git a/src/ptcoffee/config.py b/src/ptcoffee/config.py index 39df7e8..d6a6e0a 100644 --- a/src/ptcoffee/config.py +++ b/src/ptcoffee/config.py @@ -28,6 +28,7 @@ ANYMAIL_CONFIG = { SERVER_EMAIL = os.environ.get('SERVER_EMAIL', '') DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', '') +DEFAULT_CONTACT_EMAIL = os.environ.get('DEFAULT_CONTACT_EMAIL', '') SECURE_HSTS_SECONDS = os.environ.get('SECURE_HSTS_SECONDS', 3600) SECURE_SSL_REDIRECT = os.environ.get('SECURE_SSL_REDIRECT', 'False') == 'True' diff --git a/src/static/scripts/product_form.js b/src/static/scripts/product_form.js new file mode 100644 index 0000000..b740af1 --- /dev/null +++ b/src/static/scripts/product_form.js @@ -0,0 +1,14 @@ +const form = document.querySelector('form') +const purchaseTypeInput = form.querySelector('[name=purchase_type]') +const scheduleInput = form.querySelector('[name=schedule]') + +scheduleInput.parentElement.style.display = 'none' + +purchaseTypeInput.addEventListener('change', event => { + if (event.target.value === 'Subscribe') { + scheduleInput.parentElement.style.display = 'block' + } else if (event.target.value === 'One-time purchase') { + scheduleInput.parentElement.style.display = 'none' + } + +}) diff --git a/src/static/styles/dashboard.css b/src/static/styles/dashboard.css index 29d1e87..1f35734 100644 --- a/src/static/styles/dashboard.css +++ b/src/static/styles/dashboard.css @@ -72,6 +72,7 @@ label { input[type=text], input[type=email], input[type=number], +input[type=date], input[type=password], select[multiple=multiple], textarea { @@ -145,7 +146,7 @@ button:hover { } .action-button--warning { - background-color: var(--red-color); + background-color: var(--red-color) !important; } .action-link { @@ -314,12 +315,33 @@ main article { .object__item { display: grid; - grid-template-columns: repeat(5, 1fr); gap: 1rem; padding: 1rem; border-bottom: 0.05rem solid var(--gray-color); text-decoration: none; align-items: center; + justify-items: start; +} + +.object__item--col3 { + grid-template-columns: repeat(3, 1fr); +} + +.object__item--col5 { + grid-template-columns: repeat(5, 1fr); +} + +.object__item--col4 { + grid-template-columns: repeat(4, 1fr); +} + +.object__item--col8 { + grid-template-columns: repeat(8, 1fr); +} + +.panel__header--flex { + display: flex; + justify-content: space-between; } .panel__item:last-child, @@ -328,11 +350,11 @@ main article { border-radius: 0 0 0.5rem 0.5rem; } -.object__item:hover { +.object__item--link:hover { background-color: var(--bg-alt-color); } -.object__item--header { +.panel__header { font-weight: bold; background-color: var(--bg-alt-color); border-radius: 0.5rem 0.5rem 0 0; @@ -497,3 +519,24 @@ main article { height: 50px; margin-right: 1rem; } + +.gallery { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 3rem; + +} + +.gallery__item { +} + +.gallery__item, +.gallery__item img { + width: 100%; +} + +.gallery__item img { + border: var(--default-border); + object-fit: cover; + aspect-ratio: 1/1; +} diff --git a/src/static/styles/main.css b/src/static/styles/main.css index 9142f15..17e4494 100644 --- a/src/static/styles/main.css +++ b/src/static/styles/main.css @@ -63,9 +63,6 @@ small, .text_small { font-size: 0.833rem; } -label { - display: block; -} @@ -82,6 +79,7 @@ textarea { font: inherit; margin: 0; max-width: 100%; + box-sizing: border-box; } input { @@ -91,6 +89,7 @@ input { label { + display: block; text-align: left; font-weight: 700; } @@ -105,6 +104,7 @@ select { input[type=text], input[type=email], input[type=number], +input[type=date], input[type=password], select[multiple=multiple], textarea { @@ -116,19 +116,27 @@ textarea { outline: 0; } - input:focus, - textarea:focus { - border-color: var(--yellow-color); - } +input:focus, +textarea:focus { + border-color: var(--yellow-color); +} select[multiple=multiple] { height: 125px; } +input[type=radio], input[type=checkbox] { - width: 1em; - vertical-align: text-top; + width: 2rem; + height: 2rem; + vertical-align: middle; +} + +input[type=radio] + label, +input[type=checkbox] + label { + display: inline-block; + margin: 1rem 0; } textarea { @@ -296,6 +304,11 @@ nav { justify-content: flex-end; } +.coupon__form { + max-width: 16rem; + align-self: flex-end; +} + .item__figure img { vertical-align: middle; } @@ -325,7 +338,17 @@ nav { .order__shipping { +} + + +.shipping__details { margin-bottom: 3rem; + max-width: 32rem; +} + +.shipping__details input[type=submit] { + font-size: 1.25rem; + } @@ -334,10 +357,6 @@ nav { text-align: right; } -.order__details { - /*margin: 3rem 0;*/ - -} footer { margin: 4rem 0 0; @@ -386,7 +405,7 @@ footer { background-color: var(--bg-alt-color); } -.object__item--header { +.panel__header { font-weight: bold; background-color: var(--bg-alt-color); border-radius: 0.5rem 0.5rem 0 0; @@ -415,10 +434,11 @@ footer { -._form_1 div { - text-align: left !important; +._form_1 { + margin: 0; } ._form_1 div form { margin: 1rem 0 !important; padding: 0 !important; + } diff --git a/src/storefront/cart.py b/src/storefront/cart.py index c9827e7..2a6ba01 100644 --- a/src/storefront/cart.py +++ b/src/storefront/cart.py @@ -1,14 +1,23 @@ import logging from decimal import Decimal from django.conf import settings -from core.models import Product, OrderLine +from core.models import Product, OrderLine, Coupon from .payments import CreateOrder +from core import ( + DiscountValueType, + VoucherType, + TransactionStatus, + OrderStatus, + ShippingMethodType +) + logger = logging.getLogger(__name__) class Cart: def __init__(self, request): self.session = request.session + self.coupon_code = self.session.get('coupon_code') cart = self.session.get(settings.CART_SESSION_ID) if not cart: cart = self.session[settings.CART_SESSION_ID] = {} @@ -62,14 +71,16 @@ class Cart: def clear(self): del self.session[settings.CART_SESSION_ID] + del self.coupon_code self.session.modified = True def build_order_params(self): return \ { 'items': self, - 'total_price': f'{self.get_total_price()}', + 'total_price': f'{self.get_total_price_after_discount()}', 'item_total': f'{self.get_total_price()}', + 'discount': f'{self.get_discount()}', 'shipping_price': '0', 'tax_total': '0', 'shipping_method': 'US POSTAL SERVICE', @@ -105,16 +116,19 @@ class Cart: 'country_code': 'US' } - # @property - # def coupon(self): - # if self.coupon_id: - # return Coupon.objects.get(id=self.coupon_id) - # return None + @property + def coupon(self): + if self.coupon_code: + return Coupon.objects.get(code=self.coupon_code) + return None - # def get_discount(self): - # if self.coupon: - # return (self.coupon.discount / Decimal('100')) * self.get_total_price() - # return Decimal('0') + def get_discount(self): + 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) + return Decimal('0') - # def get_total_price_after_discount(self): - # return self.get_total_price() - self.get_discount() + def get_total_price_after_discount(self): + return round(self.get_total_price() - self.get_discount(), 2) diff --git a/src/storefront/forms.py b/src/storefront/forms.py index ec4df2f..524c351 100644 --- a/src/storefront/forms.py +++ b/src/storefront/forms.py @@ -5,6 +5,8 @@ from django.core.mail import EmailMessage from core.models import Order from accounts import STATE_CHOICES +from .tasks import contact_form_email + logger = logging.getLogger(__name__) class AddToCartForm(forms.Form): @@ -28,8 +30,27 @@ class AddToCartForm(forms.Form): (PERCOLATOR, 'Percolator'), (OTHER, 'Other (enter below)') ] + + ONE_TIME = 'One-time purchase' + SUBSCRIBE = 'Subscribe' + PURCHASE_TYPE_CHOICES = [ + (ONE_TIME, 'One-time purchase'), + (SUBSCRIBE, 'Subscribe and save 10%'), + ] + + SEVEN_DAYS = 7 + FOURTEEN_DAYS = 14 + THIRTY_DAYS = 30 + SCHEDULE_CHOICES = [ + (SEVEN_DAYS, 'Every 7 days'), + (FOURTEEN_DAYS, 'Every 14 days'), + (THIRTY_DAYS, 'Every 30 days'), + ] + quantity = forms.IntegerField(min_value=1, initial=1) roast = forms.ChoiceField(choices=ROAST_CHOICES) + purchase_type = forms.ChoiceField(choices=PURCHASE_TYPE_CHOICES, initial=ONE_TIME) + schedule = forms.ChoiceField(choices=SCHEDULE_CHOICES) update = forms.BooleanField(required=False, initial=False, widget=forms.HiddenInput) @@ -53,8 +74,43 @@ class OrderCreateForm(forms.ModelForm): class Meta: model = Order fields = ( + 'coupon', 'total_net_amount', ) widgets = { + 'coupon': forms.HiddenInput(), 'total_net_amount': forms.HiddenInput() } + +class CouponApplyForm(forms.Form): + code = forms.CharField(label='Enter coupon') + +class ContactForm(forms.Form): + GOOGLE = 'Google Search' + SHOP = 'The coffee shop' + WOM = 'Word of mouth' + PRODUCT = 'Coffee Bag' + STORE = 'Store' + OTHER = 'Other' + + REFERAL_CHOICES = [ + (GOOGLE, 'Google Search'), + (SHOP, 'Better Living Through Coffee coffee shop'), + (WOM, 'Friend/Relative'), + (PRODUCT, 'Our Coffee Bag'), + (STORE, 'PT Food Coop/other store'), + (OTHER, 'Other (please describe in the Message section below'), + ] + + first_name = forms.CharField() + last_name = forms.CharField() + email_address = forms.EmailField() + referal = forms.ChoiceField( + label='How did you find our website?', + choices=REFERAL_CHOICES + ) + subject = forms.CharField() + message = forms.CharField(widget=forms.Textarea) + + def send_email(self): + contact_form_email.delay(self.cleaned_data) diff --git a/src/storefront/payments.py b/src/storefront/payments.py index 1951d4e..2a7bc5a 100644 --- a/src/storefront/payments.py +++ b/src/storefront/payments.py @@ -112,10 +112,10 @@ class CreateOrder(PayPalClient): "currency_code": "USD", "value": params['tax_total'] }, - # "shipping_discount": { - # "currency_code": "USD", - # "value": "10" - # } + "discount": { + "currency_code": "USD", + "value": params['discount'] + } } }, "items": processed_items, diff --git a/src/storefront/tasks.py b/src/storefront/tasks.py index e02e9db..dadbf7f 100644 --- a/src/storefront/tasks.py +++ b/src/storefront/tasks.py @@ -1,26 +1,22 @@ -# from celery import shared_task -# from celery.utils.log import get_task_logger -# from django.conf import settings -# from django.core.mail import EmailMessage, send_mail +from celery import shared_task +from celery.utils.log import get_task_logger +from django.conf import settings +from django.core.mail import EmailMessage, send_mail -# from templated_email import send_templated_mail +from templated_email import send_templated_mail -# from core.models import Order - -# logger = get_task_logger(__name__) +logger = get_task_logger(__name__) -# CONFIRM_ORDER_TEMPLATE = 'storefront/order_confirmation' -# ORDER_CANCEl_TEMPLATE = 'storefront/order_cancel' -# ORDER_REFUND_TEMPLATE = 'storefront/order_refund' +COTACT_FORM_TEMPLATE = 'storefront/contact_form' -# @shared_task(name='send_order_confirmation_email') -# def send_order_confirmation_email(order): -# send_templated_mail( -# template_name=CONFIRM_ORDER_TEMPLATE, -# from_email=settings.DEFAULT_FROM_EMAIL, -# recipient_list=[order['email']], -# context=order -# ) +@shared_task(name='contact_form_email') +def contact_form_email(formdata): + send_templated_mail( + template_name=COTACT_FORM_TEMPLATE, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[settings.DEFAULT_CONTACT_EMAIL], + context=formdata + ) -# logger.info(f"Order confirmation email sent to {order['email']}") + logger.info(f"Contact form email sent from {formdata['email_address']}") diff --git a/src/storefront/templates/storefront/cart_detail.html b/src/storefront/templates/storefront/cart_detail.html index 5e962d4..8c349a9 100644 --- a/src/storefront/templates/storefront/cart_detail.html +++ b/src/storefront/templates/storefront/cart_detail.html @@ -28,7 +28,22 @@ {% endfor %}
-

Cart total: ${{cart.get_total_price}}

+
+
+ {% csrf_token %} + {{ coupon_apply_form.as_p }} +

+ +

+
+
+

+ Subtotal: ${{cart.get_total_price|floatformat:"2"}}
+ {% if cart.coupon %} + Coupon: {{cart.coupon.discount_value}} {{cart.coupon.get_discount_value_type_display}}
+ {% endif %} + Cart total: ${{cart.get_total_price_after_discount|floatformat:"2"}} +

Continue Shopping or Proceed to Checkout

diff --git a/src/storefront/templates/storefront/checkout_address.html b/src/storefront/templates/storefront/checkout_address.html index d70373e..d5d1f4e 100644 --- a/src/storefront/templates/storefront/checkout_address.html +++ b/src/storefront/templates/storefront/checkout_address.html @@ -4,8 +4,8 @@ {% block content %}

Checkout

-
-
+
+

Shipping Address

{% csrf_token %} diff --git a/src/storefront/templates/storefront/contact_form.html b/src/storefront/templates/storefront/contact_form.html new file mode 100644 index 0000000..8c892c5 --- /dev/null +++ b/src/storefront/templates/storefront/contact_form.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} + +{% block content %} +
+

Contact PT Coffee

+
+ + {% csrf_token %} + {{form.as_p}} + + +
+
+{% endblock %} diff --git a/src/storefront/templates/storefront/customer_detail.html b/src/storefront/templates/storefront/customer_detail.html index 289a626..62f6d1f 100644 --- a/src/storefront/templates/storefront/customer_detail.html +++ b/src/storefront/templates/storefront/customer_detail.html @@ -8,7 +8,7 @@ Edit profile
-
+

Info

@@ -53,19 +53,15 @@
{% with order_list=customer.orders.all %}
-
+
Order # Date - Status Total
{% for order in order_list %} #{{order.pk}} {{order.created_at|date:"D, M j Y"}} - -
- {{order.get_status_display}}
${{order.total_net_amount}}
{% empty %} diff --git a/src/storefront/templates/storefront/order_form.html b/src/storefront/templates/storefront/order_form.html index d648d57..a29d6ec 100644 --- a/src/storefront/templates/storefront/order_form.html +++ b/src/storefront/templates/storefront/order_form.html @@ -46,7 +46,13 @@ {{form.as_p}} {# #} -

Total: ${{cart.get_total_price}}

+

+ Subtotal: ${{cart.get_total_price|floatformat:"2"}}
+ {% if cart.coupon %} + Coupon: {{cart.coupon.discount_value}} {{cart.coupon.get_discount_value_type_display}}
+ {% endif %} + Cart total: ${{cart.get_total_price_after_discount|floatformat:"2"}} +

diff --git a/src/storefront/templates/storefront/product_detail.html b/src/storefront/templates/storefront/product_detail.html index 0b84bde..f2287fb 100644 --- a/src/storefront/templates/storefront/product_detail.html +++ b/src/storefront/templates/storefront/product_detail.html @@ -1,7 +1,11 @@ {% extends "base.html" %} +{% load static %} + +{% block head %} + +{% endblock %} {% block content %} -
{{product.productphoto_set.first.image}} @@ -20,5 +24,4 @@
- {% endblock %} diff --git a/src/storefront/templates/storefront/product_list.html b/src/storefront/templates/storefront/product_list.html index 69a11dc..5229c94 100644 --- a/src/storefront/templates/storefront/product_list.html +++ b/src/storefront/templates/storefront/product_list.html @@ -20,6 +20,3 @@ {% endblock %} -{% block footer %} -
-{% endblock footer %} diff --git a/src/storefront/urls.py b/src/storefront/urls.py index 365cacc..1cfc1a1 100644 --- a/src/storefront/urls.py +++ b/src/storefront/urls.py @@ -3,6 +3,7 @@ from . import views urlpatterns = [ path('about/', views.AboutView.as_view(), name='about'), + path('contact/', views.ContactFormView.as_view(), name='contact'), path('', views.ProductListView.as_view(), name='product-list'), path('products//', include([ @@ -13,7 +14,10 @@ urlpatterns = [ path('cart//add/', views.CartAddProductView.as_view(), name='cart-add'), path('cart//remove/', views.cart_remove_product_view, name='cart-remove'), + path('coupon/apply/', views.CouponApplyView.as_view(), name='coupon-apply'), + path('paypal/order//capture/', 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(), name='checkout-address'), path('checkout/', views.OrderCreateView.as_view(), name='order-create'), diff --git a/src/storefront/views.py b/src/storefront/views.py index de47e30..0eed505 100644 --- a/src/storefront/views.py +++ b/src/storefront/views.py @@ -1,9 +1,12 @@ import logging import requests +import json from django.conf import settings +from django.utils import timezone from django.shortcuts import render, reverse, redirect, get_object_or_404 from django.urls import reverse_lazy from django.core.mail import EmailMessage +from django.core.exceptions import ObjectDoesNotExist from django.http import JsonResponse from django.views.generic.base import RedirectView, TemplateView from django.views.generic.edit import FormView, CreateView, UpdateView, DeleteView, FormMixin @@ -11,16 +14,19 @@ from django.views.generic.detail import DetailView, SingleObjectMixin from django.views.generic.list import ListView from django.contrib.auth.decorators import login_required from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin +from django.contrib.messages.views import SuccessMessageMixin +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_POST from paypalcheckoutsdk.orders import OrdersCreateRequest, OrdersCaptureRequest from paypalcheckoutsdk.core import PayPalHttpClient, SandboxEnvironment from accounts.models import User, Address from accounts.utils import get_or_create_customer -from core.models import Product, Order, Transaction, OrderLine +from core.models import Product, Order, Transaction, OrderLine, Coupon from core.forms import ShippingMethodForm -from .forms import AddToCartForm, OrderCreateForm, AddressForm +from .forms import AddToCartForm, OrderCreateForm, AddressForm, CouponApplyForm, ContactForm from .cart import Cart from .payments import CaptureOrder @@ -41,6 +47,7 @@ class CartView(TemplateView): } ) context['cart'] = cart + context['coupon_apply_form'] = CouponApplyForm() return context class CartAddProductView(SingleObjectMixin, FormView): @@ -75,10 +82,32 @@ def cart_remove_product_view(request, pk): return redirect('storefront:cart-detail') +class CouponApplyView(FormView): + template_name = 'contact.html' + form_class = CouponApplyForm + success_url = reverse_lazy('storefront:cart-detail') + + def form_valid(self, form): + today = timezone.localtime(timezone.now()).date() + code = form.cleaned_data['code'] + try: + coupon = Coupon.objects.get( + code__iexact=code, + valid_from__date__lte=today, + valid_to__date__gte=today + ) + if coupon.is_valid: + self.request.session['coupon_code'] = coupon.code + except ObjectDoesNotExist: + self.request.session['coupon_code'] = None + return super().form_valid(form) + + class ProductListView(FormMixin, ListView): model = Product template_name = 'storefront/product_list.html' form_class = AddToCartForm + ordering = 'sorting' queryset = Product.objects.filter( visible_in_listings=True @@ -127,6 +156,7 @@ class OrderCreateView(CreateView): def get_initial(self): cart = Cart(self.request) initial = { + 'coupon': cart.coupon, 'total_net_amount': cart.get_total_price() } @@ -182,6 +212,13 @@ def paypal_order_transaction_capture(request, transaction_id): else: 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' @@ -211,3 +248,12 @@ class CustomerUpdateView(UpdateView): class AboutView(TemplateView): template_name = 'storefront/about.html' + +class ContactFormView(FormView, SuccessMessageMixin): + template_name = 'storefront/contact_form.html' + form_class = ContactForm + success_url = reverse_lazy('storefront:product-list') + + def form_valid(self, form): + form.send_email() + return super().form_valid(form) diff --git a/src/templates/account/email.html b/src/templates/account/email.html index d54b282..a2bc362 100644 --- a/src/templates/account/email.html +++ b/src/templates/account/email.html @@ -16,10 +16,10 @@ {% csrf_token %}
{% for emailaddress in user.emailaddress_set.all %} -
+
+