Add shipping tracking functionality

This commit is contained in:
Nathan Chapman 2022-03-23 13:27:20 -06:00
parent 89e0f32d23
commit 194fb8d655
19 changed files with 450 additions and 39 deletions

View File

@ -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
)

View File

@ -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',
},
),
]

View File

@ -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),
),
]

View File

@ -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

View File

@ -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

View File

@ -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']}")

View File

@ -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
)

View File

@ -0,0 +1,36 @@
{% extends "dashboard.html" %}
{% load static %}
{% load tz %}
{% block content %}
<article>
<header class="object__header">
<h1><img src="{% static 'images/gear.png' %}" alt=""> Site configuration</h1>
</header>
<section class="object__panel">
<div class="object__item object__item--header">
<h4>Shipping methods</h4>
<a href="{% url 'dashboard:shipmeth-create' %}" class="action-button order__fulfill">+ New method</a>
</div>
<div class="panel__item">
{% for method in shipping_method_list %}
<p>
<a href="{% url 'dashboard:shipmeth-detail' method.pk %}">{{method.name}} | {{method.type}} | {{method.price}}</a>
</p>
{% empty %}
<p>No shipping methods yet.</p>
{% endfor %}
</div>
</section>
<section class="object__panel">
<div class="object__item object__item--header">
<h4>Staff</h4>
<a href="" class="action-button order__fulfill">+ New staff</a>
</div>
<div class="panel__item">
</div>
</section>
</article>
{% endblock %}

View File

@ -4,7 +4,7 @@
{% block content %}
<article>
<header class="object__header">
<h1><img src="{% static "images/box.png" %}" alt=""> Order #{{order.pk}}</h1>
<h1><img src="{% static 'images/box.png' %}" alt=""> Order #{{order.pk}}</h1>
<div class="object__menu">
<div class="dropdown">
<span class="dropdown__menu">Options &darr;</span>
@ -17,13 +17,13 @@
</div>
</header>
<section class="object__list">
<div class="object__item object__item--header">
<span>Product</span>
<span>SKU</span>
<span>Quantity</span>
<span>Price</span>
<span>Total</span>
</div>
<div class="object__item object__item--header">
<span>Product</span>
<span>SKU</span>
<span>Quantity</span>
<span>Price</span>
<span>Total</span>
</div>
{% for item in order.lines.all %}
<div class="object__item">
{% with product=item.product %}
@ -40,18 +40,29 @@
{% empty %}
<p>No items in order yet.</p>
{% endfor %}
<div class="object__item">
<a href="{% url 'dashboard:order-fulfill' order.pk %}" class="action-button order__fulfill">Fulfill &rarr;</a>
</div>
<div class="object__item">
<a href="{% url 'dashboard:order-fulfill' order.pk %}" class="action-button order__fulfill">Fulfill &rarr;</a>
</div>
</section>
<section class="object__panel">
<div class="object__item object__item--header">
<h4>Shipping</h4>
<a href="{% url 'dashboard:order-ship' order.pk %}" class="action-button order__fulfill">Ship order &rarr;</a>
</div>
<div class="panel__item">
<a href="{% url 'dashboard:order-fulfill' order.pk %}" class="action-button order__fulfill">Ship order &rarr;</a>
</div>
{% for number in order.tracking_numbers.all %}
<div class="panel__item">
<p>
<strong>Shipment</strong><br>
Date: {{number.created_at|date:"SHORT_DATE_FORMAT" }}<br>
Tracking number: {{number.tracking_id}}
</p>
</div>
{% empty %}
<div class="panel__item">
<p>No tracking information.</p>
</div>
{% endfor %}
</section>
<section class="object__panel">

View File

@ -0,0 +1,37 @@
{% extends "dashboard.html" %}
{% block content %}
<article>
<h1>Fulfill Order #{{order.pk}}</h1>
<section>
<form method="POST" action="">
{% csrf_token %}
{{ form.management_form }}
<section class="object__list">
{% for dict in form.errors %}
{% for error in dict.values %}
<div class="object__item">
{{ error }}
</div>
{% endfor %}
{% endfor %}
<div class="object__item object__item--header">
<span>Product</span>
<span>SKU</span>
<span>Quantity to fulfill</span>
<span>Grind</span>
</div>
{% for formitem in form %}
<div class="object__item">
{{formitem}}
</div>
{% endfor %}
<div class="object__item">
<a href="{% url 'dashboard:order-detail' order.pk %}">cancel</a> <input class="action-button order__fulfill" type="submit" value="Ship order and send tracking info to customer">
</div>
</section>
</form>
</section>
</article>
{% endblock content %}

View File

@ -0,0 +1,22 @@
{% extends "dashboard.html" %}
{% load static %}
{% block content %}
<article>
<header class="object__header">
<h1><img src="{% static 'images/cubes.png' %}" alt=""> Product</h1>
</header>
<section class="product__detail object__panel">
<figure class="product__figure">
<img class="" src="{{product.productphoto_set.first.image.url}}" alt="{{product.productphoto_set.first.image}}">
</figure>
<form method="post">{% csrf_token %}
<p>Are you sure you want to delete "{{ object }}"?</p>
{{ form.as_p }}
<p>
<input class="action-button action-button--warning" type="submit" value="Confirm"> or <a href="{% url 'dashboard:product-detail' product.pk %}">cancel</a>
</p>
</form>
</section>
</article>
{% endblock content %}

View File

@ -5,7 +5,10 @@
<article>
<header class="object__header">
<h1><img src="{% static "images/cubes.png" %}" alt=""> Product</h1>
<a href="{% url 'dashboard:product-update' product.pk %}" class="action-button">Edit</a>
<div class="object__menu">
<a href="{% url 'dashboard:product-delete' product.pk %}" class="action-button action-button--warning">Delete</a>
<a href="{% url 'dashboard:product-update' product.pk %}" class="action-button">Edit</a>
</div>
</header>
<section class="product__detail object__panel">
<figure class="product__figure">
@ -17,7 +20,7 @@
<p>$<strong>{{product.price}}</strong></p>
<p>{{product.weight.oz}} oz</p>
<p>Visible in listings: <strong>{{product.visible_in_listings|yesno:"Yes,No"}}</strong></p>
<p>Ordered {{product.num_ordered|default_if_none:"0"}} time{{ product.num_ordered|pluralize }}.</p>
<p>Ordered {{product.num_ordered|default_if_none:"0"}} time{{ product.num_ordered|default_if_none:"0"|pluralize }}.</p>
</div>
</section>
</article>

View File

@ -0,0 +1,16 @@
{% extends "dashboard.html" %}
{% block content %}
<article class="product">
<h1>Create shipmeth</h1>
<section>
<form method="POST" action="{% url 'dashboard:shipmeth-create' %}">
{% csrf_token %}
{{form.as_p}}
<p class="form__submit">
<input class="action-button" type="submit" value="Create method"> or <a href="{% url 'dashboard:config' %}">cancel</a>
</p>
</form>
</section>
</article>
{% endblock %}

View File

@ -0,0 +1,21 @@
{% extends "dashboard.html" %}
{% load static %}
{% block content %}
<article>
<header class="object__header">
<h1><img src="{% static 'images/gear.png' %}" alt=""> Shipping Method</h1>
<div class="object__menu">
<a href="" class="action-button action-button--warning">Delete</a>
<a href="" class="action-button">Edit</a>
</div>
</header>
<section class="product__detail object__panel">
<div>
<h1>{{shippingmethod.name}}</h1>
<p>{{shippingmethod.get_type_display}}</p>
<p>$<strong>{{shippingmethod.price}}</strong></p>
</div>
</section>
</article>
{% endblock content %}

View File

@ -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/<int:pk>/', include([
path('', views.ShippingMethodDetailView.as_view(), name='shipmeth-detail'),
])),
path('orders/', views.OrderListView.as_view(), name='order-list'),
path('orders/<int:pk>/', 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('<int:pk>/', include([
path('products/<int:pk>/', 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'),

View File

@ -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',

View File

@ -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 {

View File

@ -45,9 +45,9 @@
<img src="{% static 'images/coupon.png' %}" alt="">
Coupons
</a>
<a href="">
<a href="{% url 'dashboard:config' %}">
<img src="{% static 'images/gear.png' %}" alt="">
Staff
Config
</a>
</nav>
<div class="dashboard__user">
@ -67,8 +67,24 @@
{% endblock content %}
</main>
</div>
{% if messages %}
<div class="site__messages">
{% for message in messages %}
<span class="messages__message {% if message.tags %} {{ message.tags }} {% endif %}">{{ message }}</span>
{% endfor %}
</div>
{% endif %}
<footer>
</footer>
<script>
const messageEl = document.querySelector('.site__messages')
if (messageEl) {
setTimeout(function () {
messageEl.style.display = 'none'
}, 5000)
}
</script>
</body>
</html>

View File

@ -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 %}
<p>Great news! Your recent order #{{order_id}} has shipped</p>
<p>{{tracking_id}}</p>
<p>Thanks,<br>
Port Townsend Coffee</p>
{% endblock %}