Source code for checkout.views

# -*- encoding: utf-8 -*-
import json
import logging

from braces.views import LoginRequiredMixin, StaffuserRequiredMixin
from datetime import date
from django.apps import apps
from django.conf import settings
from django.contrib import messages
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.http import Http404, HttpResponseRedirect
from django.urls import reverse
from django.utils import timezone
from django.views.generic import (
    CreateView,
    DetailView,
    ListView,
    UpdateView,
    TemplateView,
)

from base.url_utils import url_with_querystring
from base.view_utils import BaseMixin
from mail.service import queue_mail_template
from mail.tasks import process_mail
from .forms import (
    # CheckoutTokenForm,
    CustomerCheckoutForm,
    CustomerEmptyForm,
    CustomerPaymentForm,
    ObjectPaymentPlanEmptyForm,
    ObjectPaymentPlanInstalmentEmptyForm,
    PaymentPlanEmptyForm,
    PaymentPlanForm,
)
from .models import (
    as_pennies,
    Checkout,
    CheckoutAction,
    CheckoutAdditional,
    CheckoutError,
    CheckoutSettings,
    CURRENCY,
    Customer,
    CustomerPayment,
    ObjectPaymentPlan,
    ObjectPaymentPlanInstalment,
    PaymentPlan,
    PaymentRun,
    PaymentRunItem,
)


CONTENT_OBJECT_PK = "content_object_pk"
logger = logging.getLogger(__name__)


def _check_perm(request, content_object):
    """Check the session variable to make sure it was set."""
    checkout_actions = content_object.checkout_actions
    pk = request.session.get(CONTENT_OBJECT_PK, None)
    if pk:
        pk = int(pk)
        if pk == content_object.pk:
            pass
        else:
            logger.critical(
                "content object check: invalid: {} != {}".format(
                    pk, content_object.pk
                )
            )
            raise PermissionDenied("content check failed")
    elif checkout_actions == [CheckoutAction.CARD_REFRESH]:
        # allow card refresh without setting content object in the session
        pass
    else:
        logger.critical("content object check: invalid")
        raise PermissionDenied("content check failed")


def _check_perm_success(request, payment):
    """Check the permissions for the checkout success (thank you) page.

    We check the user (who might be anonymous) is not viewing an old
    transaction.

    """
    _check_perm(request, payment.content_object)
    td = timezone.now() - payment.created
    diff = td.days * 1440 + td.seconds / 60
    if abs(diff) > 10:
        raise CheckoutError(
            "Cannot view this checkout transaction.  It is too old "
            "(or has travelled in time, {} {} {}).".format(
                payment.created.strftime("%d/%m/%Y %H:%M"),
                timezone.now().strftime("%d/%m/%Y %H:%M"),
                abs(diff),
            )
        )


[docs]def payment_plan_example(total): checkout_settings = CheckoutSettings.objects.settings payment_plan = checkout_settings.default_payment_plan return payment_plan.example(date.today(), total)
[docs]class CheckoutAuditListView( LoginRequiredMixin, StaffuserRequiredMixin, BaseMixin, ListView ): paginate_by = 10
[docs] def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context.update(dict(audit=True)) return context
[docs] def get_queryset(self): return Checkout.objects.audit()
[docs]class CheckoutCardRefreshListView( LoginRequiredMixin, StaffuserRequiredMixin, BaseMixin, ListView ): """Customers with expiring cards.""" paginate_by = 10 template_name = "checkout/card_refresh_list.html"
[docs] def get_queryset(self): return Customer.objects.refresh
[docs]class CheckoutDashTemplateView( StaffuserRequiredMixin, LoginRequiredMixin, BaseMixin, TemplateView ): template_name = "checkout/dash.html"
[docs]class CheckoutListView( LoginRequiredMixin, StaffuserRequiredMixin, BaseMixin, ListView ): paginate_by = 10
[docs] def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context.update(dict(audit=False)) return context
[docs] def get_queryset(self): return Checkout.objects.success()
[docs]class CheckoutBaseMixin: """Some methods for use in creating a checkout .. note: if using checkout in a standalone view use CheckoutMixin """ def _add_cost_info_to_context(self, context): context.update({"email": self.object.checkout_email}) if CheckoutAction.PAYMENT_PLAN in self._checkout_actions(): context.update( {"example": payment_plan_example(self.object.checkout_total)} ) def _action_data(self): """the action data for the javascript on the page.""" actions = self._checkout_actions() result = {} for slug in actions: obj = CheckoutAction.objects.get(slug=slug) result[slug] = dict(name=obj.name, payment=obj.payment) return json.dumps(result) def _check_permissions(self): if not self.request.user.is_staff: _check_perm(self.request, self.object) if not self.object.checkout_can_charge: raise CheckoutError( "Cannot charge '{}': {}".format( self.object.__class__.__name__, self.object.pk ) ) def _checkout_actions(self): return self.object.checkout_actions def _form_valid_checkout(self, obj, form): """Process the payment. .. note: We check ``checkout_can_charge`` again here. It will already have been called in ``get_context_data``, but the user might have continued in another tab (or something): https://www.kbsoftware.co.uk/crm/ticket/2554/ """ token = form.cleaned_data["token"] slug = form.cleaned_data["action"] action = CheckoutAction.objects.get(slug=slug) checkout = None if not self.object.checkout_can_charge: raise CheckoutError( "Cannot charge '{}': {}".format(obj.__class__.__name__, obj.pk) ) try: with transaction.atomic(): checkout = Checkout.objects.create_checkout( action, obj, self.request.user ) # do stripe stuff outside of a transaction so we have some chance # of diagnosing progress if an exception is thrown. if not action.invoice: if not token: raise CheckoutError( "No checkout 'token' for: '{}'".format(obj) ) self._form_valid_stripe(checkout, token) with transaction.atomic(): if action.invoice or action.payment_plan: self._form_valid_invoice(checkout, form) checkout.success() checkout.notify(self.request) # obj.checkout_mail(action) url = obj.checkout_success_url(checkout.pk) transaction.on_commit(lambda: process_mail.delay()) except CheckoutError as e: logger.error(e) if checkout: with transaction.atomic(): checkout.fail() checkout.notify(self.request) url = obj.checkout_fail_url(checkout.pk) # PJK TODO remove temp # raise return url def _form_valid_invoice(self, checkout, form): """Use the forms's additional_data method to create the CheckoutAdditional record. """ data = form.additional_data() CheckoutAdditional.objects.create_checkout_additional(checkout, **data) def _form_valid_stripe(self, checkout, token): """Create a stripe customer object for a checkout. """ customer = Customer.objects.init_customer(self.object, token) checkout.customer = customer checkout.save() checkout.charge_user(self.request.user)
[docs] def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) self._check_permissions() allow_remember_me = not self.request.user.is_staff checkout_actions = self._checkout_actions() context.update( dict( action_data=self._action_data(), allow_pay_by_invoice=CheckoutAction.INVOICE in checkout_actions, allow_remember_me=allow_remember_me, currency=CURRENCY, key=settings.STRIPE_PUBLISH_KEY, name=settings.STRIPE_CAPTION, ) ) self._add_cost_info_to_context(context) return context
[docs] def get_form_kwargs(self): kwargs = super().get_form_kwargs() kwargs.update(dict(actions=self._checkout_actions())) return kwargs
[docs]class CheckoutMixin(CheckoutBaseMixin): """Checkout using a standalone view. Use with an ``UpdateView`` e.g:: class ShopCheckoutUpdateView(CheckoutMixin, BaseMixin, UpdateView): """
[docs] def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context.update( dict( description=self.object.checkout_description, total=as_pennies(self.object.checkout_total), # pennies ) ) return context
[docs] def form_valid(self, form): """Process the payment .. note: We do NOT update 'self.object' """ self.object = form.save(commit=False) url = self._form_valid_checkout(self.object, form) return HttpResponseRedirect(url)
[docs]class CheckoutSuccessMixin(object): """Thank you for your payment (etc). Use with a ``DetailView`` e.g:: class ShopCheckoutSuccessView( CheckoutSuccessMixin, BaseMixin, DetailView): """ model = Checkout
[docs] def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) _check_perm_success(self.request, self.object) if self.object.action == CheckoutAction.objects.payment_plan: context.update(example=payment_plan_example(self.object.total)) return context
[docs]class CustomerCardRefreshFormView( StaffuserRequiredMixin, LoginRequiredMixin, BaseMixin, UpdateView ): """Send an email to a user asking them to refresh their card details. The email will contain a link to the page on the site where the user can enter their card details. The URL is hard-coded to be ``web.contact.card.refresh``. """ form_class = CustomerEmptyForm model = Customer template_name = "checkout/customer_card_refresh_form.html" def _get_customer(self): pk = int(self.kwargs.get("pk")) return Customer.objects.get(pk=pk) def _queue_email(self): customer = self._get_customer() url = self.request.build_absolute_uri( reverse("web.contact.card.refresh") ) context = dict(name=customer.name, url=url) queue_mail_template( customer, Customer.MAIL_TEMPLATE_CARD_REFRESH_REQUEST, {customer.email: context}, ) transaction.on_commit(lambda: process_mail.delay()) messages.info( self.request, "email sent to {} asking them to update their card details".format( customer.email ), )
[docs] def form_valid(self, form): self._queue_email() return HttpResponseRedirect(self.get_success_url())
[docs] def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context.update(dict(customer=self._get_customer())) return context
[docs] def get_success_url(self): customer = self._get_customer() return url_with_querystring( reverse("checkout.customer"), email=customer.email )
[docs]class CustomerCheckoutRefreshUpdateView( StaffuserRequiredMixin, LoginRequiredMixin, CheckoutMixin, BaseMixin, UpdateView, ): form_class = CustomerCheckoutForm model = Customer template_name = "checkout/customer_checkout_refresh.html"
# def _email(self): # if self.request.method == 'GET': # email = self.request.GET.get('email') # else: # email = self.request.POST.get('next') # if not email: # raise Http404( # "Customer view needs an email address parameter." # ) # return email # def get_object(self, queryset=None): # customer = Customer.objects.get_customer(self._email()) # self.request.session[CONTENT_OBJECT_PK] = customer.pk # return customer
[docs]class CustomerChargeCreateView( LoginRequiredMixin, StaffuserRequiredMixin, BaseMixin, CreateView ): form_class = CustomerPaymentForm model = CustomerPayment def _customer(self): pk = int(self.kwargs.get("pk")) return Customer.objects.get(pk=pk)
[docs] def form_valid(self, form): try: self.object = form.save(commit=False) self.object.customer = self._customer() self.object.user = self.request.user self.object = form.save() Checkout.objects.charge(self.object, self.request.user) messages.info( self.request, "Charged '{}' a total of {} ref {}".format( self.object.customer.email, self.object.total, self.object.description, ), ) except CheckoutError as e: logger.exception(e) messages.error( self.request, ( "Error charging customer {}. Please check Stripe for " "more information.".format(self.object.customer.email) ), ) return HttpResponseRedirect(self.get_success_url())
[docs] def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context.update(dict(customer=self._customer())) return context
[docs] def get_success_url(self): return url_with_querystring( reverse("checkout.customer"), email=self.object.customer.email )
[docs]class CustomerTemplateView( StaffuserRequiredMixin, LoginRequiredMixin, BaseMixin, TemplateView ): template_name = "checkout/customer.html" def _contacts(self, email): contact_model = apps.get_model(settings.CONTACT_MODEL) return contact_model.objects.filter(user__email__iexact=email) def _customers(self, email): return Customer.objects.get_customers(email) def _email(self): email = self.request.GET.get("email") if not email: raise Http404("Customer view needs an email address parameter.") return email def _payment_plans(self, customers): emails = [o.email.lower() for o in customers] payment_plans = ( ObjectPaymentPlan.objects.all() .prefetch_related("content_object") .order_by("-pk") ) result = [] for obj in payment_plans: if obj.content_object.checkout_email.lower() in emails: result.append(obj) return result
[docs] def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) email = self._email() customers = self._customers(email) context.update( dict( audit=Checkout.objects.audit_customers(customers), contacts=self._contacts(email), customers=customers, email=email, payment_plans=self._payment_plans(customers), ) ) return context
[docs]class ObjectPaymentPlanDeleteView( LoginRequiredMixin, StaffuserRequiredMixin, BaseMixin, UpdateView ): form_class = ObjectPaymentPlanEmptyForm model = ObjectPaymentPlan template_name = "checkout/object_paymentplan_delete.html"
[docs] def form_valid(self, form): with transaction.atomic(): self.object = form.save(commit=False) self.object.deleted = True self.object = form.save() return HttpResponseRedirect(self.get_success_url())
[docs] def get_success_url(self): return reverse("checkout.object.payment.plan.list")
[docs]class ObjectPaymentPlanListView( LoginRequiredMixin, StaffuserRequiredMixin, BaseMixin, ListView ): model = ObjectPaymentPlan paginate_by = 10
[docs] def get_queryset(self): qs = ObjectPaymentPlan.objects.outstanding_payment_plans return qs.order_by("-pk")
[docs]class ObjectPaymentPlanCardFailListView( LoginRequiredMixin, StaffuserRequiredMixin, BaseMixin, ListView ): """""" paginate_by = 10
[docs] def get_queryset(self): return ObjectPaymentPlan.objects.fail_or_request
[docs]class ObjectPaymentPlanInstalmentAuditListView( LoginRequiredMixin, StaffuserRequiredMixin, BaseMixin, ListView ): paginate_by = 15
[docs] def get_queryset(self): return Checkout.objects.audit( ContentType.objects.get_for_model(ObjectPaymentPlanInstalment) )
[docs]class ObjectPaymentPlanDetailView( StaffuserRequiredMixin, LoginRequiredMixin, BaseMixin, DetailView ): model = ObjectPaymentPlan
[docs]class ObjectPaymentPlanInstalmentDetailView( StaffuserRequiredMixin, LoginRequiredMixin, BaseMixin, DetailView ): model = ObjectPaymentPlanInstalment
[docs]class ObjectPaymentPlanInstalmentChargeUpdateView( StaffuserRequiredMixin, LoginRequiredMixin, BaseMixin, UpdateView ): """Charge the customers card for this instalment.""" model = ObjectPaymentPlanInstalment form_class = ObjectPaymentPlanInstalmentEmptyForm template_name = "checkout/objectpaymentplaninstalment_charge_form.html"
[docs] def form_valid(self, form): self.object = form.save(commit=False) try: Checkout.objects.charge(self.object, self.request.user) messages.info( self.request, "Charged '{}' a total of {} ref {}".format( self.object.checkout_email, self.object.checkout_total, self.object.checkout_description, ), ) except CheckoutError as e: logger.exception(e) messages.error( self.request, ( "Error charging customer {}. Please check Stripe for " "more information.".format(self.object.checkout_email) ), ) return HttpResponseRedirect(self.get_success_url())
[docs] def get_success_url(self): return reverse( "checkout.object.payment.plan", args=[self.object.object_payment_plan.pk], )
[docs]class ObjectPaymentPlanInstalmentPaidUpdateView( StaffuserRequiredMixin, LoginRequiredMixin, BaseMixin, UpdateView ): """Mark this instalment as paid.""" model = ObjectPaymentPlanInstalment form_class = ObjectPaymentPlanInstalmentEmptyForm template_name = "checkout/objectpaymentplaninstalment_paid_form.html"
[docs] def form_valid(self, form): self.object = form.save(commit=False) Checkout.objects.manual(self.object, self.request.user) return HttpResponseRedirect(self.get_success_url())
[docs] def get_success_url(self): return reverse( "checkout.object.payment.plan", args=[self.object.object_payment_plan.pk], )
[docs]class PaymentPlanCreateView( LoginRequiredMixin, StaffuserRequiredMixin, BaseMixin, CreateView ): form_class = PaymentPlanForm model = PaymentPlan
[docs] def get_success_url(self): return reverse("checkout.payment.plan.list")
[docs]class PaymentPlanDeleteView( LoginRequiredMixin, StaffuserRequiredMixin, BaseMixin, UpdateView ): form_class = PaymentPlanEmptyForm model = PaymentPlan template_name = "checkout/paymentplan_delete.html"
[docs] def form_valid(self, form): with transaction.atomic(): self.object = form.save(commit=False) self.object.deleted = True self.object = form.save() return HttpResponseRedirect(self.get_success_url())
[docs] def get_success_url(self): return reverse("checkout.payment.plan.list")
[docs]class PaymentPlanListView( LoginRequiredMixin, StaffuserRequiredMixin, BaseMixin, ListView ): paginate_by = 5
[docs] def get_queryset(self): return PaymentPlan.objects.current()
[docs]class PaymentPlanUpdateView( LoginRequiredMixin, StaffuserRequiredMixin, BaseMixin, UpdateView ): form_class = PaymentPlanForm model = PaymentPlan
[docs] def get_success_url(self): return reverse("checkout.payment.plan.list")
[docs]class PaymentRunItemListView( LoginRequiredMixin, StaffuserRequiredMixin, BaseMixin, ListView ): paginate_by = 20 def _payment_run(self): pk = int(self.kwargs.get("pk")) return PaymentRun.objects.get(pk=pk)
[docs] def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context.update(dict(payment_run=self._payment_run())) return context
[docs] def get_queryset(self): return PaymentRunItem.objects.filter( payment_run=self._payment_run() ).order_by("created")
[docs]class PaymentRunListView( LoginRequiredMixin, StaffuserRequiredMixin, BaseMixin, ListView ): paginate_by = 20
[docs] def get_queryset(self): return PaymentRun.objects.all().order_by("-created")