From 194fb8d655203fdfdb6e208e5d24f36893ee6148 Mon Sep 17 00:00:00 2001 From: Nathan Chapman Date: Wed, 23 Mar 2022 13:27:20 -0600 Subject: [PATCH 1/7] Add shipping tracking functionality --- src/core/apps.py | 7 +- ...ethod_price_alter_order_status_and_more.py | 36 +++++++++ ...er_created_at_trackingnumber_updated_at.py | 25 +++++++ src/core/models.py | 29 +++++++ src/core/signals.py | 21 +++++- src/core/tasks.py | 12 +++ src/dashboard/forms.py | 15 +++- src/dashboard/templates/dashboard/config.html | 36 +++++++++ .../templates/dashboard/order_detail.html | 39 ++++++---- .../dashboard/order_tracking_form.html | 37 +++++++++ .../dashboard/product_confirm_delete.html | 22 ++++++ .../templates/dashboard/product_detail.html | 7 +- .../dashboard/shipmeth_create_form.html | 16 ++++ .../templates/dashboard/shipmeth_detail.html | 21 ++++++ src/dashboard/urls.py | 11 ++- src/dashboard/views.py | 75 +++++++++++++++---- src/static/styles/dashboard.css | 42 ++++++++++- src/templates/dashboard.html | 20 ++++- .../storefront/order_shipped.email | 18 +++++ 19 files changed, 450 insertions(+), 39 deletions(-) create mode 100644 src/core/migrations/0002_shippingmethod_price_alter_order_status_and_more.py create mode 100644 src/core/migrations/0003_trackingnumber_created_at_trackingnumber_updated_at.py create mode 100644 src/dashboard/templates/dashboard/config.html create mode 100644 src/dashboard/templates/dashboard/order_tracking_form.html create mode 100644 src/dashboard/templates/dashboard/product_confirm_delete.html create mode 100644 src/dashboard/templates/dashboard/shipmeth_create_form.html create mode 100644 src/dashboard/templates/dashboard/shipmeth_detail.html create mode 100644 src/templates/templated_email/storefront/order_shipped.email diff --git a/src/core/apps.py b/src/core/apps.py index 8e2dd6c..cf4aa6c 100644 --- a/src/core/apps.py +++ b/src/core/apps.py @@ -6,4 +6,9 @@ class CoreConfig(AppConfig): name = 'core' def ready(self): - from .signals import order_created, transaction_created, order_line_post_save + from .signals import ( + order_created, + transaction_created, + order_line_post_save, + trackingnumber_postsave + ) diff --git a/src/core/migrations/0002_shippingmethod_price_alter_order_status_and_more.py b/src/core/migrations/0002_shippingmethod_price_alter_order_status_and_more.py new file mode 100644 index 0000000..96dd6bd --- /dev/null +++ b/src/core/migrations/0002_shippingmethod_price_alter_order_status_and_more.py @@ -0,0 +1,36 @@ +# Generated by Django 4.0.2 on 2022-03-23 16:25 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='shippingmethod', + name='price', + field=models.DecimalField(decimal_places=2, default=0, max_digits=12), + ), + migrations.AlterField( + model_name='order', + name='status', + field=models.CharField(choices=[('draft', 'Draft'), ('unfulfilled', 'Unfulfilled'), ('partially_fulfilled', 'Partially fulfilled'), ('partially_returned', 'Partially returned'), ('returned', 'Returned'), ('fulfilled', 'Fulfilled'), ('canceled', 'Canceled')], default='unfulfilled', max_length=32), + ), + migrations.CreateModel( + name='TrackingNumber', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('tracking_id', models.CharField(max_length=256)), + ('order', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='tracking_numbers', to='core.order')), + ], + options={ + 'verbose_name': 'Tracking Number', + 'verbose_name_plural': 'Tracking Numbers', + }, + ), + ] diff --git a/src/core/migrations/0003_trackingnumber_created_at_trackingnumber_updated_at.py b/src/core/migrations/0003_trackingnumber_created_at_trackingnumber_updated_at.py new file mode 100644 index 0000000..170c781 --- /dev/null +++ b/src/core/migrations/0003_trackingnumber_created_at_trackingnumber_updated_at.py @@ -0,0 +1,25 @@ +# Generated by Django 4.0.2 on 2022-03-23 17:04 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0002_shippingmethod_price_alter_order_status_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='trackingnumber', + name='created_at', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='trackingnumber', + name='updated_at', + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/src/core/models.py b/src/core/models.py index 93441f8..54224b0 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -116,6 +116,14 @@ class Coupon(models.Model): 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, + ) + + def get_absolute_url(self): + return reverse('dashboard:shipmeth-detail', kwargs={'pk': self.pk}) class OrderManager(models.Manager): @@ -251,3 +259,24 @@ class OrderLine(models.Model): @property def quantity_unfulfilled(self): return self.quantity - self.quantity_fulfilled + + +class TrackingNumber(models.Model): + order = models.ForeignKey( + Order, + related_name="tracking_numbers", + editable=False, + on_delete=models.CASCADE + ) + tracking_id = models.CharField(max_length=256) + + created_at = models.DateTimeField(auto_now_add=True, editable=False) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = 'Tracking Number' + verbose_name_plural = 'Tracking Numbers' + + def __str__(self): + return self.tracking_id + diff --git a/src/core/signals.py b/src/core/signals.py index 85fcdba..67e1d60 100644 --- a/src/core/signals.py +++ b/src/core/signals.py @@ -6,8 +6,11 @@ from django.dispatch import receiver from django.db import models from . import OrderStatus, TransactionStatus -from .models import Order, OrderLine, Transaction -from .tasks import send_order_confirmation_email +from .models import Order, OrderLine, Transaction, TrackingNumber +from .tasks import ( + send_order_confirmation_email, + send_order_shipped_email +) logger = logging.getLogger(__name__) @@ -34,6 +37,20 @@ def transaction_created(sender, instance, created, **kwargs): instance.confirmation_email_sent = True instance.save() +@receiver(post_save, sender=TrackingNumber, dispatch_uid="trackingnumber_postsave") +def trackingnumber_postsave(sender, instance, created, **kwargs): + if created: + logger.info("TrackingNumber was created") + + data = { + 'order_id': instance.order.pk, + 'email': instance.order.customer.email, + 'full_name': instance.order.customer.get_full_name(), + 'tracking_id': instance.tracking_id + } + send_order_shipped_email.delay(data) + + def get_order_status(total_quantity_fulfilled, total_quantity_ordered): if total_quantity_fulfilled >= total_quantity_ordered: return OrderStatus.FULFILLED diff --git a/src/core/tasks.py b/src/core/tasks.py index 0211068..27d0468 100644 --- a/src/core/tasks.py +++ b/src/core/tasks.py @@ -11,6 +11,7 @@ logger = get_task_logger(__name__) CONFIRM_ORDER_TEMPLATE = 'storefront/order_confirmation' +SHIP_ORDER_TEMPLATE = 'storefront/order_shipped' ORDER_CANCEl_TEMPLATE = 'storefront/order_cancel' ORDER_REFUND_TEMPLATE = 'storefront/order_refund' @@ -24,3 +25,14 @@ def send_order_confirmation_email(order): ) logger.info(f"Order confirmation email sent to {order['email']}") + +@shared_task(name='send_order_shipped_email') +def send_order_shipped_email(data): + send_templated_mail( + template_name=SHIP_ORDER_TEMPLATE, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[data['email']], + context=data + ) + + logger.info(f"Order shipped email sent to {data['email']}") diff --git a/src/dashboard/forms.py b/src/dashboard/forms.py index 13a8394..272ddfa 100644 --- a/src/dashboard/forms.py +++ b/src/dashboard/forms.py @@ -1,7 +1,7 @@ import logging from django import forms -from core.models import Order, OrderLine, ShippingMethod +from core.models import Order, OrderLine, ShippingMethod, TrackingNumber logger = logging.getLogger(__name__) @@ -25,3 +25,16 @@ OrderLineFormset = forms.inlineformset_factory( Order, OrderLine, form=OrderLineFulfillForm, extra=0, can_delete=False ) + + +class OrderTrackingForm(forms.ModelForm): + # send_shipment_details_to_customer = forms.BooleanField(initial=True) + + class Meta: + model = TrackingNumber + fields = ('tracking_id',) + +OrderTrackingFormset = forms.inlineformset_factory( + Order, TrackingNumber, form=OrderTrackingForm, + extra=1, can_delete=False +) diff --git a/src/dashboard/templates/dashboard/config.html b/src/dashboard/templates/dashboard/config.html new file mode 100644 index 0000000..1dcf8fb --- /dev/null +++ b/src/dashboard/templates/dashboard/config.html @@ -0,0 +1,36 @@ +{% extends "dashboard.html" %} +{% load static %} +{% load tz %} + +{% block content %} +
+
+

Site configuration

+
+ +
+
+

Shipping methods

+ + New method +
+
+ {% for method in shipping_method_list %} +

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

+ {% empty %} +

No shipping methods yet.

+ {% endfor %} +
+
+ +
+
+

Staff

+ + New staff +
+
+
+
+
+{% endblock %} diff --git a/src/dashboard/templates/dashboard/order_detail.html b/src/dashboard/templates/dashboard/order_detail.html index 02aaf88..6108af5 100644 --- a/src/dashboard/templates/dashboard/order_detail.html +++ b/src/dashboard/templates/dashboard/order_detail.html @@ -4,7 +4,7 @@ {% block content %}
-

Order #{{order.pk}}

+

Order #{{order.pk}}

-
- Product - SKU - Quantity - Price - Total -
+
+ Product + SKU + Quantity + Price + Total +
{% for item in order.lines.all %}
{% with product=item.product %} @@ -40,18 +40,29 @@ {% empty %}

No items in order yet.

{% endfor %} - +

Shipping

+ Ship order →
- + {% for number in order.tracking_numbers.all %} +
+

+ Shipment
+ Date: {{number.created_at|date:"SHORT_DATE_FORMAT" }}
+ Tracking number: {{number.tracking_id}} +

+
+ {% empty %} +
+

No tracking information.

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

Fulfill Order #{{order.pk}}

+
+
+ {% csrf_token %} + {{ form.management_form }} + +
+ {% for dict in form.errors %} + {% for error in dict.values %} +
+ {{ error }} +
+ {% endfor %} + {% endfor %} +
+ Product + SKU + Quantity to fulfill + Grind +
+ {% for formitem in form %} +
+ {{formitem}} +
+ {% endfor %} +
+ cancel +
+
+
+
+
+{% endblock content %} diff --git a/src/dashboard/templates/dashboard/product_confirm_delete.html b/src/dashboard/templates/dashboard/product_confirm_delete.html new file mode 100644 index 0000000..8f6d1a1 --- /dev/null +++ b/src/dashboard/templates/dashboard/product_confirm_delete.html @@ -0,0 +1,22 @@ +{% extends "dashboard.html" %} +{% load static %} + +{% block content %} +
+
+

Product

+
+
+
+ {{product.productphoto_set.first.image}} +
+
{% csrf_token %} +

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

+ {{ form.as_p }} +

+ or cancel +

+
+
+
+{% endblock content %} diff --git a/src/dashboard/templates/dashboard/product_detail.html b/src/dashboard/templates/dashboard/product_detail.html index 50aebfa..73f320a 100644 --- a/src/dashboard/templates/dashboard/product_detail.html +++ b/src/dashboard/templates/dashboard/product_detail.html @@ -5,7 +5,10 @@

Product

- Edit +
+ Delete + Edit +
@@ -17,7 +20,7 @@

${{product.price}}

{{product.weight.oz}} oz

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

-

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

+

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

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

Create shipmeth

+
+
+ {% 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 new file mode 100644 index 0000000..dad3e49 --- /dev/null +++ b/src/dashboard/templates/dashboard/shipmeth_detail.html @@ -0,0 +1,21 @@ +{% 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/urls.py b/src/dashboard/urls.py index 25bd9f6..a86ab55 100644 --- a/src/dashboard/urls.py +++ b/src/dashboard/urls.py @@ -3,6 +3,12 @@ from . import views urlpatterns = [ path('', views.DashboardHomeView.as_view(), name='home'), + path('config/', views.DashboardConfigView.as_view(), name='config'), + + path('shipping-methods/new/', views.ShippingMethodCreateView.as_view(), name='shipmeth-create'), + path('shipping-methods//', include([ + path('', views.ShippingMethodDetailView.as_view(), name='shipmeth-detail'), + ])), path('orders/', views.OrderListView.as_view(), name='order-list'), path('orders//', include([ @@ -10,14 +16,15 @@ urlpatterns = [ # path('update/', views.OrderUpdateView.as_view(), name='product-update'), # path('delete/', views.OrderDeleteView.as_view(), name='product-delete'), path('fulfill/', views.OrderFulfillView.as_view(), name='order-fulfill'), + path('ship/', views.OrderTrackingView.as_view(), name='order-ship'), ])), path('products/', views.ProductListView.as_view(), name='product-list'), path('products/new/', views.ProductCreateView.as_view(), name='product-create'), - path('/', include([ + path('products//', include([ 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('delete/', views.ProductDeleteView.as_view(), name='product-delete'), ])), path('customers/', views.CustomerListView.as_view(), name='customer-list'), diff --git a/src/dashboard/views.py b/src/dashboard/views.py index 2549d27..19599be 100644 --- a/src/dashboard/views.py +++ b/src/dashboard/views.py @@ -12,6 +12,8 @@ from django.views.generic.list import ListView from django.contrib.auth.decorators import login_required from django.contrib.auth.mixins import LoginRequiredMixin from django.forms import inlineformset_factory +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 @@ -21,14 +23,14 @@ 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 +from core.models import Product, Order, OrderLine, ShippingMethod, Transaction, TrackingNumber from core import DiscountValueType, VoucherType, OrderStatus, ShippingMethodType -from .forms import OrderLineFulfillForm, OrderLineFormset +from .forms import OrderLineFulfillForm, OrderLineFormset, OrderTrackingFormset logger = logging.getLogger(__name__) -class DashboardHomeView(TemplateView): +class DashboardHomeView(LoginRequiredMixin, TemplateView): template_name = 'dashboard/dashboard_detail.html' def get_context_data(self, **kwargs): @@ -45,13 +47,35 @@ class DashboardHomeView(TemplateView): ).aggregate(total=Sum('total_net_amount'))['total'] return context -class OrderListView(ListView): +class DashboardConfigView(TemplateView): + template_name = 'dashboard/config.html' + + 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() + + return context + + +class ShippingMethodCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): + model = ShippingMethod + template_name = 'dashboard/shipmeth_create_form.html' + fields = '__all__' + success_message = '%(name)s created.' + +class ShippingMethodDetailView(LoginRequiredMixin, DetailView): + model = ShippingMethod + template_name = 'dashboard/shipmeth_detail.html' + + +class OrderListView(LoginRequiredMixin, ListView): model = Order template_name = 'dashboard/order_list.html' def get_queryset(self): query = self.request.GET.get('status') - order = self.request.GET.get('order') if query: object_list = Order.objects.filter( status=query @@ -70,7 +94,7 @@ class OrderListView(ListView): return object_list -class OrderDetailView(DetailView): +class OrderDetailView(LoginRequiredMixin, DetailView): model = Order template_name = 'dashboard/order_detail.html' @@ -93,10 +117,24 @@ class OrderDetailView(DetailView): return context -class OrderFulfillView(UpdateView): +class OrderFulfillView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): model = Order template_name = "dashboard/order_fulfill.html" form_class = OrderLineFormset + success_message = "Order saved." + + def form_valid(self, form): + form.save() + return HttpResponseRedirect(self.get_success_url()) + + def get_success_url(self): + return reverse('dashboard:order-detail', kwargs={'pk': self.object.pk}) + +class OrderTrackingView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): + model = Order + template_name = "dashboard/order_tracking_form.html" + form_class = OrderTrackingFormset + success_message = "Order saved." def form_valid(self, form): form.save() @@ -106,7 +144,7 @@ class OrderFulfillView(UpdateView): return reverse('dashboard:order-detail', kwargs={'pk': self.object.pk}) -class ProductListView(ListView): +class ProductListView(LoginRequiredMixin, ListView): model = Product template_name = 'dashboard/product_list.html' @@ -118,22 +156,30 @@ class ProductListView(ListView): # ) # return object_list -class ProductDetailView(DetailView): +class ProductDetailView(LoginRequiredMixin, DetailView): model = Product template_name = 'dashboard/product_detail.html' -class ProductUpdateView(UpdateView): +class ProductUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): model = Product template_name = 'dashboard/product_update_form.html' fields = '__all__' + success_message = "%(name)s saved." -class ProductCreateView(CreateView): +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." + +class ProductCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): model = Product template_name = "dashboard/product_create_form.html" fields = '__all__' + success_message = "%(name)s created." -class CustomerListView(ListView): +class CustomerListView(LoginRequiredMixin, ListView): model = User template_name = 'dashboard/customer_list.html' @@ -150,15 +196,16 @@ class CustomerListView(ListView): return object_list -class CustomerDetailView(DetailView): +class CustomerDetailView(LoginRequiredMixin, DetailView): model = User template_name = 'dashboard/customer_detail.html' context_object_name = 'customer' -class CustomerUpdateView(UpdateView): +class CustomerUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): model = User template_name = 'dashboard/customer_form.html' context_object_name = 'customer' + success_message = '%(name)s saved.' fields = ( 'first_name', 'last_name', diff --git a/src/static/styles/dashboard.css b/src/static/styles/dashboard.css index 2942e86..29d1e87 100644 --- a/src/static/styles/dashboard.css +++ b/src/static/styles/dashboard.css @@ -9,6 +9,7 @@ --red-color: #ff4d44; --default-border: 2px solid var(--gray-color); + --default-shadow: 0 1rem 3rem var(--gray-color); } html { @@ -261,6 +262,41 @@ main article { } +.site__messages { + text-align: left; + white-space: normal; + background-color: var(--fg-color); + border-radius: 0.5rem; + box-shadow: var(--default-shadow); + + margin: 1rem; + padding: 0.5rem 1rem; + + position: fixed; + + left: auto; + right: 0; + bottom: 0; + top: auto; + z-index: 990; +} + +.messages__message.debug { + color: white; +} +.messages__message.info { + color: white; +} +.messages__message.success { + color: var(--green-color); +} +.messages__message.warning { + color: var(--yellow-color); +} +.messages__message.error { + color: var(--red-color); +} + .object__header { display: flex; @@ -328,6 +364,10 @@ main article { align-items: center; } +.object__menu > a:not(:last-child) { + margin-right: 1rem; +} + .order__fulfill { grid-column: 8; } @@ -356,7 +396,7 @@ main article { position: absolute; right: 1rem; border-radius: 0.5rem; - box-shadow: 0 0 3rem var(--gray-color); + box-shadow: var(--default-shadow); } .dropdown__child a { diff --git a/src/templates/dashboard.html b/src/templates/dashboard.html index 5cbc9af..a9284da 100644 --- a/src/templates/dashboard.html +++ b/src/templates/dashboard.html @@ -45,9 +45,9 @@ Coupons - + - Staff + Config
@@ -67,8 +67,24 @@ {% endblock content %}
+{% if messages %} +
+{% for message in messages %} + {{ message }} +{% endfor %} +
+{% endif %}
+ + diff --git a/src/templates/templated_email/storefront/order_shipped.email b/src/templates/templated_email/storefront/order_shipped.email new file mode 100644 index 0000000..9537410 --- /dev/null +++ b/src/templates/templated_email/storefront/order_shipped.email @@ -0,0 +1,18 @@ +{% block subject %}Your PT Coffee order #{{order_id}} has shipped{% endblock %} +{% block plain %} + Great news! Your recent order #{{order_id}} has shipped + + {{tracking_id}} + + Thanks, + Port Townsend Coffee +{% endblock %} + +{% block html %} +

Great news! Your recent order #{{order_id}} has shipped

+ +

{{tracking_id}}

+ +

Thanks,
+ Port Townsend Coffee

+{% endblock %} From 775df2501a7b6dbe320eb0fb455232ba5fc1b55f Mon Sep 17 00:00:00 2001 From: Nathan Chapman Date: Fri, 1 Apr 2022 11:11:50 -0600 Subject: [PATCH 2/7] 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 %} -
+
+
{% with order_list=customer.orders.all %} +
+

Your orders

+
Order # diff --git a/src/storefront/templates/storefront/order_form.html b/src/storefront/templates/storefront/order_form.html index 0fc9f7a..72b37a9 100644 --- a/src/storefront/templates/storefront/order_form.html +++ b/src/storefront/templates/storefront/order_form.html @@ -34,9 +34,10 @@ {{product.productphoto_set.first.image}}
-

{{product.name}}
${{item.price}}

+

{{product.name}}

+

${{item.price}}

Quantity: {{item.quantity}}

-

Grind options: {{item.roast}}

+

Grind options: {{item.grind}}

{% endwith %}
diff --git a/src/storefront/templates/storefront/product_detail.html b/src/storefront/templates/storefront/product_detail.html index 724e8e3..7a9e22f 100644 --- a/src/storefront/templates/storefront/product_detail.html +++ b/src/storefront/templates/storefront/product_detail.html @@ -13,21 +13,16 @@

{{product.name}}

{{product.description}}

+

${{product.price}}

{{product.weight.oz}} oz

{% csrf_token %} -

One-time purchase

{{ form.as_p }}

-


-
-

Subscribe and save 10%

-
- Subscriptions
{% endblock %} diff --git a/src/storefront/views.py b/src/storefront/views.py index bff7fb3..6e93ba0 100644 --- a/src/storefront/views.py +++ b/src/storefront/views.py @@ -62,7 +62,7 @@ class CartAddProductView(SingleObjectMixin, FormView): if form.is_valid(): cart.add( product=self.get_object(), - roast=form.cleaned_data['roast'], + grind=form.cleaned_data['grind'], quantity=form.cleaned_data['quantity'] ) return self.form_valid(form) diff --git a/src/templates/base.html b/src/templates/base.html index 7abba39..70d3d32 100644 --- a/src/templates/base.html +++ b/src/templates/base.html @@ -25,34 +25,37 @@
{% block content %} From f803491fa304d57cdcc4769ab6b0dbb6e2ced342 Mon Sep 17 00:00:00 2001 From: Nathan Chapman Date: Tue, 5 Apr 2022 11:23:35 -0600 Subject: [PATCH 5/7] Add modal menu for newsletter --- src/static/scripts/cookie.js | 4 +-- src/static/scripts/index.js | 37 ++++++++++++++++++++++++ src/static/styles/main.css | 54 ++++++++++++++++++++++++++++++++++++ src/templates/base.html | 15 +++++++++- 4 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 src/static/scripts/index.js diff --git a/src/static/scripts/cookie.js b/src/static/scripts/cookie.js index fcc94bc..8e2378d 100644 --- a/src/static/scripts/cookie.js +++ b/src/static/scripts/cookie.js @@ -16,9 +16,9 @@ export function getCookie(name) { const twentyYears = 20 * 365 * 24 * 60 * 60 * 1000 -export function setCookie(name, value) { +export function setCookie(name, value, expiration=twentyYears) { const body = [ name, value ].map(encodeURIComponent).join("=") - const expires = new Date(Date.now() + twentyYears).toUTCString() + const expires = new Date(Date.now() + expiration).toUTCString() const cookie = `${body}; domain=; path=/; SameSite=Lax; expires=${expires}` document.cookie = cookie } diff --git a/src/static/scripts/index.js b/src/static/scripts/index.js new file mode 100644 index 0000000..e7820e6 --- /dev/null +++ b/src/static/scripts/index.js @@ -0,0 +1,37 @@ +import { getCookie, setCookie } from "./cookie.js" + +// Get the modal +const modal = document.querySelector(".modal-menu"); + +// Get the element that closes the modal +const closeBtn = document.querySelector(".close-modal"); + +const oneDay = 1 * 24 * 60 * 60 * 1000 + +// When the user clicks on (x), close the modal +closeBtn.addEventListener("click", event => { + modal.style.display = "none"; + setCookie('newsletter-modal', 'true', oneDay) +}) + +const scrollFunction = () => { + let modalDismissed = getCookie('newsletter-modal') + console.log(modalDismissed) + if (modalDismissed != 'true') { + if (document.body.scrollTop > 600 || document.documentElement.scrollTop > 600) { + modal.style.display = "block"; + } + } +} + +window.onscroll = () => { + scrollFunction(); +}; + +// When the user clicks anywhere outside of the modal, close it +window.addEventListener("click", event => { + if (event.target == modal) { + modal.style.display = "none"; + } + setCookie('newsletter-modal', 'true', oneDay) +}); diff --git a/src/static/styles/main.css b/src/static/styles/main.css index 3dffd95..ab1aea1 100644 --- a/src/static/styles/main.css +++ b/src/static/styles/main.css @@ -202,6 +202,60 @@ img { max-width: 100%; } + + + + +/* MODAL MENU */ +.modal-menu { + display: none; + position: fixed; /* Stay in place */ + z-index: 1; /* Sit on top */ + left: 0; + top: 0; + width: 100%; /* Full width */ + height: 100%; /* Full height */ + overflow: auto; /* Enable scroll if needed */ + background-color: rgb(0,0,0); /* Fallback color */ + background-color: rgba(0,0,0,0.4); /* Black w/ opacity */ +} + +/* Modal Content/Box */ +.modal-menu__content { + background-color: #fefefe; + margin: 25vh auto; + padding: 20px; + border: 1px solid #888; + max-width: 40rem; +} + +.modal-menu__form { + +} + +.modal-menu__header { + display: flex; + justify-content: space-between; + align-items: baseline; +} + +/* The Close Button */ +.close-modal { + color: #aaa; + font-size: 2rem; + line-height: 0; + font-weight: bold; +} + +.close-modal:hover, +.close-modal:focus { + color: black; + text-decoration: none; + cursor: pointer; +} + + + .fair_trade--small { max-width: 4rem; vertical-align: middle; diff --git a/src/templates/base.html b/src/templates/base.html index 70d3d32..74d7dab 100644 --- a/src/templates/base.html +++ b/src/templates/base.html @@ -18,11 +18,24 @@ {% endcompress %} + {% block head %} {% endblock %} + +