Add coupon functionality

This commit is contained in:
Nathan Chapman 2022-04-01 11:11:50 -06:00
parent 194fb8d655
commit 775df2501a
43 changed files with 714 additions and 124 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -9,7 +9,7 @@
</header>
<section class="object__panel">
<div class="object__item object__item--header">
<div class="object__item panel__header panel__header--flex">
<h4>Shipping methods</h4>
<a href="{% url 'dashboard:shipmeth-create' %}" class="action-button order__fulfill">+ New method</a>
</div>
@ -25,7 +25,7 @@
</section>
<section class="object__panel">
<div class="object__item object__item--header">
<div class="object__item panel__header panel__header--flex">
<h4>Staff</h4>
<a href="" class="action-button order__fulfill">+ New staff</a>
</div>

View File

@ -0,0 +1,19 @@
{% extends "dashboard.html" %}
{% load static %}
{% block content %}
<article>
<header class="object__header">
<h1><img src="{% static 'images/coupon.png' %}" alt=""> Coupon</h1>
</header>
<section class="coupon__detail object__panel">
<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:coupon-detail' coupon.pk %}">cancel</a>
</p>
</form>
</section>
</article>
{% endblock content %}

View File

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

View File

@ -0,0 +1,23 @@
{% extends "dashboard.html" %}
{% load static %}
{% block content %}
<article>
<header class="object__header">
<h1><img src="{% static 'images/coupon.png' %}" alt=""> {{ coupon.name }}</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>
<p>{{ coupon.get_type_display }}</p>
<p>{{ coupon.code }}</p>
<p>{{ coupon.valid_from }}</p>
<p>{{ coupon.valid_to }}</p>
<p>{{ coupon.discount_value }} {{ coupon.get_discount_value_type_display }}</p>
</div>
</section>
</article>
{% endblock content %}

View File

@ -0,0 +1,18 @@
{% extends "dashboard.html" %}
{% block content %}
<article class="product">
<header class="object__header">
<h1>Update Coupon</h1>
</header>
<section class="object__panel">
<form class="panel__item" method="POST" action="{% url 'dashboard:coupon-update' coupon.pk %}">
{% csrf_token %}
{{form.as_p}}
<p class="form__submit">
<input class="action-button" type="submit" value="Save changes"> or <a href="{% url 'dashboard:coupon-detail' coupon.pk %}">cancel</a>
</p>
</form>
</section>
</article>
{% endblock %}

View File

@ -0,0 +1,33 @@
{% extends "dashboard.html" %}
{% load static %}
{% block content %}
<article>
<header class="object__header">
<h1><img src="{% static 'images/coupon.png' %}" alt=""> Coupons</h1>
<div class="object__menu">
<a href="{% url 'dashboard:coupon-create' %}" class="action-button order__fulfill">+ New coupon</a>
</div>
</header>
<section class="object__list">
<div class="object__item panel__header object__item--col5" href="coupon-detail">
<span>Name</span>
<span>Code</span>
<span>Starts</span>
<span>Ends</span>
<span>Value</span>
</div>
{% for coupon in coupon_list %}
<a class="object__item object__item--link object__item--col5" href="{% url 'dashboard:coupon-detail' coupon.pk %}">
<span>{{ coupon.name }}</span>
<span>{{ coupon.code }}</span>
<span>{{ coupon.valid_from|date:"SHORT_DATE_FORMAT" }}</span>
<span>{{ coupon.valid_to|date:"SHORT_DATE_FORMAT" }}</span>
<span>{{ coupon.discount_value }} {{ coupon.get_discount_value_type_display }}</span>
</a>
{% empty %}
<span class="object__item">No coupons</span>
{% endfor %}
</section>
</article>
{% endblock content %}

View File

@ -5,10 +5,12 @@
<article>
<header class="object__header">
<h1><img src="{% static "images/customers.png" %}" alt=""> Customer: {{customer.get_full_name}}</h1>
<a href="{% url 'dashboard:customer-update' customer.pk %}" class="action-button">Edit</a>
<div class="object__menu">
<a href="{% url 'dashboard:customer-update' customer.pk %}" class="action-button">Edit</a>
</div>
</header>
<section class="object__panel">
<div class="object__item object__item--header">
<div class="object__item panel__header">
<h4>Info</h4>
</div>
<div class="panel__item">
@ -52,14 +54,14 @@
</section>
{% with order_list=customer.orders.all %}
<section class="object__list">
<div class="object__item object__item--header" href="order-detail">
<div class="object__item panel__header object__item--col4" href="order-detail">
<span>Order #</span>
<span>Date</span>
<span>Status</span>
<span>Total</span>
</div>
{% for order in order_list %}
<a class="object__item" href="{% url 'dashboard:order-detail' order.pk %}">
<a class="object__item object__item--col4 object__item--link" href="{% url 'dashboard:order-detail' order.pk %}">
<span>#{{order.pk}}</span>
<span>{{order.created_at|date:"D, M j Y"}}</span>
<span class="order__status--display">

View File

@ -2,9 +2,11 @@
{% block content %}
<article class="product">
<h1>Update Customer</h1>
<section>
<form method="POST" action="{% url 'dashboard:customer-update' customer.pk %}">
<header class="object__header">
<h1>Update Customer</h1>
</header>
<section class="object__panel">
<form class="panel__item" method="POST" action="{% url 'dashboard:customer-update' customer.pk %}">
{% csrf_token %}
{{form.as_p}}
<p class="form__submit">

View File

@ -3,15 +3,17 @@
{% block content %}
<article>
<h1><img src="{% static 'images/customer.png' %}" alt=""> Customers</h1>
<header class="object__header">
<h1><img src="{% static 'images/customer.png' %}" alt=""> Customers</h1>
</header>
<section class="object__list">
<div class="object__item object__item--header" href="customer-detail">
<div class="object__item panel__header object__item--col3" href="customer-detail">
<span>Name</span>
<span>Email</span>
<span>Orders</span>
</div>
{% for customer in user_list %}
<a class="object__item" href="{% url 'dashboard:customer-detail' customer.pk %}">
<a class="object__item object__item--link object__item--col3" href="{% url 'dashboard:customer-detail' customer.pk %}">
<span>{{customer.get_full_name}}</span>
<span>{{customer.email}}</span>
<span>{{customer.num_orders}}</span>

View File

@ -17,7 +17,7 @@
</div>
</header>
<section class="object__list">
<div class="object__item object__item--header">
<div class="object__item panel__header object__item--col5">
<span>Product</span>
<span>SKU</span>
<span>Quantity</span>
@ -25,7 +25,7 @@
<span>Total</span>
</div>
{% for item in order.lines.all %}
<div class="object__item">
<div class="object__item object__item--col5">
{% with product=item.product %}
<figure class="item__figure">
<img class="product__image product__image--small" src="{{product.productphoto_set.first.image.url}}" alt="{{product.productphoto_set.first.image}}">
@ -40,13 +40,13 @@
{% empty %}
<p>No items in order yet.</p>
{% endfor %}
<div class="object__item">
<div class="object__item object__item--col5">
<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">
<div class="object__item panel__header object__item--col5">
<h4>Shipping</h4>
<a href="{% url 'dashboard:order-ship' order.pk %}" class="action-button order__fulfill">Ship order &rarr;</a>
</div>
@ -66,7 +66,7 @@
</section>
<section class="object__panel">
<div class="object__item object__item--header">
<div class="object__item panel__header">
<h4>Customer</h4>
</div>
{% with customer=order.customer %}
@ -98,7 +98,22 @@
</section>
<section class="object__panel">
<div class="object__item object__item--header">
<div class="object__item panel__header">
<h4>Payment</h4>
</div>
<div class="panel__item">
<p>
<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>
</p>
</div>
</section>
<section class="object__panel">
<div class="object__item panel__header">
<h4>Transaction</h4>
</div>
<div class="panel__item">

View File

@ -16,14 +16,14 @@
</div>
{% endfor %}
{% endfor %}
<div class="object__item object__item--header">
<div class="object__item panel__header object__item--col4">
<span>Product</span>
<span>SKU</span>
<span>Quantity to fulfill</span>
<span>Grind</span>
</div>
{% for form in form %}
<div class="object__item">
<div class="object__item object__item--col4">
{% with product=form.instance.product %}
{{form.id}}
<figure class="item__figure">
@ -36,7 +36,7 @@
{% endwith %}
</div>
{% endfor %}
<div class="object__item">
<div class="object__item object__item--col5">
<a href="{% url 'dashboard:order-detail' order.pk %}">cancel</a> <input class="action-button order__fulfill" type="submit" value="Fulfill">
</div>
</section>

View File

@ -7,7 +7,7 @@
<h1><img src="{% static "images/box.png" %}" alt=""> Orders</h1>
</header>
<section class="object__list">
<div class="object__item object__item--header" href="order-detail">
<div class="object__item panel__header object__item--col5" href="order-detail">
<span>Order #</span>
<span>Date</span>
<span>Customer</span>
@ -15,7 +15,7 @@
<span>Total</span>
</div>
{% for order in order_list %}
<a class="object__item" href="{% url 'dashboard:order-detail' order.pk %}">
<a class="object__item object__item--link object__item--col5" href="{% url 'dashboard:order-detail' order.pk %}">
<span>#{{order.pk}}</span>
<span>{{order.created_at|date:"D, M j Y"}}</span>
<span>{{order.customer.get_full_name}}</span>

View File

@ -2,8 +2,10 @@
{% block content %}
<article>
<h1>Fulfill Order #{{order.pk}}</h1>
<section>
<header class="object__header">
<h1>Ship Order #{{order.pk}}</h1>
</header>
<section class="object__panel">
<form method="POST" action="">
{% csrf_token %}
{{ form.management_form }}
@ -16,18 +18,15 @@
</div>
{% endfor %}
{% endfor %}
<div class="object__item object__item--header">
<div class="object__item panel__header panel__header--flex">
<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">
<div class="object__item object__item--col5">
<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>

View File

@ -0,0 +1,18 @@
{% extends "dashboard.html" %}
{% block content %}
<article class="product">
<header class="object__header">
<h1>Add photo</h1>
</header>
<section class="object__panel">
<form class="panel__item" enctype="multipart/form-data" method="POST" action="{% url 'dashboard:prodphoto-create' product.pk %}">
{% csrf_token %}
{{form.as_p}}
<p class="form__submit">
<input class="action-button" type="submit" value="Add photo"> or <a href="{% url 'dashboard:product-detail' product.pk %}">cancel</a>
</p>
</form>
</section>
</article>
{% endblock %}

View File

@ -23,5 +23,24 @@
<p>Ordered {{product.num_ordered|default_if_none:"0"}} time{{ product.num_ordered|default_if_none:"0"|pluralize }}.</p>
</div>
</section>
<section class="object__panel">
<div class="object__item panel__header panel__header--flex">
<h4>Photos</h4>
<a href="{% url 'dashboard:prodphoto-create' product.pk %}" class="action-button order__fulfill">+ Upload new photo</a>
</div>
<div class="panel__item gallery">
{% for photo in product.productphoto_set.all %}
<figure class="gallery__item">
<img src="{{ photo.image.url }}" alt="">
<figcaption>
<form action="{% url 'dashboard:prodphoto-delete' product.pk photo.pk %}" method="post">
{% csrf_token %}
<input type="submit" class="action-button action-button--warning" value="Delete photo">
</form>
</figcaption>
</figure>
{% endfor %}
</div>
</section>
</article>
{% endblock content %}

View File

@ -8,14 +8,14 @@
<a href="{% url 'dashboard:product-create' %}" class="action-button">+ New product</a>
</header>
<section class="object__list">
<div class="object__item object__item--header">
<div class="object__item panel__header object__item--col4">
<span></span>
<span>Name</span>
<span>Visible</span>
<span>Price</span>
</div>
{% for product in product_list %}
<a class="object__item" href="{% url 'dashboard:product-detail' product.pk %}">
<a class="object__item object__item--link object__item--col4" href="{% url 'dashboard:product-detail' product.pk %}">
<figure class="product__figure">
<img class="product__image" src="{{product.productphoto_set.first.image.url}}" alt="{{product.productphoto_set.first.image}}">
</figure>

View File

@ -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/<int:pk>/', 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/<int:pk>/', 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/<int:photo_pk>/', include([
path('delete/', views.ProductPhotoDeleteView.as_view(), name='prodphoto-delete'),
])),
])),
path('customers/', views.CustomerListView.as_view(), name='customer-list'),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -28,7 +28,22 @@
{% endfor %}
</section>
<section>
<p class="cart__total_price">Cart total: <strong>${{cart.get_total_price}}</strong></p>
<div class="cart__total">
<form action="{% url 'storefront:coupon-apply' %}" method="post" class="coupon__form">
{% csrf_token %}
{{ coupon_apply_form.as_p }}
<p>
<input type="submit" value="Apply" class="action-button">
</p>
</form>
</div>
<p class="cart__total_price">
<span class="">Subtotal: ${{cart.get_total_price|floatformat:"2"}}</span><br>
{% if cart.coupon %}
<span class="">Coupon: {{cart.coupon.discount_value}} {{cart.coupon.get_discount_value_type_display}}</span><br>
{% endif %}
<span class="cart__total_price">Cart total: <strong>${{cart.get_total_price_after_discount|floatformat:"2"}}</strong></span>
</p>
<p class="cart__total">
<a href="{% url 'storefront:product-list' %}">Continue Shopping</a>&emsp;or&emsp;<a class="action-button action-button--large" href="{% url 'storefront:checkout-address' %}">Proceed to Checkout</a>
</p>

View File

@ -4,8 +4,8 @@
{% block content %}
<article>
<h2>Checkout</h2>
<section class="order__details">
<div class="order__shipping">
<section class="order__shipping">
<div class="shipping__details">
<h3>Shipping Address</h3>
<form action="" method="POST" class="address__form">
{% csrf_token %}

View File

@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block content %}
<article>
<h1>Contact PT Coffee</h1>
<section>
<form action="{% url 'storefront:contact' %}" method="post">
{% csrf_token %}
{{form.as_p}}
<input type="submit" value="Send message">
</form>
</section>
</article>
{% endblock %}

View File

@ -8,7 +8,7 @@
<a href="{% url 'storefront:customer-update' customer.pk %}" class="action-button">Edit profile</a>
</header>
<section class="object__panel">
<div class="object__item object__item--header">
<div class="object__item panel__header">
<h4>Info</h4>
</div>
<div class="panel__item">
@ -53,19 +53,15 @@
</section>
{% with order_list=customer.orders.all %}
<section class="object__list">
<div class="object__item object__item--header" href="order-detail">
<div class="object__item panel__header" href="order-detail">
<span>Order #</span>
<span>Date</span>
<span>Status</span>
<span>Total</span>
</div>
{% for order in order_list %}
<a class="object__item" href="">
<span>#{{order.pk}}</span>
<span>{{order.created_at|date:"D, M j Y"}}</span>
<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>
</a>
{% empty %}

View File

@ -46,7 +46,13 @@
{{form.as_p}}
{# <input type="submit" value="Place order"> #}
</form>
<h4>Total: ${{cart.get_total_price}}</h4>
<p class="cart__total_price">
<span class="">Subtotal: ${{cart.get_total_price|floatformat:"2"}}</span><br>
{% if cart.coupon %}
<span class="">Coupon: {{cart.coupon.discount_value}} {{cart.coupon.get_discount_value_type_display}}</span><br>
{% endif %}
<span class="cart__total_price">Cart total: <strong>${{cart.get_total_price_after_discount|floatformat:"2"}}</strong></span>
</p>
<div id="paypal-button-container"></div>
</div>
</section>

View File

@ -1,7 +1,11 @@
{% extends "base.html" %}
{% load static %}
{% block head %}
<script defer src="{% static 'scripts/product_form.js' %}"></script>
{% endblock %}
{% block content %}
<article class="product__item">
<figure class="product__figure">
<img class="product__image" src="{{product.productphoto_set.first.image.url}}" alt="{{product.productphoto_set.first.image}}">
@ -20,5 +24,4 @@
</form>
</section>
</article>
{% endblock %}

View File

@ -20,6 +20,3 @@
</article>
{% endblock %}
{% block footer %}
<div class="_form_1"></div><script src="https://bltc999.activehosted.com/f/embed.php?id=1" type="text/javascript" charset="utf-8"></script>
{% endblock footer %}

View File

@ -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/<int:pk>/', include([
@ -13,7 +14,10 @@ urlpatterns = [
path('cart/<int:pk>/add/', views.CartAddProductView.as_view(), name='cart-add'),
path('cart/<int:pk>/remove/', views.cart_remove_product_view, name='cart-remove'),
path('coupon/apply/', views.CouponApplyView.as_view(), name='coupon-apply'),
path('paypal/order/<slug:transaction_id>/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'),

View File

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

View File

@ -16,10 +16,10 @@
{% csrf_token %}
<fieldset>
{% for emailaddress in user.emailaddress_set.all %}
<div class="ctrlHolder">
<div>
<input id="email_radio_{{forloop.counter}}" type="radio" name="email" {% if emailaddress.primary or user.emailaddress_set.count == 1 %}checked="checked"{%endif %} value="{{emailaddress.email}}"/>
<label for="email_radio_{{forloop.counter}}" class="{% if emailaddress.primary %}primary_email{%endif%}">
<input id="email_radio_{{forloop.counter}}" type="radio" name="email" {% if emailaddress.primary or user.emailaddress_set.count == 1 %}checked="checked"{%endif %} value="{{emailaddress.email}}"/>
{{ emailaddress.email }}
{% if emailaddress.verified %}

View File

@ -42,12 +42,12 @@
</div>
<nav>
<a href="{% url 'storefront:product-list' %}">Shop</a>
<a href="">Wholesale</a>
{# <a href="">Wholesale</a> #}
<a href="">Subscribe</a>
<a href="">Cafe</a>
<a href="">Fair Trade</a>
<a href="{% url 'storefront:about' %}">About</a>
<a href="">Contact</a>
<a href="{% url 'storefront:contact' %}">Contact</a>
<a class="site__cart" href="{% url 'storefront:cart-detail' %}">
<span class="cart__length">{{cart|length}}</span>
<img class="cart__icon" src="{% static 'images/shopping_cart.svg' %}" alt="Shopping cart">
@ -71,8 +71,7 @@
<div>
<h4>Problem with your order?<br>Have a question?</h4>
<p>Please contact us, were happy to help you over the phone at <a href="tel:+13603855856">(360) 385-5856</a> between 8:00 am and 10:00 pm Pacific Time.</p>
{% block footer %}
{% endblock footer %}
<div class="_form_1"></div><script src="https://bltc999.activehosted.com/f/embed.php?id=1" type="text/javascript" charset="utf-8"></script>
</div>
</div>
<div class="site__copyright">

View File

@ -41,7 +41,7 @@
<img src="{% static 'images/customer.png' %}" alt="">
Customers
</a>
<a href="">
<a href="{% url 'dashboard:coupon-list' %}">
<img src="{% static 'images/coupon.png' %}" alt="">
Coupons
</a>

View File

@ -0,0 +1,16 @@
{% block subject %}{{subject}}{% endblock %}
{% block plain %}
Referred from: {{referal}}
From: {{first_name}} {{last_name}} {{email_address}}
Message: {{message}}
{% endblock %}
{% block html %}
<p><strong>Referred from</strong>:<br>{{referal}}</p>
<p><strong>From</strong>:<br>{{first_name}} {{last_name}} {{email_address|urlize}}</p>
<p><strong>Message</strong>:<br>{{message|linebreaks}}</p>
{% endblock %}