2023-03-13 15:44:42 -06:00

779 lines
28 KiB
Python

import logging
import requests
import json
import stripe
from decimal import Decimal
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.cache import cache
from django.contrib.sites.models import Site
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.http import JsonResponse, HttpResponseRedirect
from django.views.generic.base import View, RedirectView, TemplateView
from django.views.generic.edit import (
FormView, CreateView, UpdateView, DeleteView, FormMixin
)
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.contrib import messages
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 django.db.models import (
Exists, OuterRef, Prefetch, Subquery, Count, Sum, Avg, F, Q, Value
)
from measurement.measures import Weight
from measurement.utils import guess
from paypalcheckoutsdk.orders import OrdersCreateRequest, OrdersCaptureRequest
from paypalcheckoutsdk.core import PayPalHttpClient, SandboxEnvironment
from moneyed import Money, USD
from accounts.models import User, Address
from accounts.utils import get_or_create_customer
from accounts.forms import (
AddressForm as AccountAddressForm, CustomerUpdateForm
)
from core.models import (
ProductCategory, Product, ProductVariant, ProductOption,
Order, Transaction, OrderLine, Coupon, ShippingRate,
Subscription, SiteSettings
)
from core.forms import ShippingRateForm
from core.shipping import get_shipping_cost
from core import OrderStatus, ShippingContainer
from .forms import (
AddToCartForm, CartItemUpdateForm, OrderCreateForm,
AddressForm, CouponApplyForm, CheckoutShippingForm,
SubscriptionForm
)
from .cart import CartItem, Cart
from .payments import CaptureOrder
logger = logging.getLogger(__name__)
stripe.api_key = settings.STRIPE_API_KEY
class CartView(FormView):
template_name = 'storefront/cart_detail.html'
form_class = CartItemUpdateForm
def get_success_url(self):
return reverse('storefront:cart-detail')
def post(self, request, *args, **kwargs):
cart = Cart(request)
form = self.get_form()
if form.is_valid():
cart.update_item_quantity(
form.cleaned_data['item_index'],
form.cleaned_data['quantity']
)
return self.form_valid(form)
else:
return self.form_invalid(form)
def form_valid(self, form):
return super().form_valid(form)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['coupon_apply_form'] = CouponApplyForm()
return context
class CartAddProductView(SingleObjectMixin, FormView):
model = Product
form_class = AddToCartForm
http_method_names = ['post']
def get_success_url(self):
return reverse('storefront:cart-detail')
def get_form(self, form_class=None):
variants = self.get_object().variants.filter(
Q(track_inventory=False) | Q(
track_inventory=True,
stock__gt=0
)
)
options = ProductOption.objects.filter(products__pk=self.get_object().pk)
if form_class is None:
form_class = self.get_form_class()
return form_class(variants, options, **self.get_form_kwargs())
def post(self, request, *args, **kwargs):
cart = Cart(request)
form = self.get_form()
if form.is_valid():
cleaned_data = form.cleaned_data
cart.add_item(
CartItem({
'variant_pk': cleaned_data.pop('variant').pk,
'quantity': cleaned_data.pop('quantity'),
'options': cleaned_data
})
)
return self.form_valid(form)
else:
return self.form_invalid(form)
class CartItemUpdateView(FormView):
form_class = CartItemUpdateForm
http_method_names = ['post']
def get_success_url(self):
return reverse('storefront:cart-detail')
def post(self, request, *args, **kwargs):
cart = Cart(request)
form = self.get_form()
if form.is_valid():
cart.update_item_quantity(
form.cleaned_data['item_index'],
form.cleaned_data['quantity']
)
return self.form_valid(form)
else:
return self.form_invalid(form)
def form_valid(self, form):
return super().form_valid(form)
def cart_remove_product_view(request, pk):
cart = Cart(request)
cart.remove_item(pk)
return redirect('storefront:cart-detail')
class CouponApplyView(FormView):
form_class = CouponApplyForm
success_url = reverse_lazy('storefront:cart-detail')
http_method_names = ['post']
def form_valid(self, form):
today = timezone.localtime(timezone.now()).date()
try:
coupon = Coupon.objects.get(code=form.cleaned_data['code'])
except Coupon.DoesNotExist:
messages.warning(self.request, 'Coupon does not exist.')
else:
if coupon.is_valid:
cart = Cart(self.request)
cart.add_coupon(coupon)
else:
messages.warning(self.request, 'Coupon is invalid.')
return super().form_valid(form)
class ProductCategoryDetailView(DetailView):
model = ProductCategory
template_name = 'storefront/category_detail.html'
context_object_name = 'category'
def get_queryset(self):
object_list = ProductCategory.objects.prefetch_related(
Prefetch(
'product_set',
queryset=Product.objects.filter(
Q(visible_in_listings=True),
Q(variants__visible_in_listings=True,
variants__track_inventory=False) |
Q(variants__visible_in_listings=True,
variants__track_inventory=True,
variants__stock__gt=0)
).prefetch_related(
Prefetch(
'variants',
queryset=ProductVariant.objects.filter(
visible_in_listings=True
).order_by('sorting', 'weight')
)
).distinct()
)
)
return object_list
class ProductListView(ListView):
model = Product
template_name = 'storefront/product_list.html'
ordering = ['category', 'sorting']
queryset = Product.objects.filter(
Q(visible_in_listings=True, category__main_category=True),
Q(variants__visible_in_listings=True,
variants__track_inventory=False) |
Q(variants__visible_in_listings=True,
variants__track_inventory=True,
variants__stock__gt=0)
).prefetch_related(
Prefetch(
'variants',
queryset=ProductVariant.objects.filter(
visible_in_listings=True
).order_by('sorting', 'weight')
)
).distinct()
class ProductDetailView(FormMixin, DetailView):
model = Product
template_name = 'storefront/product_detail.html'
form_class = AddToCartForm
def get_form(self, form_class=None):
variants = self.object.variants.filter(
Q(track_inventory=False, visible_in_listings=True) | Q(
visible_in_listings=True,
track_inventory=True,
stock__gt=0
)
).order_by('sorting', 'name')
options = ProductOption.objects.filter(products__pk=self.object.pk)
if form_class is None:
form_class = self.get_form_class()
return form_class(variants, options, **self.get_form_kwargs())
class CheckoutAddressView(FormView):
template_name = 'storefront/checkout_address.html'
form_class = AddressForm
success_url = reverse_lazy('storefront:order-create')
def get_initial(self):
user = self.request.user
initial = None
if user.is_authenticated and user.default_shipping_address:
address = user.default_shipping_address
initial = {
'full_name': address.first_name + ' ' + address.last_name,
'email': user.email,
'street_address_1': address.street_address_1,
'street_address_2': address.street_address_2,
'city': address.city,
'state': address.state,
'postal_code': address.postal_code
}
elif self.request.session.get('shipping_address'):
address = self.request.session.get('shipping_address')
initial = {
'full_name': address['first_name'] + ' ' + address['last_name'],
'email': address['email'],
'street_address_1': address['street_address_1'],
'street_address_2': address['street_address_2'],
'city': address['city'],
'state': address['state'],
'postal_code': address['postal_code']
}
return initial
def form_valid(self, form):
# save address data to session
cleaned_data = form.cleaned_data
first_name, last_name = form.process_full_name(
cleaned_data.get('full_name')
)
address = {
'first_name': first_name,
'last_name': last_name,
'email': cleaned_data['email'],
'street_address_1': cleaned_data['street_address_1'],
'street_address_2': cleaned_data['street_address_2'],
'city': cleaned_data['city'],
'state': cleaned_data['state'],
'postal_code': cleaned_data['postal_code']
}
self.request.session['shipping_address'] = address
return super().form_valid(form)
class CheckoutShippingView(FormView):
template_name = 'storefront/checkout_shipping_form.html'
form_class = CheckoutShippingForm
success_url = reverse_lazy('storefront:order-create')
containers = None
def get_containers(self, request):
if self.containers is None:
cart = Cart(request)
self.containers = cart.get_shipping_container()
return self.containers
def get(self, request, *args, **kwargs):
if not self.request.session.get('shipping_address'):
messages.warning(request, 'Please add a shipping address.')
return HttpResponseRedirect(
reverse('storefront:checkout-address')
)
site_settings = cache.get('SiteSettings')
cart = Cart(self.request)
if len(self.get_containers(request)) == 0:
self.request.session['shipping_container'] = site_settings.default_shipping_rate
return HttpResponseRedirect(self.success_url)
elif len(self.get_containers(request)) == 1:
self.request.session['shipping_container'] = self.get_containers(request)[0]
return HttpResponseRedirect(self.success_url)
return super().get(request, *args, **kwargs)
def get_form(self, form_class=None):
cart = Cart(self.request)
for container in self.get_containers(self.request):
container.s_cost = cart.get_shipping_price(container.container)
if form_class is None:
form_class = self.get_form_class()
return form_class(self.get_containers(self.request), **self.get_form_kwargs())
def form_valid(self, form):
shipping_container = ShippingRate.objects.get(
pk=form.cleaned_data.get('shipping_method')
)
self.request.session['shipping_container'] = shipping_container
return super().form_valid(form)
class OrderCreateView(CreateView):
model = Order
template_name = 'storefront/order_form.html'
form_class = OrderCreateForm
success_url = reverse_lazy('storefront:payment-done')
def get(self, request, *args, **kwargs):
if not self.request.session.get('shipping_address'):
messages.warning(request, 'Please add a shipping address.')
return HttpResponseRedirect(
reverse('storefront:checkout-address')
)
cart = Cart(request)
if cart.coupon is not None:
try:
user = User.objects.get(
email=request.session.get('shipping_address').get('email')
)
except User.DoesNotExist:
user = None
if user in cart.coupon.users.all():
cart.remove_coupon()
messages.warning(request, 'Coupon already used.')
return super().get(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['shipping_address'] = self.request.session.get('shipping_address')
context['PAYPAL_CLIENT_ID'] = settings.PAYPAL_CLIENT_ID
return context
def form_valid(self, form):
cart = Cart(self.request)
form.instance.subtotal_amount = cart.subtotal_price_after_discount
form.instance.coupon = cart.coupon
form.instance.coupon_amount = cart.discount_amount
form.instance.total_amount = cart.total_price
form.instance.weight = cart.total_weight
shipping_container = cart.get_shipping_container()
form.instance.shipping_total = cart.get_shipping_price(shipping_container)
shipping_address = self.request.session.get('shipping_address')
form.instance.customer, form.instance.shipping_address = get_or_create_customer(self.request, 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']
self.request.session['order_id'] = self.object.pk
return JsonResponse(data)
@csrf_exempt
@require_POST
def paypal_order_transaction_capture(request, transaction_id):
if request.method == "POST":
data = CaptureOrder().capture_order(transaction_id)
cart = Cart(request)
order = Order.objects.get(pk=request.session.get('order_id'))
order.status = OrderStatus.UNFULFILLED
order.minus_stock()
try:
coupon = Coupon.objects.get(
code=request.session.get('coupon_code')
)
except ObjectDoesNotExist:
coupon = None
if coupon:
order.coupon = coupon
coupon.users.add(order.customer)
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()
cart.clear()
logger.debug(f'\nPayPal Response data: {data}\n')
return JsonResponse(data)
else:
return JsonResponse({'details': 'invalid request'})
class PaymentDoneView(TemplateView):
template_name = 'storefront/payment_done.html'
class PaymentCanceledView(TemplateView):
template_name = 'storefront/payment_canceled.html'
class CustomerDetailView(UserPassesTestMixin, LoginRequiredMixin, DetailView):
model = User
template_name = 'storefront/customer_detail.html'
context_object_name = 'customer'
permission_denied_message = 'Not authorized.'
raise_exception = True
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['order_list'] = Order.objects.without_drafts().filter(
customer=self.object
).prefetch_related('lines')
subscriptions = []
if self.object.stripe_id is not None:
subscriptions = stripe.Subscription.list(customer=self.object.stripe_id)['data']
context['subscriptions'] = subscriptions
return context
def test_func(self):
return self.request.user.pk == self.get_object().pk
class CustomerUpdateView(UserPassesTestMixin, LoginRequiredMixin, UpdateView):
model = User
template_name = 'storefront/customer_form.html'
context_object_name = 'customer'
form_class = CustomerUpdateForm
permission_denied_message = 'Not authorized.'
raise_exception = True
def test_func(self):
return self.request.user.pk == self.get_object().pk
def get_success_url(self):
return reverse(
'storefront:customer-detail', kwargs={'pk': self.object.pk}
)
class OrderDetailView(UserPassesTestMixin, LoginRequiredMixin, DetailView):
model = Order
template_name = 'storefront/order_detail.html'
pk_url_kwarg = 'order_pk'
permission_denied_message = 'Not authorized.'
raise_exception = True
def test_func(self):
return self.request.user.pk == self.get_object().customer.pk
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['customer'] = User.objects.get(pk=self.kwargs['pk'])
return context
class CustomerAddressCreateView(
UserPassesTestMixin, LoginRequiredMixin, CreateView
):
model = Address
template_name = 'storefront/address_create_form.html'
form_class = AccountAddressForm
permission_denied_message = 'Not authorized.'
raise_exception = True
def test_func(self):
return self.request.user.pk == self.kwargs['pk']
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['customer'] = User.objects.get(pk=self.kwargs['pk'])
return context
def form_valid(self, form):
customer = User.objects.get(pk=self.kwargs['pk'])
self.object = form.save()
customer.addresses.add(self.object)
return super().form_valid(form)
def get_success_url(self):
return reverse(
'storefront:customer-detail', kwargs={'pk': self.kwargs['pk']}
)
class CustomerAddressUpdateView(
UserPassesTestMixin, LoginRequiredMixin, UpdateView
):
model = Address
pk_url_kwarg = 'address_pk'
template_name = 'storefront/address_form.html'
form_class = AccountAddressForm
permission_denied_message = 'Not authorized.'
raise_exception = True
def test_func(self):
return self.request.user.pk == self.kwargs['pk']
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['customer'] = User.objects.get(pk=self.kwargs['pk'])
return context
def get_success_url(self):
return reverse('storefront:customer-detail', kwargs={'pk': self.kwargs['pk']})
class AboutView(TemplateView):
template_name = 'storefront/about.html'
class FairTradeView(TemplateView):
template_name = 'storefront/fairtrade.html'
class ReviewListView(TemplateView):
template_name = 'storefront/reviews.html'
class SubscriptionAdView(TemplateView):
template_name = 'storefront/subscription/ad.html'
class SubscriptionFormView(FormView):
template_name = 'storefront/subscription/form.html'
form_class = SubscriptionForm
success_url = reverse_lazy('storefront:subscription-address')
def get_stripe_products(self):
# id, name
product_list = [{
'id': product['id'],
'name': product['name'],
'created': product['created'],
'weight_per_item': product['metadata']['weight_per_item'],
'cost': product['metadata']['cost'],
'prices': []
} for product in stripe.Product.list(active=True)]
# id, product, recurring.interval_count, recurring.interval, unit_amount
price_list = [{
'id': price['id'],
'product': price['product'],
'interval_count': price.recurring.interval_count,
'interval': price.recurring.interval,
'unit_amount': price['unit_amount']
} for price in stripe.Price.list(active=True)]
for prod in product_list:
prod['prices'] = list(filter(
lambda p: True if p['product'] == prod['id'] else False,
price_list
))
return sorted(product_list, key=lambda p: p['created'])
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
context['stripe_products'] = self.get_stripe_products()
context['product_list'] = Product.objects.filter(
visible_in_listings=True,
category__name='Coffee'
)
return context
def form_valid(self, form):
self.request.session['subscription'] = {
'items': [{
'price': form.cleaned_data['stripe_price_id'],
'quantity': form.cleaned_data['total_quantity']
}],
'metadata': {
'grind': form.cleaned_data['grind'],
'total_weight': form.cleaned_data['total_weight'],
'products_and_quantities': json.loads(form.cleaned_data['products_and_quantities'])
}
}
return super().form_valid(form)
class SubscriptionAddAddressView(CheckoutAddressView):
template_name = 'storefront/subscription/address.html'
success_url = reverse_lazy('storefront:subscription-create')
class SubscriptionCreateView(SuccessMessageMixin, CreateView):
model = Subscription
success_message = 'Subscription created.'
template_name = 'storefront/subscription/create_form.html'
fields = []
def get(self, request, *args, **kwargs):
if not self.request.session.get('subscription'):
return HttpResponseRedirect(reverse('storefront:subscription-form'))
return super().get(request, *args, **kwargs)
def get_item_list(self):
item_list = [{
'product': Product.objects.get(pk=item['pk']),
'quantity': item['quantity']
} for item in self.request.session['subscription']['metadata']['products_and_quantities']]
return item_list
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
subscription = self.request.session['subscription']
metadata = subscription['metadata']
price = stripe.Price.retrieve(
subscription['items'][0].get('price'),
expand=['product']
)
shipping_address = self.request.session.get('shipping_address')
weight, unit = metadata['total_weight'].split(':')
total_weight = guess(float(weight), unit, measures=[Weight])
subtotal_price = (Decimal(price.unit_amount) * subscription['items'][0]['quantity']) / 100
shipping_cost = get_shipping_cost(
total_weight,
shipping_address['postal_code']
)
total_price = subtotal_price + shipping_cost
context['sub_cart'] = {
'items': self.get_item_list(),
'size': price.product.name,
'grind': metadata['grind'],
'schedule': f'Every {price.recurring.interval_count} / {price.recurring.interval}',
'subtotal_price': Money(subtotal_price, USD),
'shipping_cost': shipping_cost,
'total_price': total_price,
'total_weight': guess(float(weight), unit, measures=[Weight])
}
context['shipping_address'] = shipping_address
return context
def get_line_items(self):
line_items = self.object.items
recurring = stripe.Price.retrieve(
line_items[0].get('price')
).get('recurring')
shipping_cost = get_shipping_cost(
self.object.total_weight,
self.object.shipping_address.postal_code
) * 100
line_items.append({
'price_data': {
'currency': settings.DEFAULT_CURRENCY.lower(),
'unit_amount': int(shipping_cost),
'product_data': {
'name': 'Shipping'
},
'recurring': {
'interval': recurring.interval,
'interval_count': recurring.interval_count
}
},
'quantity': 1
})
return line_items
def get_success_url(self):
session = stripe.checkout.Session.create(
customer=self.object.customer.get_or_create_stripe_id(),
success_url='http://' + Site.objects.get_current().domain + reverse(
'storefront:subscription-done'
) + '?session_id={CHECKOUT_SESSION_ID}',
cancel_url='http://' + Site.objects.get_current().domain + reverse(
'storefront:subscription-create'),
mode='subscription',
line_items=self.get_line_items(),
subscription_data={'metadata': self.object.format_metadata()}
)
return session.url
def form_valid(self, form):
shipping_address = self.request.session.get('shipping_address')
subscription = self.request.session.get('subscription')
form.instance.customer, form.instance.shipping_address = get_or_create_customer(self.request, shipping_address)
weight, unit = subscription['metadata']['total_weight'].split(':')
form.instance.total_weight = guess(
float(weight), unit, measures=[Weight]
)
form.instance.items = subscription['items']
form.instance.metadata = subscription['metadata']
return super().form_valid(form)
class SubscriptionDoneView(TemplateView):
template_name = 'storefront/subscription/done.html'
# stripe listen --forward-to localhost:8000/stripe-webhook/
@csrf_exempt
@require_POST
def stripe_webhook(request):
# You can use webhooks to receive information about asynchronous payment events.
# For more about our webhook events check out https://stripe.com/docs/webhooks.
endpoint_secret = settings.STRIPE_WEBHOOK_SECRET
payload = request.body
sig_header = request.META['HTTP_STRIPE_SIGNATURE']
event = None
try:
event = stripe.Webhook.construct_event(
payload, sig_header, endpoint_secret
)
except ValueError as e:
# Invalid payload
logger.warning('Stripe Webhook: Invalid payload')
return JsonResponse({'status': 'ERROR: Invalid payload'})
except stripe.error.SignatureVerificationError as e:
# Invalid signature
logger.warning('Stripe Webhook: Invalid signature')
return JsonResponse({'status': 'ERROR: Invalid signature'})
if event.type == 'customer.subscription.created':
try:
subscription = Subscription.objects.get(
pk=event.data.object['metadata'].get('subscription_pk')
)
except Subscription.DoesNotExist:
logger.warning('Subscription does not exist')
raise
else:
subscription.stripe_id = event.data.object['id']
subscription.is_active = True
subscription.save()
if event.type == 'invoice.paid':
# Continue to provision the subscription as payments continue to be made.
# Store the status in your database and check when a user accesses your service.
# This approach helps you avoid hitting rate limits.
try:
subscription = Subscription.objects.get(
stripe_id=event.data.object['subscription']
)
except Subscription.DoesNotExist:
logger.warning('Subscription does not exist')
raise
else:
subscription.create_order(event.data.object)
return JsonResponse({'status': 'success'})