Merge branch 'feature/usps-shipping' into develop

This commit is contained in:
Nathan Chapman 2022-04-24 12:38:48 -06:00
commit 1e49f36f7a
21 changed files with 254 additions and 32 deletions

View File

@ -71,3 +71,27 @@ class ShippingMethodType:
(PRICE_BASED, "Price based shipping"),
(WEIGHT_BASED, "Weight based shipping"),
]
class ShippingService:
FIRST_CLASS = "FIRST CLASS"
PRIORITY = "PRIORITY"
PRIORITY_COMMERCIAL = "PRIORITY COMMERCIAL"
CHOICES = [
(FIRST_CLASS, "First Class"),
(PRIORITY, "Priority"),
(PRIORITY_COMMERCIAL, "Priority Commercial")
]
class ShippingContainer:
LG_FLAT_RATE_BOX = "LG FLAT RATE BOX"
REGIONAL_RATE_BOX_A = "REGIONALRATEBOXA"
REGIONAL_RATE_BOX_B = "REGIONALRATEBOXB"
VARIABLE = "VARIABLE"
CHOICES = [
(LG_FLAT_RATE_BOX, "Flate Rate Box - Large"),
(REGIONAL_RATE_BOX_A, "Regional Rate Box A"),
(REGIONAL_RATE_BOX_B, "Regional Rate Box B"),
(VARIABLE, "Variable")
]

View File

@ -0,0 +1,22 @@
# Generated by Django 4.0.2 on 2022-04-24 16:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0005_alter_product_options_product_sorting'),
]
operations = [
migrations.AlterModelOptions(
name='order',
options={'ordering': ('-created_at',)},
),
migrations.AddField(
model_name='order',
name='shipping_total',
field=models.DecimalField(decimal_places=2, default=0, max_digits=5),
),
]

View File

@ -9,6 +9,8 @@ from django.conf import settings
from django.utils import timezone
from django.urls import reverse
from django.core.validators import MinValueValidator, MaxValueValidator
from django.core.serializers.json import DjangoJSONEncoder
from django.forms.models import model_to_dict
from django_measurement.models import MeasurementField
@ -25,6 +27,10 @@ from .weight import WeightUnits, zero_weight
logger = logging.getLogger(__name__)
class ProductEncoder(DjangoJSONEncoder):
def default(self, obj):
logger.info(f"\n{obj}\n")
return super().default(obj)
class ProductManager(models.Manager):
def get_queryset(self):
@ -69,7 +75,7 @@ class Product(models.Model):
try:
return self.productphoto_set.all()[1]
except IndexError:
pass
return 'No image'
class Meta:
ordering = ['sorting', 'name']
@ -200,6 +206,7 @@ class Order(models.Model):
on_delete=models.SET_NULL,
)
coupon = models.ForeignKey(
Coupon,
related_name='orders',
@ -207,6 +214,12 @@ class Order(models.Model):
null=True
)
shipping_total = models.DecimalField(
max_digits=5,
decimal_places=2,
default=0
)
total_net_amount = models.DecimalField(
max_digits=10,
decimal_places=2,
@ -237,7 +250,7 @@ class Order(models.Model):
return Decimal('0')
def get_total_price_after_discount(self):
return round(self.total_net_amount - self.get_discount(), 2)
return round((self.total_net_amount - self.get_discount()) + self.shipping_total, 2)
def get_absolute_url(self):
return reverse('dashboard:order-detail', kwargs={'pk': self.pk})

38
src/core/usps.py Normal file
View File

@ -0,0 +1,38 @@
import json
import requests
import xmltodict
from lxml import etree
from usps import USPSApi
class USPSApiWithRate(USPSApi):
urls = {
'tracking': 'TrackV2{test}&XML={xml}',
'label': 'eVS{test}&XML={xml}',
'validate': 'Verify&XML={xml}',
'rate': 'RateV4&XML={xml}',
}
def get_rate(self, *args, **kwargs):
return Rate(self, *args, **kwargs)
class Rate:
def __init__(self, usps, request, **kwargs):
xml = etree.Element('RateV4Request', {'USERID': usps.api_user_id})
etree.SubElement(xml, 'Revision').text = '2'
package = etree.SubElement(xml, 'Package', {'ID': '0'})
etree.SubElement(package, 'Service').text = request['service']
etree.SubElement(package, 'ZipOrigination').text = request['zip_origination']
etree.SubElement(package, 'ZipDestination').text = request['zip_destination']
etree.SubElement(package, 'Pounds').text = request['pounds']
etree.SubElement(package, 'Ounces').text = request['ounces']
etree.SubElement(package, 'Container').text = request['container']
etree.SubElement(package, 'Width').text = request['width']
etree.SubElement(package, 'Length').text = request['length']
etree.SubElement(package, 'Height').text = request['height']
etree.SubElement(package, 'Girth').text = request['girth']
etree.SubElement(package, 'Machinable').text = request['machinable']
self.result = usps.send_request('rate', xml)

View File

@ -103,11 +103,12 @@
</div>
<div class="panel__item">
<p>
<span>Subtotal: {{order.total_net_amount}}</span><br>
<span>Subtotal: ${{order.total_net_amount}}</span><br>
{% if order.coupon %}
<span>Discount: {{order.coupon.discount_value}} {{order.coupon.get_discount_value_type_display}}</span><br>
{% endif %}
<span>Total: {{order.get_total_price_after_discount}}</span>
<span>Shipping: ${{order.shipping_total}}</span><br>
<span>Total: ${{order.get_total_price_after_discount}}</span>
</p>
</div>
</section>

View File

@ -22,7 +22,7 @@
<span class="order__status--display">
<div class="status__dot order__status--{{order.status}}"></div>
{{order.get_status_display}}</span>
<span>${{order.total_net_amount}}</span>
<span>${{order.get_total_price_after_discount}}</span>
</a>
{% empty %}
<span class="object__item">No orders</span>

View File

@ -20,6 +20,8 @@ CACHE_CONFIG = {
PAYPAL_CLIENT_ID = os.environ.get('PAYPAL_CLIENT_ID', '')
PAYPAL_SECRET_ID = os.environ.get('PAYPAL_SECRET_ID', '')
USPS_USER_ID = os.environ.get('USPS_USER_ID', '639NATHA3105')
DEFAULT_ZIP_ORIGINATION = os.environ.get('DEFAULT_ZIP_ORIGINATION', '98368')
ANYMAIL_CONFIG = {
'MAILGUN_API_KEY': os.environ.get('MAILGUN_API_KEY', ''),

View File

@ -93,6 +93,7 @@ DATABASES = {
CACHES = {'default': CACHE_CONFIG}
SESSION_ENGINE = 'django.contrib.sessions.backends.cached_db'
SESSION_SERIALIZER = 'django.contrib.sessions.serializers.PickleSerializer'
# Password validation
# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

View File

@ -1,12 +1,14 @@
:root {
--fg-color: #34201a;
--fg-alt-color: #663a2d;
--bg-color: #f5f5f5;
--bg-alt-color: #c8a783;
--bg-color: #fffbf8;
--bg-alt-color: #b07952;
--gray-color: #9d9d9d;
--yellow-color: #f8a911;
--yellow-alt-color: #ffce6f;
--yellow-dark-color: #b27606;
--red-color: #d43131;
--green-color: #3ea165;
--default-border: 2px solid var(--gray-color);
}
@ -48,6 +50,7 @@ h1, h2, h3, h4, h5 {
h1 {
margin-top: 0;
font-family: 'Vollkorn', serif;
font-size: 2.488rem;
}
@ -443,6 +446,53 @@ section:not(:last-child) {
flex-direction: column;
}
/* Site Banner
========================================================================== */
.site__banner {
background-color: rgba(0, 0, 0, 0.44);
background-blend-mode: multiply;
background-image: url("/static/images/site_banner.jpg");
background-size: cover;
background-position: center;
color: white;
text-align: center;
padding: 8rem 1rem;
font-family: 'Vollkorn', serif;
}
.site__banner h1 {
font-size: 3.5rem;
}
.site__banner p {
text-transform: lowercase;
font-variant: small-caps;
font-size: 2rem;
}
/* Messages
========================================================================== */
.messages {
text-align: center;
font-weight: bold;
margin-bottom: 0 !important;
}
.messages p {
margin-bottom: 0;
}
.messages .success {
background-color: var(--green-color);
}
.messages .warning {
background-color: var(--yellow-color);
}
.messages .error {
background-color: var(--red-color);
}
/* Site Cart
========================================================================== */
.site__cart {

View File

@ -1,17 +1,23 @@
import logging
from decimal import Decimal
from django.conf import settings
from core.models import Product, OrderLine, Coupon
from .payments import CreateOrder
from django.contrib import messages
from core.models import Product, OrderLine, Coupon
from core.usps import USPSApiWithRate
from core import (
DiscountValueType,
VoucherType,
TransactionStatus,
OrderStatus,
ShippingMethodType
ShippingMethodType,
ShippingService,
ShippingContainer
)
from .payments import CreateOrder
logger = logging.getLogger(__name__)
class Cart:
@ -23,7 +29,7 @@ class Cart:
cart = self.session[settings.CART_SESSION_ID] = {}
self.cart = cart
def add(self, product, quantity=1, grind='', update_quantity=False):
def add(self, request, product, quantity=1, grind='', update_quantity=False):
product_id = str(product.id)
if product_id not in self.cart:
self.cart[product_id] = {
@ -36,7 +42,10 @@ class Cart:
self.cart[product_id]['quantity'] = quantity
else:
self.cart[product_id]['quantity'] += quantity
if len(self) <= 20:
self.save()
else:
messages.warning(request, "Cart is full: 20 items or less.")
def save(self):
self.session[settings.CART_SESSION_ID] = self.cart
@ -63,6 +72,26 @@ class Cart:
def __len__(self):
return sum(item['quantity'] for item in self.cart.values())
def get_total_weight(self):
return sum([item['product'].weight.value * item['quantity'] for item in self])
def get_shipping_box(self):
logger.info(len(self))
if len(self) > 6 and len(self) <= 10:
return ShippingContainer.LG_FLAT_RATE_BOX
elif len(self) > 2 and len(self) <= 6:
return ShippingContainer.REGIONAL_RATE_BOX_B
elif len(self) <= 2:
return ShippingContainer.REGIONAL_RATE_BOX_A
else:
return ShippingContainer.VARIABLE
def get_shipping_cost(self):
usps_rate_request = self.build_usps_rate_request()
usps = USPSApiWithRate(settings.USPS_USER_ID, test=True)
validation = usps.get_rate(usps_rate_request)
return Decimal(validation.result['RateV4Response']['Package']['Postage']['CommercialRate'])
def get_total_price(self):
return sum(Decimal(item['price']) * item['quantity'] for item in self.cart.values())
@ -74,6 +103,22 @@ class Cart:
pass
self.session.modified = True
def build_usps_rate_request(self):
return \
{
'service': ShippingService.PRIORITY_COMMERCIAL,
'zip_origination': settings.DEFAULT_ZIP_ORIGINATION,
'zip_destination': f'{self.session.get("shipping_address")["postal_code"]}',
'pounds': '0',
'ounces': f'{self.get_total_weight()}',
'container': f'{self.get_shipping_box()}',
'width': '',
'length': '',
'height': '',
'girth': '',
'machinable': 'TRUE'
}
def build_order_params(self):
return \
{
@ -81,7 +126,7 @@ class Cart:
'total_price': f'{self.get_total_price_after_discount()}',
'item_total': f'{self.get_total_price()}',
'discount': f'{self.get_discount()}',
'shipping_price': '0',
'shipping_price': f'{self.get_shipping_cost()}',
'tax_total': '0',
'shipping_method': 'US POSTAL SERVICE',
'shipping_address': self.build_shipping_address(self.session.get('shipping_address')),
@ -130,5 +175,8 @@ class Cart:
return round((self.coupon.discount_value / Decimal('100')) * self.get_total_price(), 2)
return Decimal('0')
def get_total_price_after_discount(self):
def get_subtotal_price_after_discount(self):
return round(self.get_total_price() - self.get_discount(), 2)
def get_total_price_after_discount(self):
return round(self.get_total_price() - self.get_discount() + self.get_shipping_cost(), 2)

View File

@ -96,9 +96,11 @@ class OrderCreateForm(forms.ModelForm):
model = Order
fields = (
'total_net_amount',
'shipping_total',
)
widgets = {
'total_net_amount': forms.HiddenInput()
'total_net_amount': forms.HiddenInput(),
'shipping_total': forms.HiddenInput()
}
class CouponApplyForm(forms.Form):

View File

@ -51,7 +51,7 @@
<table class="cart__totals">
<tr>
<td>Subtotal</td>
<td>${{cart.get_total_price|floatformat:"2"}}</td>
<td>${{ cart.get_total_price|floatformat:"2" }}</td>
</tr>
{% if cart.coupon %}
<tr>
@ -61,7 +61,7 @@
{% endif %}
<tr>
<th>Total</th>
<td><strong>${{cart.get_total_price_after_discount|floatformat:"2"}}</strong></td>
<td><strong>${{cart.get_subtotal_price_after_discount|floatformat:"2"}}</strong></td>
</tr>
</table>
</div>

View File

@ -5,13 +5,9 @@
<header>
<h1>Contact us</h1>
<h4>Problem with your online order or have a question?</h4>
<p>
Please contact us, were happy to help you over the phone<br>
<a href="tel:+13603855856">(360) 385-5856</a> between 8:00 am and 10:00 pm Pacific Time.
</p>
<p>Please contact us, were happy to help you.</p>
</header>
<section>
<p>Or send us a message using the form below and we'll email you back as soon as we can.</p>
<form action="{% url 'storefront:contact' %}" method="post" class="contact-form">
{% csrf_token %}
{{form.as_p}}

View File

@ -62,6 +62,10 @@
<td>{{cart.coupon.discount_value}} {{cart.coupon.get_discount_value_type_display}}</td>
</tr>
{% endif %}
<tr>
<td>Shipping</td>
<td>${{ cart.get_shipping_cost }}</small></td>
</tr>
<tr>
<th>Total</th>
<td><strong>${{cart.get_total_price_after_discount|floatformat:"2"}}</strong></td>

View File

@ -6,6 +6,10 @@
{% endblock %}
{% block content %}
<div class="site__banner">
<h1><em>Better</em>, not <em>Bitter</em></h1>
<p>ORGANIC COFFEE, SLOW ROASTED, ITALIAN STYLE</p>
</div>
<article>
<section class="product__list">
{% for product in product_list %}

View File

@ -4,7 +4,7 @@
{% block content %}
<article>
<header>
<h2>Reviews</h2>
<h1>Reviews</h1>
</header>
<section>
<figure>

View File

@ -44,6 +44,7 @@ class CartTest(TestCase):
request = response.wsgi_request
cart = Cart(request)
cart.add(
request=request,
product=self.product,
quantity=1,
update_quantity=False

View File

@ -17,6 +17,7 @@ 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 django.forms.models import model_to_dict
from paypalcheckoutsdk.orders import OrdersCreateRequest, OrdersCaptureRequest
from paypalcheckoutsdk.core import PayPalHttpClient, SandboxEnvironment
@ -26,6 +27,7 @@ from accounts.utils import get_or_create_customer
from accounts.forms import AddressForm as AccountAddressForm, CustomerUpdateForm
from core.models import Product, Order, Transaction, OrderLine, Coupon
from core.forms import ShippingMethodForm
from core import OrderStatus
from .forms import AddToCartForm, UpdateCartItemForm, OrderCreateForm, AddressForm, CouponApplyForm, ContactForm
from .cart import Cart
@ -61,6 +63,7 @@ class CartAddProductView(SingleObjectMixin, FormView):
form = self.get_form()
if form.is_valid():
cart.add(
request=request,
product=self.get_object(),
grind=form.cleaned_data['grind'],
quantity=form.cleaned_data['quantity']
@ -85,6 +88,7 @@ class CartUpdateProductView(SingleObjectMixin, FormView):
form = self.get_form()
if form.is_valid():
cart.add(
request=request,
product=self.get_object(),
quantity=form.cleaned_data['quantity'],
update_quantity=form.cleaned_data['update']
@ -178,7 +182,8 @@ class OrderCreateView(CreateView):
def get_initial(self):
cart = Cart(self.request)
initial = {
'total_net_amount': cart.get_total_price()
'total_net_amount': cart.get_total_price(),
'shipping_total': cart.get_shipping_cost()
}
if self.request.user.is_authenticated:
@ -210,13 +215,14 @@ class OrderCreateView(CreateView):
cart = Cart(self.request)
shipping_address = self.request.session.get('shipping_address')
form.instance.customer, form.instance.shipping_address = get_or_create_customer(self.request, form, shipping_address)
form.instance.status = OrderStatus.DRAFT
self.object = form.save()
bulk_list = cart.build_bulk_list(self.object)
objs = OrderLine.objects.bulk_create(bulk_list)
response = cart.create_order()
data = response.result.__dict__['_dict']
cart.clear()
self.request.session['order_id'] = self.object.pk
return JsonResponse(data)
@ -224,8 +230,12 @@ class OrderCreateView(CreateView):
def paypal_order_transaction_capture(request, transaction_id):
if request.method =="POST":
data = CaptureOrder().capture_order(transaction_id)
transaction = Transaction.objects.get(order__pk=request.session.get('order_id'))
cart = Cart(request)
cart.clear()
order = Order.objects.get(pk=request.session.get('order_id'))
order.status = OrderStatus.UNFULFILLED
order.save()
transaction = Transaction.objects.get(order=order)
transaction.paypal_id = data['purchase_units'][0]['payments']['captures'][0]['id']
transaction.status = data['status']
transaction.save()

View File

@ -73,6 +73,13 @@
</nav>
</header>
<main>
{% if messages %}
<section class="messages">
{% for message in messages %}
<p {% if message.tags %} class="{{ message.tags }}"{% endif %}>{{ message }}</p>
{% endfor %}
</section>
{% endif %}
{% block content %}
{% endblock content %}
</main>
@ -81,8 +88,7 @@
<p><button class="show-modal">Subscribe to our newsletter</button></p>
<p>
<strong>Problem with your online order or have a question?</strong><br>
Please <a href="{% url 'storefront:contact' %}">contact us</a>, were happy to help you over the phone<br>
<a href="tel:+13603855856">(360) 385-5856</a> between 8:00 am and 10:00 pm Pacific Time.<br>
Please <a href="{% url 'storefront:contact' %}">contact us</a>, were happy to help you.<br>
<address>854 East Park Ave. Suite 1, Port Townsend, WA 98368</address>
</p>
<p>

View File

@ -2,7 +2,7 @@
{% block plain %}
Great news! Your recent order #{{order_id}} has shipped
{{tracking_id}}
Your USPS tracking ID: {{tracking_id}}
Thanks,
Port Townsend Coffee
@ -11,7 +11,7 @@
{% block html %}
<p>Great news! Your recent order #{{order_id}} has shipped</p>
<p>{{tracking_id}}</p>
<p>Your USPS tracking ID: {{tracking_id}}</p>
<p>Thanks,<br>
Port Townsend Coffee</p>