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 %}