kopia lustrzana https://github.com/longclawshop/longclaw
rodzic
c58c7f303e
commit
3de6f48124
|
@ -29,4 +29,5 @@ install:
|
|||
script: tox -e $TOX_ENV
|
||||
|
||||
after_success:
|
||||
- coverage xml
|
||||
- codecov -e TOX_ENV
|
||||
|
|
|
@ -5,6 +5,9 @@ Longclaw
|
|||
.. image:: https://badge.fury.io/py/longclaw.svg
|
||||
:target: https://badge.fury.io/py/longclaw
|
||||
|
||||
.. image:: https://codecov.io/gh/JamesRamm/longclaw/branch/master/graph/badge.svg
|
||||
:target: https://codecov.io/gh/JamesRamm/longclaw
|
||||
|
||||
.. image:: https://travis-ci.org/JamesRamm/longclaw.svg?branch=master
|
||||
:target: https://travis-ci.org/JamesRamm/longclaw
|
||||
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
codecov:
|
||||
token: c4e276fe-1d69-49e9-bbbe-fabaf4890222
|
|
@ -2,179 +2,40 @@
|
|||
|
||||
Checkout
|
||||
========
|
||||
The Longclaw checkout process consists of three steps:
|
||||
|
||||
- Capturing the payment
|
||||
- Collecting details about the customer - their email and shipping address.
|
||||
- Creating a new ``Order`` to store this information.
|
||||
Longclaw provides a simple, single checkout view.
|
||||
The URL for the checkout is ``'checkout/'``.
|
||||
After a successful checkout, it is redirected to ``checkout/success/``.
|
||||
|
||||
Longclaw currently provides a web API for the checkout process which captures this information in one step.
|
||||
It is left to the user to design the front end template and javascript. In the front end, you should:
|
||||
To implement the checkout, simply provide ``'longclawcheckout/checkout.html'`` and
|
||||
``'longclawcheckout/success.html'`` templates. (Empty templates will have been created if
|
||||
you ran the longclaw CLI to start your project)
|
||||
|
||||
- Collect the customer email
|
||||
- Collect the shipping and billing address
|
||||
- Payment capture by tokenizing the payment method (e.g. credit card) or payment itself
|
||||
- (Optionally) calculate the shipping costs
|
||||
- Submit all the information to the server in an AJAX POST request.
|
||||
There are three forms provided in the checkout view:
|
||||
|
||||
The first two are relatively simple to achieve. Longclaw provides some utilities to help with the rest.
|
||||
:checkout_form:
|
||||
Captures the email address and optionally the shipping option for the checkout.
|
||||
Also captures a boolean indicating whether a different billing address should be used
|
||||
|
||||
Payment Capture
|
||||
===============
|
||||
With Longclaw you can either tokenize the customers payment method (e.g. credit card) and
|
||||
send this to the server for the payment to be captured, or you can use a service such as paypal
|
||||
express checkout, which captures the payment directly and returns a token representing the transaction
|
||||
id. You would then submit this token to your server.
|
||||
:shipping_form:
|
||||
Captures shipping information.
|
||||
|
||||
The second option is often easiest to integrate since the user is redirected to the 3rd party site for payment.
|
||||
(This is increasingly done via a modal popup rather than a redirect, which makes the user experience smoother).
|
||||
The first option offers tightest integration with the look and feel of your site, but invariable involves more
|
||||
front end work and validation.
|
||||
|
||||
Tokenizing the Payment
|
||||
+++++++++++++++++++++++
|
||||
|
||||
To capture the payment with a 3rd party service, you will include some external javascript on your page
|
||||
and often designate a button or ``div`` to initialise the popup/redirect. You will also specify a submit
|
||||
handler to receive the token representing the transaction.
|
||||
|
||||
For example, the braintree javascript client allows express checkout using Paypal. Full details of how
|
||||
to setup are `here <https://developers.braintreepayments.com/guides/paypal/checkout-with-paypal/javascript/v3>`_.
|
||||
Other providers such as Stripe offer similar services.
|
||||
|
||||
Once you have received this token, you should submit it, along with the shipping address, billing address,
|
||||
email and shipping rate to the ``api/checkout/prepaid/`` endpoint.
|
||||
|
||||
|
||||
.. note:: The ``api/`` prefix can be configured in your django settings under ``API_URL_PREFIX``.
|
||||
For example, if you want to distinguish the longclaw API from your own, you could set ``API_URL_PREFIX="api/longclaw/"``
|
||||
The checkout url would then be ``api/longclaw/checkout/prepaid/``
|
||||
|
||||
The JSON request data would look like:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
transaction_id: "...",
|
||||
shipping_rate: 0.0,
|
||||
email: "john@smith.com",
|
||||
address: {
|
||||
shipping_name: "john smith",
|
||||
shipping_address_line_1": "...",
|
||||
shipping_address_city: "",
|
||||
shipping_address_zip: "",
|
||||
shipping_address_country: "",
|
||||
billing_name: "john smith",
|
||||
billing_address_line_1: "...",
|
||||
billing_address_city: "",
|
||||
billing_address_zip: "",
|
||||
billing_address_country: "",
|
||||
}
|
||||
}
|
||||
|
||||
transaction_id
|
||||
The token returned from e.g. paypal
|
||||
|
||||
When using this method, you do not need to define the ``PAYMENT_GATEWAY`` setting.
|
||||
|
||||
Tokenizing the Payment method
|
||||
+++++++++++++++++++++++++++++
|
||||
|
||||
Alternatively, you can pass the payment method for Longclaw to manually capture the payment.
|
||||
Longclaw expects the payment details (i.e. credit card) to be passed as some kind of token in
|
||||
a POST request to ``api/checkout/``.
|
||||
Longclaw will then use the payment gateway defined by the ``PAYMENT_GATEWAY`` setting to capture
|
||||
the payment.
|
||||
To create the initial token representing the customers payment information, you may be able to use
|
||||
the ``api/checkout/token/`` endpoint, passing the card information in the request data. This is dependent
|
||||
upon the backend and it may be preferable to use client javascript libraries provided by your payment
|
||||
gateway (e.g. ``stripe.js`` or ``braintree-web`` ) to generate a token.
|
||||
|
||||
Once the token is generated, the request data to send to ``api/checkout/`` is very similar to that for
|
||||
``api/checkout/prepaid/``:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
token: "...",
|
||||
shipping_rate: 0.0,
|
||||
email: "john@smith.com",
|
||||
address: {
|
||||
shipping_name: "john smith",
|
||||
shipping_address_line_1: "...",
|
||||
shipping_address_city: "",
|
||||
shipping_address_zip: "",
|
||||
shipping_address_country: "",
|
||||
billing_name: "john smith",
|
||||
billing_address_line_1: "...",
|
||||
billing_address_city: "",
|
||||
billing_address_zip: "",
|
||||
billing_address_country: "",
|
||||
}
|
||||
}
|
||||
|
||||
token
|
||||
The token for customer details. The key name is dependent on the backend ("token" for stripe, "payment_method_nonce" for braintree)
|
||||
|
||||
shipping_rate
|
||||
Number or string representation of a number (will be cast to float). The shipping costs
|
||||
|
||||
email
|
||||
The customers' email
|
||||
|
||||
.. note:: The ``"token"`` key is dependent upon the payment backend and may be named differently.
|
||||
|
||||
Both ``api/checkout/`` and ``api/checkout/prepaid/`` return a 201 response with ``order_id`` in the JSON data.
|
||||
If the payment fails, ``api/checkout/`` will return a 400 response with ``order_id`` and ``message`` in the JSON data.
|
||||
|
||||
Calculating Shipping Costs
|
||||
==========================
|
||||
|
||||
You will have noticed the need to send ``shipping_rate`` with the checkout. If you are using Longclaws' shipping
|
||||
settings, you can easily calculate the shipping cost either in python or by using the ``api/shipping/cost/`` endpoint.
|
||||
|
||||
Python example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from longclaw.longclawshipping import utils
|
||||
from longclaw.longclawsettings.models import LongclawSettings
|
||||
|
||||
country_code = "GB" # ISO 2-letter country code for a configured shipping rate
|
||||
option = "standard" # Name of shipping rate configured through longclaw admin (only used if more than one shipping rate exists for the given country)
|
||||
|
||||
settings = LongclawSettings.for_site(request.site)
|
||||
|
||||
try:
|
||||
data = utils.get_shipping_cost(country_code, option, settings)
|
||||
except InvalidShippingRate:
|
||||
# More than 1 shipping rate for the country exists,
|
||||
# but the supplied option doesnt match any
|
||||
pass
|
||||
except InvalidShippingCountry:
|
||||
# A shipping rate for this country does not exist and ``default_shipping_enabled``
|
||||
# is set to ``False`` in the longclaw admin settings
|
||||
|
||||
Javascript example:
|
||||
|
||||
.. code-block:: javascript
|
||||
|
||||
fetch(
|
||||
"api/shipping/cost/",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: 'application/json, application/json, application/coreapi+json',
|
||||
"Content-Type": 'application/json"
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
country_code: "GB",
|
||||
shipping_rate_name: "standard"
|
||||
})
|
||||
}
|
||||
).then(response => {...})
|
||||
:billing_form:
|
||||
A second address form for capturing alternate billing information. If you do not submit this form
|
||||
(e.g. by not rendering it on the template), the billing and shipping addresses are assumed to be the same.
|
||||
|
||||
Generally, you may need to use a little javascript to optionally render the form if the user selects
|
||||
'different billing address'.
|
||||
|
||||
Payment forms
|
||||
-------------
|
||||
|
||||
It is up to you to render a payment form and then pass the token in the POST data.
|
||||
Normally, the payment gateway chosen will have a javascript integration to render a form for you
|
||||
and tokenize the payment method (e.g. braintrees 'hosted fields')
|
||||
|
||||
Longclaws' payment gateways provide some helpful utilities to load client javascript and generate tokens.
|
||||
Loading ``longclawcheckout_tags`` in your template will allow you to retrieve the gateways' javascript libraries
|
||||
as script tags (``{% gateway_client_js %}`` and generate a client token (``{% gateway_token %}``).
|
||||
A little javascript is then required to setup your form and ask the gateway to tokenize the payment method for you.
|
||||
You should then add this token to the request POST data (e.g. with a hidden input field).
|
||||
|
|
|
@ -0,0 +1,175 @@
|
|||
.. checkout:
|
||||
|
||||
The Checkout API
|
||||
================
|
||||
The checkout API allows you to create more complex javascript-based checkout flows by providing some
|
||||
simple endpoints for capturing payments and orders.
|
||||
In the front end, you should:
|
||||
|
||||
- Collect the customer email
|
||||
- Collect the shipping and billing address
|
||||
- Payment capture by tokenizing the payment method (e.g. credit card) or payment itself
|
||||
- (Optionally) calculate the shipping costs
|
||||
- Submit all the information to the server in an AJAX POST request.
|
||||
|
||||
The first two are relatively simple to achieve. Longclaw provides some utilities to help with the rest.
|
||||
|
||||
Payment Capture
|
||||
===============
|
||||
With Longclaw you can either tokenize the customers payment method (e.g. credit card) and
|
||||
send this to the server for the payment to be captured, or you can use a service such as paypal
|
||||
express checkout, which captures the payment directly and returns a token representing the transaction
|
||||
id. You would then submit this token to your server.
|
||||
|
||||
The second option is often easiest to integrate since the user is redirected to the 3rd party site for payment.
|
||||
(This is increasingly done via a modal popup rather than a redirect, which makes the user experience smoother).
|
||||
The first option offers tightest integration with the look and feel of your site, but invariable involves more
|
||||
front end work and validation.
|
||||
|
||||
Tokenizing the Payment
|
||||
+++++++++++++++++++++++
|
||||
|
||||
To capture the payment with a 3rd party service, you will include some external javascript on your page
|
||||
and often designate a button or ``div`` to initialise the popup/redirect. You will also specify a submit
|
||||
handler to receive the token representing the transaction.
|
||||
|
||||
For example, the braintree javascript client allows express checkout using Paypal. Full details of how
|
||||
to setup are `here <https://developers.braintreepayments.com/guides/paypal/checkout-with-paypal/javascript/v3>`_.
|
||||
Other providers such as Stripe offer similar services.
|
||||
|
||||
Once you have received this token, you should submit it, along with the shipping address, billing address,
|
||||
email and shipping rate to the ``api/checkout/prepaid/`` endpoint.
|
||||
|
||||
|
||||
.. note:: The ``api/`` prefix can be configured in your django settings under ``API_URL_PREFIX``.
|
||||
For example, if you want to distinguish the longclaw API from your own, you could set ``API_URL_PREFIX="api/longclaw/"``
|
||||
The checkout url would then be ``api/longclaw/checkout/prepaid/``
|
||||
|
||||
The JSON request data would look like:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
transaction_id: "...",
|
||||
shipping_rate: 0.0,
|
||||
email: "john@smith.com",
|
||||
address: {
|
||||
shipping_name: "john smith",
|
||||
shipping_address_line_1": "...",
|
||||
shipping_address_city: "",
|
||||
shipping_address_zip: "",
|
||||
shipping_address_country: "",
|
||||
billing_name: "john smith",
|
||||
billing_address_line_1: "...",
|
||||
billing_address_city: "",
|
||||
billing_address_zip: "",
|
||||
billing_address_country: "",
|
||||
}
|
||||
}
|
||||
|
||||
transaction_id
|
||||
The token returned from e.g. paypal
|
||||
|
||||
When using this method, you do not need to define the ``PAYMENT_GATEWAY`` setting.
|
||||
|
||||
Tokenizing the Payment method
|
||||
+++++++++++++++++++++++++++++
|
||||
|
||||
Alternatively, you can pass the payment method for Longclaw to manually capture the payment.
|
||||
Longclaw expects the payment details (i.e. credit card) to be passed as some kind of token in
|
||||
a POST request to ``api/checkout/``.
|
||||
Longclaw will then use the payment gateway defined by the ``PAYMENT_GATEWAY`` setting to capture
|
||||
the payment.
|
||||
To create the initial token representing the customers payment information, you may be able to use
|
||||
the ``api/checkout/token/`` endpoint, passing the card information in the request data. This is dependent
|
||||
upon the backend and it may be preferable to use client javascript libraries provided by your payment
|
||||
gateway (e.g. ``stripe.js`` or ``braintree-web`` ) to generate a token.
|
||||
|
||||
Once the token is generated, the request data to send to ``api/checkout/`` is very similar to that for
|
||||
``api/checkout/prepaid/``:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
token: "...",
|
||||
shipping_rate: 0.0,
|
||||
email: "john@smith.com",
|
||||
address: {
|
||||
shipping_name: "john smith",
|
||||
shipping_address_line_1: "...",
|
||||
shipping_address_city: "",
|
||||
shipping_address_zip: "",
|
||||
shipping_address_country: "",
|
||||
billing_name: "john smith",
|
||||
billing_address_line_1: "...",
|
||||
billing_address_city: "",
|
||||
billing_address_zip: "",
|
||||
billing_address_country: "",
|
||||
}
|
||||
}
|
||||
|
||||
token
|
||||
The token for customer details. The key name is dependent on the backend ("token" for stripe, "payment_method_nonce" for braintree)
|
||||
|
||||
shipping_rate
|
||||
Number or string representation of a number (will be cast to float). The shipping costs
|
||||
|
||||
email
|
||||
The customers' email
|
||||
|
||||
.. note:: The ``"token"`` key is dependent upon the payment backend and may be named differently.
|
||||
|
||||
Both ``api/checkout/`` and ``api/checkout/prepaid/`` return a 201 response with ``order_id`` in the JSON data.
|
||||
If the payment fails, ``api/checkout/`` will return a 400 response with ``order_id`` and ``message`` in the JSON data.
|
||||
|
||||
Calculating Shipping Costs
|
||||
==========================
|
||||
|
||||
You will have noticed the need to send ``shipping_rate`` with the checkout. If you are using Longclaws' shipping
|
||||
settings, you can easily calculate the shipping cost either in python or by using the ``api/shipping/cost/`` endpoint.
|
||||
|
||||
Python example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from longclaw.longclawshipping import utils
|
||||
from longclaw.longclawsettings.models import LongclawSettings
|
||||
|
||||
country_code = "GB" # ISO 2-letter country code for a configured shipping rate
|
||||
option = "standard" # Name of shipping rate configured through longclaw admin (only used if more than one shipping rate exists for the given country)
|
||||
|
||||
settings = LongclawSettings.for_site(request.site)
|
||||
|
||||
try:
|
||||
data = utils.get_shipping_cost(country_code, option, settings)
|
||||
except InvalidShippingRate:
|
||||
# More than 1 shipping rate for the country exists,
|
||||
# but the supplied option doesnt match any
|
||||
pass
|
||||
except InvalidShippingCountry:
|
||||
# A shipping rate for this country does not exist and ``default_shipping_enabled``
|
||||
# is set to ``False`` in the longclaw admin settings
|
||||
|
||||
Javascript example:
|
||||
|
||||
.. code-block:: javascript
|
||||
|
||||
fetch(
|
||||
"api/shipping/cost/",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: 'application/json, application/json, application/coreapi+json',
|
||||
"Content-Type": 'application/json"
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
country_code: "GB",
|
||||
shipping_rate_name: "standard"
|
||||
})
|
||||
}
|
||||
).then(response => {...})
|
||||
|
||||
|
||||
|
||||
|
|
@ -10,6 +10,7 @@ Usage Guide
|
|||
products
|
||||
basket
|
||||
checkout
|
||||
checkout_api
|
||||
shipping
|
||||
orders
|
||||
payments
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
from django import template
|
||||
from longclaw.longclawbasket.utils import get_basket_items
|
||||
|
||||
register = template.Library()
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def basket(context):
|
||||
'''
|
||||
Return the BasketItems in the current basket
|
||||
'''
|
||||
items, _ = get_basket_items(context["request"])
|
||||
return items
|
|
@ -2,16 +2,13 @@
|
|||
Shipping logic and payment capture API
|
||||
'''
|
||||
from django.utils import timezone
|
||||
from django.utils.module_loading import import_string
|
||||
from django.db import transaction
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework import permissions, status
|
||||
from rest_framework.response import Response
|
||||
from longclaw.longclawbasket.utils import get_basket_items, destroy_basket
|
||||
from longclaw.longclawcheckout.utils import PaymentError, create_order
|
||||
from longclaw import settings
|
||||
|
||||
gateway = import_string(settings.PAYMENT_GATEWAY)()
|
||||
from longclaw.longclawbasket.utils import destroy_basket
|
||||
from longclaw.longclawcheckout.utils import create_order, GATEWAY
|
||||
from longclaw.longclawcheckout.errors import PaymentError
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([permissions.AllowAny])
|
||||
|
@ -20,7 +17,7 @@ def create_token(request):
|
|||
payment backend. Some payment backends (e.g. braintree) support creating a payment
|
||||
token, which should be imported from the backend as 'get_token'
|
||||
'''
|
||||
token = gateway.get_token(request)
|
||||
token = GATEWAY.get_token(request)
|
||||
return Response({'token': token}, status=status.HTTP_200_OK)
|
||||
|
||||
@transaction.atomic
|
||||
|
@ -36,23 +33,19 @@ def create_order_with_token(request):
|
|||
# Get the request data
|
||||
try:
|
||||
address = request.data['address']
|
||||
postage = float(request.data['shipping_rate'])
|
||||
shipping_option = request.data.get('shipping_option', None)
|
||||
email = request.data['email']
|
||||
transaction_id = request.data['transaction_id']
|
||||
except KeyError:
|
||||
return Response(data={"message": "Missing parameters from request data"},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Get the contents of the basket
|
||||
items, _ = get_basket_items(request)
|
||||
# Create the order
|
||||
ip_address = request.data.get('ip', '0.0.0.0')
|
||||
order = create_order(
|
||||
items,
|
||||
address,
|
||||
email,
|
||||
postage,
|
||||
ip_address
|
||||
request,
|
||||
addresses=address,
|
||||
shipping_option=shipping_option
|
||||
)
|
||||
|
||||
order.payment_date = timezone.now()
|
||||
|
@ -85,41 +78,22 @@ def capture_payment(request):
|
|||
billing_address_country
|
||||
|
||||
'email': Email address of the customer
|
||||
'ip': IP address of the customer
|
||||
'shipping': The shipping rate (in the sites' currency)
|
||||
'''
|
||||
|
||||
# Get the contents of the basket
|
||||
items, _ = get_basket_items(request)
|
||||
|
||||
# Compute basket total
|
||||
total = 0
|
||||
for item in items:
|
||||
total += item.total()
|
||||
|
||||
# Create the order
|
||||
# get request data
|
||||
address = request.data['address']
|
||||
postage = float(request.data['shipping_rate'])
|
||||
email = request.data['email']
|
||||
ip_address = request.data.get('ip', '0.0.0.0')
|
||||
order = create_order(
|
||||
items,
|
||||
address,
|
||||
email,
|
||||
postage,
|
||||
ip_address
|
||||
)
|
||||
email = request.data.get('email', None)
|
||||
shipping_option = request.data.get('shipping_option', None)
|
||||
|
||||
# Capture the payment
|
||||
try:
|
||||
desc = 'Payment from {} for order id #{}'.format(request.data['email'], order.id)
|
||||
transaction_id = gateway.create_payment(request,
|
||||
float(total) + postage,
|
||||
description=desc)
|
||||
order.payment_date = timezone.now()
|
||||
order.transaction_id = transaction_id
|
||||
# Once the order has been successfully taken, we can empty the basket
|
||||
destroy_basket(request)
|
||||
order = create_order(
|
||||
email,
|
||||
request,
|
||||
addresses=address,
|
||||
shipping_option=shipping_option,
|
||||
capture_payment=True
|
||||
)
|
||||
response = Response(data={"order_id": order.id},
|
||||
status=status.HTTP_201_CREATED)
|
||||
except PaymentError as err:
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
|
||||
class PaymentError(Exception):
|
||||
def __init__(self, message):
|
||||
self.message = str(message)
|
|
@ -0,0 +1,9 @@
|
|||
from django import forms
|
||||
|
||||
class CheckoutForm(forms.Form):
|
||||
'''
|
||||
Captures extra info required for checkout
|
||||
'''
|
||||
email = forms.EmailField()
|
||||
shipping_option = forms.ChoiceField(required=False)
|
||||
different_billing_address = forms.BooleanField(required=False)
|
|
@ -1,6 +1,6 @@
|
|||
from longclaw.longclawcheckout.utils import PaymentError
|
||||
from longclaw.longclawcheckout.errors import PaymentError
|
||||
|
||||
class BasePayment():
|
||||
class BasePayment(object):
|
||||
'''
|
||||
Provides the interface for payment backends and
|
||||
can function as a dummy backend for testing.
|
||||
|
@ -13,20 +13,28 @@ class BasePayment():
|
|||
Can be used for testing - to simulate a failed payment/error,
|
||||
pass `error: true` in the request data.
|
||||
'''
|
||||
err = request.data.get("error", False)
|
||||
err = request.POST.get("error", False)
|
||||
if err:
|
||||
raise PaymentError("Dummy error requested")
|
||||
|
||||
return 'fake_transaction_id'
|
||||
|
||||
def get_token(self, request):
|
||||
def get_token(self, request=None):
|
||||
'''
|
||||
Dummy function for generating a client token through
|
||||
a payment gateway. Most (all?) gateways have a flow which
|
||||
involves requesting a token from the server (usually to
|
||||
tokenize the payment method) and then passing that token
|
||||
to another api endpoint to create the payment.
|
||||
involves requesting a token from the server to initialise
|
||||
a client.
|
||||
|
||||
This function should be overriden in child classes
|
||||
'''
|
||||
return 'dummy_token'
|
||||
|
||||
def client_js(self):
|
||||
'''
|
||||
Return any client javascript library paths required
|
||||
by the payment integration.
|
||||
Should return an iterable of JS paths which can
|
||||
be used in <script> tags
|
||||
'''
|
||||
return ('http://dummy.js', 'dummy.js')
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import braintree
|
||||
from longclaw import settings
|
||||
from longclaw.longclawsettings.models import LongclawSettings
|
||||
from longclaw.longclawcheckout.utils import PaymentError
|
||||
from longclaw.longclawcheckout.errors import PaymentError
|
||||
from longclaw.longclawcheckout.gateways import BasePayment
|
||||
|
||||
class BraintreePayment(BasePayment):
|
||||
|
@ -9,13 +9,17 @@ class BraintreePayment(BasePayment):
|
|||
Create a payment using Braintree
|
||||
'''
|
||||
def __init__(self):
|
||||
braintree.Configuration.configure(braintree.Environment.Sandbox,
|
||||
if settings.BRAINTREE_SANDBOX:
|
||||
env = braintree.Environment.Sandbox
|
||||
else:
|
||||
env = braintree.Environment.Production
|
||||
braintree.Configuration.configure(env,
|
||||
merchant_id=settings.BRAINTREE_MERCHANT_ID,
|
||||
public_key=settings.BRAINTREE_PUBLIC_KEY,
|
||||
private_key=settings.BRAINTREE_PRIVATE_KEY)
|
||||
|
||||
def create_payment(self, request, amount):
|
||||
nonce = request.data['payment_method_nonce']
|
||||
def create_payment(self, request, amount, description=''):
|
||||
nonce = request.POST.get('payment_method_nonce')
|
||||
result = braintree.Transaction.sale({
|
||||
"amount": str(amount),
|
||||
"payment_method_nonce": nonce,
|
||||
|
@ -27,9 +31,15 @@ class BraintreePayment(BasePayment):
|
|||
raise PaymentError(result)
|
||||
return result.transaction.id
|
||||
|
||||
def get_token(self, request):
|
||||
# Generate client token for the dropin ui
|
||||
return braintree.ClientToken.generate({})
|
||||
def get_token(self, request=None):
|
||||
# Generate client token
|
||||
return braintree.ClientToken.generate()
|
||||
|
||||
def client_js(self):
|
||||
return (
|
||||
"https://js.braintreegateway.com/web/3.15.0/js/client.min.js",
|
||||
"https://js.braintreegateway.com/web/3.15.0/js/hosted-fields.min.js"
|
||||
)
|
||||
|
||||
class PaypalVZeroPayment(BasePayment):
|
||||
'''
|
||||
|
@ -40,7 +50,7 @@ class PaypalVZeroPayment(BasePayment):
|
|||
|
||||
def create_payment(self, request, amount, description=''):
|
||||
longclaw_settings = LongclawSettings.for_site(request.site)
|
||||
nonce = request.data['payment_method_nonce']
|
||||
nonce = request.POST.get('payment_method_nonce')
|
||||
result = self.gateway.transaction.sale({
|
||||
"amount": str(amount),
|
||||
"payment_method_nonce": nonce,
|
||||
|
@ -57,3 +67,10 @@ class PaypalVZeroPayment(BasePayment):
|
|||
|
||||
def get_token(self, request):
|
||||
return self.gateway.client_token.generate()
|
||||
|
||||
def client_js(self):
|
||||
return (
|
||||
"https://www.paypalobjects.com/api/checkout.js",
|
||||
"https://js.braintreegateway.com/web/3.15.0/js/client.min.js",
|
||||
"https://js.braintreegateway.com/web/3.15.0/js/paypal-checkout.min.js"
|
||||
)
|
||||
|
|
|
@ -2,7 +2,7 @@ import math
|
|||
import stripe
|
||||
from longclaw.settings import STRIPE_SECRET
|
||||
from longclaw.longclawsettings.models import LongclawSettings
|
||||
from longclaw.longclawcheckout.utils import PaymentError
|
||||
from longclaw.longclawcheckout.errors import PaymentError
|
||||
from longclaw.longclawcheckout.gateways import BasePayment
|
||||
|
||||
|
||||
|
@ -13,14 +13,14 @@ class StripePayment(BasePayment):
|
|||
def __init__(self):
|
||||
stripe.api_key = STRIPE_SECRET
|
||||
|
||||
def create_payment(self, request, amount):
|
||||
def create_payment(self, request, amount, description=''):
|
||||
try:
|
||||
currency = LongclawSettings.for_site(request.site).currency
|
||||
charge = stripe.Charge.create(
|
||||
amount=int(math.ceil(amount * 100)), # Amount in pence
|
||||
currency=currency.lower(),
|
||||
source=request.data['token'],
|
||||
description="Payment from"
|
||||
description=description
|
||||
)
|
||||
return charge.id
|
||||
except stripe.error.CardError as error:
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
from django import template
|
||||
from longclaw.longclawcheckout.utils import GATEWAY
|
||||
|
||||
register = template.Library()
|
||||
|
||||
@register.simple_tag
|
||||
def gateway_client_js():
|
||||
'''
|
||||
Template tag which provides a `script` tag for each javascript item
|
||||
required by the payment gateway
|
||||
'''
|
||||
javascripts = GATEWAY.client_js()
|
||||
if isinstance(javascripts, (tuple, list)):
|
||||
tags = []
|
||||
for js in javascripts:
|
||||
tags.append('<script type="text/javascript" src="{}"></script>'.format(js))
|
||||
return tags
|
||||
else:
|
||||
raise TypeError(
|
||||
'function client_js of {} must return a list or tuple'.format(GATEWAY.__name__))
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def gateway_token():
|
||||
'''
|
||||
Provide a client token from the chosen gateway
|
||||
'''
|
||||
return GATEWAY.get_token()
|
|
@ -1,12 +1,22 @@
|
|||
from django.test import TestCase
|
||||
from django.test.client import RequestFactory
|
||||
from longclaw.tests.utils import LongclawTestCase, BasketItemFactory
|
||||
from django.contrib.sites.models import Site
|
||||
try:
|
||||
from django.urls import reverse
|
||||
except ImportError:
|
||||
from django.core.urlresolvers import reverse
|
||||
|
||||
from longclaw.tests.utils import LongclawTestCase, AddressFactory, BasketItemFactory, CountryFactory, OrderFactory
|
||||
from longclaw.longclawcheckout.utils import create_order
|
||||
from longclaw.longclawcheckout.forms import CheckoutForm
|
||||
from longclaw.longclawcheckout.views import CheckoutView
|
||||
from longclaw.longclawbasket.utils import basket_id
|
||||
|
||||
|
||||
class CheckoutTest(LongclawTestCase):
|
||||
class CheckoutApiTest(LongclawTestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
self.addresses = {
|
||||
'shipping_name': '',
|
||||
'shipping_address_line1': '',
|
||||
|
@ -20,16 +30,14 @@ class CheckoutTest(LongclawTestCase):
|
|||
'billing_address_country': ''
|
||||
}
|
||||
self.email = "test@test.com"
|
||||
request = RequestFactory().get('/')
|
||||
request.session = {}
|
||||
self.basket_id = basket_id(request)
|
||||
self.request = RequestFactory().get('/')
|
||||
self.request.session = {}
|
||||
self.basket_id = basket_id(self.request)
|
||||
|
||||
def test_create_order(self):
|
||||
shipping_rate = 0
|
||||
basket_items = [BasketItemFactory(basket_id=self.basket_id),
|
||||
BasketItemFactory(basket_id=self.basket_id)]
|
||||
order = create_order(basket_items, self.addresses,
|
||||
self.email, shipping_rate)
|
||||
BasketItemFactory(basket_id=self.basket_id),
|
||||
BasketItemFactory(basket_id=self.basket_id)
|
||||
order = create_order(self.email, self.request, self.addresses)
|
||||
self.assertIsNotNone(order)
|
||||
self.assertEqual(self.email, order.email)
|
||||
self.assertEqual(order.items.count(), 2)
|
||||
|
@ -42,8 +50,7 @@ class CheckoutTest(LongclawTestCase):
|
|||
BasketItemFactory(basket_id=self.basket_id)
|
||||
data = {
|
||||
'address': self.addresses,
|
||||
'email': self.email,
|
||||
'shipping_rate': 0
|
||||
'email': self.email
|
||||
}
|
||||
self.post_test(data, 'longclaw_checkout', format='json')
|
||||
|
||||
|
@ -55,7 +62,6 @@ class CheckoutTest(LongclawTestCase):
|
|||
data = {
|
||||
'address': self.addresses,
|
||||
'email': self.email,
|
||||
'shipping_rate': 3.95,
|
||||
'transaction_id': 'blahblah'
|
||||
}
|
||||
self.post_test(data, 'longclaw_checkout_prepaid', format='json')
|
||||
|
@ -65,3 +71,111 @@ class CheckoutTest(LongclawTestCase):
|
|||
Test api endpoint checkout/token/
|
||||
"""
|
||||
self.get_test('longclaw_checkout_token')
|
||||
|
||||
|
||||
class CheckoutTest(TestCase):
|
||||
|
||||
def test_checkout_form(self):
|
||||
'''
|
||||
Test we can create the form without a shipping option
|
||||
'''
|
||||
data = {
|
||||
'email': 'test@test.com',
|
||||
'different_billing_address': False
|
||||
}
|
||||
form = CheckoutForm(data=data)
|
||||
self.assertTrue(form.is_valid(), form.errors.as_json())
|
||||
|
||||
def test_invalid_checkout_form(self):
|
||||
'''
|
||||
Test making an invalid form
|
||||
'''
|
||||
form = CheckoutForm({
|
||||
'email': ''
|
||||
})
|
||||
self.assertFalse(form.is_valid())
|
||||
|
||||
def test_get_checkout(self):
|
||||
'''
|
||||
Test the checkout GET view
|
||||
'''
|
||||
request = RequestFactory().get(reverse('longclaw_checkout_view'))
|
||||
response = CheckoutView.as_view()(request)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_post_checkout(self):
|
||||
'''
|
||||
Test correctly posting to the checkout view
|
||||
'''
|
||||
country = CountryFactory()
|
||||
request = RequestFactory().post(
|
||||
reverse('longclaw_checkout_view'),
|
||||
{
|
||||
'shipping-name': 'bob',
|
||||
'shipping-line_1': 'blah blah',
|
||||
'shipping-postcode': 'ytxx 23x',
|
||||
'shipping-city': 'London',
|
||||
'shipping-country': country.pk,
|
||||
'email': 'test@test.com'
|
||||
}
|
||||
)
|
||||
request.session = {}
|
||||
bid = basket_id(request)
|
||||
BasketItemFactory(basket_id=bid)
|
||||
response = CheckoutView.as_view()(request)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_post_checkout_billing(self):
|
||||
'''
|
||||
Test using an alternate shipping
|
||||
address in the checkout view
|
||||
'''
|
||||
country = CountryFactory()
|
||||
request = RequestFactory().post(
|
||||
reverse('longclaw_checkout_view'),
|
||||
{
|
||||
'shipping-name': 'bob',
|
||||
'shipping-line_1': 'blah blah',
|
||||
'shipping-postcode': 'ytxx 23x',
|
||||
'shipping-city': 'London',
|
||||
'shipping-country': country.pk,
|
||||
'billing-name': 'john',
|
||||
'billing-line_1': 'somewhere',
|
||||
'billing-postcode': 'lmewrewr',
|
||||
'billing-city': 'London',
|
||||
'billing-country': country.pk,
|
||||
'email': 'test@test.com',
|
||||
'different_billing_address': True
|
||||
}
|
||||
)
|
||||
request.session = {}
|
||||
bid = basket_id(request)
|
||||
BasketItemFactory(basket_id=bid)
|
||||
response = CheckoutView.as_view()(request)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_post_checkout_invalid(self):
|
||||
'''
|
||||
Test posting an invalid form.
|
||||
This should return a 200 response - rerendering
|
||||
the form page with the errors
|
||||
'''
|
||||
request = RequestFactory().post(
|
||||
reverse('longclaw_checkout_view')
|
||||
)
|
||||
request.session = {}
|
||||
bid = basket_id(request)
|
||||
BasketItemFactory(basket_id=bid)
|
||||
response = CheckoutView.as_view()(request)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_checkout_success(self):
|
||||
'''
|
||||
Test the checkout success view
|
||||
'''
|
||||
address = AddressFactory()
|
||||
order = OrderFactory(shipping_address=address, billing_address=address)
|
||||
response = self.client.get(reverse('longclaw_checkout_success', kwargs={'pk': order.id}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
from django.conf.urls import url
|
||||
from longclaw.longclawcheckout import api
|
||||
from longclaw.longclawcheckout import api, views
|
||||
from longclaw.settings import API_URL_PREFIX
|
||||
|
||||
urlpatterns = [
|
||||
|
@ -11,5 +11,11 @@ urlpatterns = [
|
|||
name='longclaw_checkout_prepaid'),
|
||||
url(API_URL_PREFIX + r'checkout/token/$',
|
||||
api.create_token,
|
||||
name='longclaw_checkout_token')
|
||||
]
|
||||
name='longclaw_checkout_token'),
|
||||
url(r'checkout/$',
|
||||
views.CheckoutView.as_view(),
|
||||
name='longclaw_checkout_view'),
|
||||
url(r'checkout/success/(?P<pk>[0-9]+)/$',
|
||||
views.checkout_success,
|
||||
name='longclaw_checkout_success')
|
||||
]
|
||||
|
|
|
@ -1,41 +1,76 @@
|
|||
from django.utils.module_loading import import_string
|
||||
from django.utils import timezone
|
||||
from ipware.ip import get_real_ip
|
||||
|
||||
from longclaw.longclawbasket.utils import get_basket_items, destroy_basket
|
||||
from longclaw.longclawshipping.utils import get_shipping_cost
|
||||
|
||||
from longclaw.longclaworders.models import Order, OrderItem
|
||||
from longclaw.longclawshipping.models import Address
|
||||
from longclaw.longclawsettings.models import LongclawSettings
|
||||
from longclaw import settings
|
||||
|
||||
class PaymentError(Exception):
|
||||
def __init__(self, message):
|
||||
self.message = str(message)
|
||||
GATEWAY = import_string(settings.PAYMENT_GATEWAY)()
|
||||
|
||||
def create_order(basket_items,
|
||||
addresses,
|
||||
email,
|
||||
shipping_rate,
|
||||
ip_address='0.0.0.0'):
|
||||
def create_order(email,
|
||||
request,
|
||||
addresses=None,
|
||||
shipping_address=None,
|
||||
billing_address=None,
|
||||
shipping_option=None,
|
||||
capture_payment=False):
|
||||
'''
|
||||
Create an order from a basket and customer infomation
|
||||
'''
|
||||
if isinstance(addresses, dict):
|
||||
shipping_address, _ = Address.objects.get_or_create(name=addresses['shipping_name'],
|
||||
basket_items, _ = get_basket_items(request)
|
||||
if addresses:
|
||||
# Longclaw < 0.2 used 'shipping_name', longclaw > 0.2 uses a consistent
|
||||
# prefix (shipping_address_xxxx)
|
||||
try:
|
||||
shipping_name = addresses['shipping_name']
|
||||
except KeyError:
|
||||
shipping_name = addresses['shipping_address_name']
|
||||
|
||||
shipping_country = addresses['shipping_address_country']
|
||||
if not shipping_country:
|
||||
shipping_country = None
|
||||
shipping_address, _ = Address.objects.get_or_create(name=shipping_name,
|
||||
line_1=addresses[
|
||||
'shipping_address_line1'],
|
||||
city=addresses[
|
||||
'shipping_address_city'],
|
||||
postcode=addresses[
|
||||
'shipping_address_zip'],
|
||||
country=addresses[
|
||||
'shipping_address_country'])
|
||||
country=shipping_country)
|
||||
shipping_address.save()
|
||||
billing_address, _ = Address.objects.get_or_create(name=addresses['billing_name'],
|
||||
try:
|
||||
billing_name = addresses['billing_name']
|
||||
except KeyError:
|
||||
billing_name = addresses['billing_address_name']
|
||||
billing_country = addresses['shipping_address_country']
|
||||
if not billing_country:
|
||||
billing_country = None
|
||||
billing_address, _ = Address.objects.get_or_create(name=billing_name,
|
||||
line_1=addresses[
|
||||
'billing_address_line1'],
|
||||
city=addresses[
|
||||
'billing_address_city'],
|
||||
postcode=addresses[
|
||||
'billing_address_zip'],
|
||||
country=addresses[
|
||||
'billing_address_country'])
|
||||
country=billing_country)
|
||||
billing_address.save()
|
||||
else:
|
||||
shipping_country = shipping_address.country
|
||||
|
||||
|
||||
ip_address = get_real_ip(request)
|
||||
if shipping_country and shipping_option:
|
||||
site_settings = LongclawSettings.for_site(request.site)
|
||||
shipping_rate = get_shipping_cost(
|
||||
shipping_address.country.pk,
|
||||
shipping_option,
|
||||
site_settings)
|
||||
else:
|
||||
shipping_rate = 0
|
||||
order = Order(
|
||||
email=email,
|
||||
ip_address=ip_address,
|
||||
|
@ -44,8 +79,11 @@ def create_order(basket_items,
|
|||
shipping_rate=shipping_rate
|
||||
)
|
||||
order.save()
|
||||
# Create the order items
|
||||
|
||||
# Create the order items & compute total
|
||||
total = 0
|
||||
for item in basket_items:
|
||||
total += item.total()
|
||||
order_item = OrderItem(
|
||||
product=item.variant,
|
||||
quantity=item.quantity,
|
||||
|
@ -53,4 +91,13 @@ def create_order(basket_items,
|
|||
)
|
||||
order_item.save()
|
||||
|
||||
if capture_payment:
|
||||
desc = 'Payment from {} for order id #{}'.format(email, order.id)
|
||||
transaction_id = GATEWAY.create_payment(request,
|
||||
float(total) + shipping_rate,
|
||||
description=desc)
|
||||
order.payment_date = timezone.now()
|
||||
order.transaction_id = transaction_id
|
||||
# Once the order has been successfully taken, we can empty the basket
|
||||
destroy_basket(request)
|
||||
return order
|
||||
|
|
|
@ -1,3 +1,80 @@
|
|||
from django.shortcuts import render
|
||||
from django.shortcuts import render, get_object_or_404
|
||||
from django.views.generic import TemplateView
|
||||
from django.views.decorators.http import require_GET
|
||||
from django.http import HttpResponseRedirect
|
||||
|
||||
# Create your views here.
|
||||
try:
|
||||
from django.urls import reverse
|
||||
except ImportError:
|
||||
from django.core.urlresolvers import reverse
|
||||
|
||||
from longclaw.longclawshipping.forms import AddressForm
|
||||
from longclaw.longclawcheckout.forms import CheckoutForm
|
||||
from longclaw.longclawcheckout.utils import create_order
|
||||
from longclaw.longclawbasket.utils import get_basket_items
|
||||
from longclaw.longclaworders.models import Order
|
||||
|
||||
|
||||
@require_GET
|
||||
def checkout_success(request, pk):
|
||||
order = get_object_or_404(Order, id=pk)
|
||||
return render(request, "longclawcheckout/success.html", {'order': order})
|
||||
|
||||
|
||||
class CheckoutView(TemplateView):
|
||||
template_name = "longclawcheckout/checkout.html"
|
||||
checkout_form = CheckoutForm
|
||||
shipping_address_form = AddressForm
|
||||
billing_address_form = AddressForm
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super(CheckoutView, self).get_context_data(**kwargs)
|
||||
items, _ = get_basket_items(self.request)
|
||||
total_price = sum(item.total() for item in items)
|
||||
site = getattr(self.request, 'site', None)
|
||||
context['checkout_form'] = self.checkout_form(
|
||||
self.request.POST or None)
|
||||
context['shipping_form'] = self.shipping_address_form(
|
||||
self.request.POST or None,
|
||||
prefix='shipping',
|
||||
site=site)
|
||||
context['billing_form'] = self.billing_address_form(
|
||||
self.request.POST or None,
|
||||
prefix='billing',
|
||||
site=site)
|
||||
context['basket'] = items
|
||||
context['total_price'] = total_price
|
||||
return context
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
context = self.get_context_data(**kwargs)
|
||||
checkout_form = context['checkout_form']
|
||||
shipping_form = context['shipping_form']
|
||||
all_ok = checkout_form.is_valid() and shipping_form.is_valid()
|
||||
if all_ok:
|
||||
email = checkout_form.cleaned_data['email']
|
||||
shipping_option = checkout_form.cleaned_data.get(
|
||||
'shipping_option', None)
|
||||
shipping_address = shipping_form.save()
|
||||
|
||||
if checkout_form.cleaned_data['different_billing_address']:
|
||||
billing_form = context['billing_form']
|
||||
all_ok = billing_form.is_valid()
|
||||
if all_ok:
|
||||
billing_address = billing_form.save()
|
||||
else:
|
||||
billing_address = shipping_address
|
||||
|
||||
if all_ok:
|
||||
order = create_order(
|
||||
email,
|
||||
request,
|
||||
shipping_address=shipping_address,
|
||||
billing_address=billing_address,
|
||||
shipping_option=shipping_option,
|
||||
capture_payment=True
|
||||
)
|
||||
return HttpResponseRedirect(reverse(
|
||||
'longclaw_checkout_success',
|
||||
kwargs={'pk': order.id}))
|
||||
return super(CheckoutView, self).render_to_response(context)
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.5 on 2017-05-16 16:29
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('longclaworders', '0007_auto_20170313_0846'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='order',
|
||||
name='shipping_address',
|
||||
field=models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, related_name='orders_shipping_address', to='longclawshipping.Address'),
|
||||
),
|
||||
]
|
|
@ -24,10 +24,12 @@ class Order(models.Model):
|
|||
ip_address = models.GenericIPAddressField(blank=True, null=True)
|
||||
|
||||
# shipping info
|
||||
shipping_address = models.ForeignKey(Address, related_name="orders_shipping_address")
|
||||
shipping_address = models.ForeignKey(
|
||||
Address, blank=True, related_name="orders_shipping_address")
|
||||
|
||||
# billing info
|
||||
billing_address = models.ForeignKey(Address, blank=True, related_name="orders_billing_address")
|
||||
billing_address = models.ForeignKey(
|
||||
Address, blank=True, related_name="orders_billing_address")
|
||||
|
||||
shipping_rate = models.DecimalField(max_digits=12,
|
||||
decimal_places=2,
|
||||
|
|
|
@ -46,6 +46,5 @@ def shipping_cost(request):
|
|||
def shipping_countries(request):
|
||||
''' Get all shipping countries
|
||||
'''
|
||||
queryset = models.ShippingRate.objects.all()
|
||||
country_data = [(c.name, c.code) for obj in queryset for c in obj.countries]
|
||||
return Response(data=country_data, status=status.HTTP_200_OK)
|
||||
queryset = models.Country.exclude(shippingrate=None)
|
||||
return Response(data=queryset, status=status.HTTP_200_OK)
|
||||
|
|
|
@ -1,38 +0,0 @@
|
|||
from longclaw.longclawsettings.models import LongclawSettings
|
||||
from longclaw.longclawshipping.models import ShippingRate
|
||||
from django_countries import countries, fields
|
||||
|
||||
class CountryChoices(object):
|
||||
'''
|
||||
Helper class which returns a list of available countries based on
|
||||
the selected shipping options.
|
||||
|
||||
If default_shipping_enabled is ``True`` in the longclaw settings, then
|
||||
all possible countries are returned. Otherwise only countries for
|
||||
which a ``ShippingRate`` has been declared are returned.
|
||||
'''
|
||||
def __init__(self, **kwargs):
|
||||
request = kwargs.get('request', None)
|
||||
self._all_countries = True
|
||||
if request:
|
||||
settings = LongclawSettings.for_site(request.site)
|
||||
self._all_countries = settings.default_shipping_enabled
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
if self._all_countries:
|
||||
return countries
|
||||
else:
|
||||
return ShippingRate.objects.values_list('countries').distinct()
|
||||
|
||||
|
||||
class ShippingCountryField(fields.CountryField):
|
||||
'''
|
||||
Country choice field whose choices are constrained by the
|
||||
configured shipping options.
|
||||
'''
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs.update({
|
||||
'countries': CountryChoices(**kwargs)
|
||||
})
|
||||
super(ShippingCountryField, self).__init__(*args, **kwargs)
|
||||
|
Plik diff jest za duży
Load Diff
|
@ -0,0 +1,25 @@
|
|||
from django.forms import ModelForm, ModelChoiceField
|
||||
from longclaw.longclawsettings.models import LongclawSettings
|
||||
from longclaw.longclawshipping.models import Address, Country
|
||||
|
||||
class AddressForm(ModelForm):
|
||||
class Meta:
|
||||
model = Address
|
||||
fields = ['name', 'line_1', 'line_2', 'city', 'postcode', 'country']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
site = kwargs.pop('site', None)
|
||||
super(AddressForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# Edit the country field to only contain
|
||||
# countries specified for shipping
|
||||
all_countries = True
|
||||
if site:
|
||||
settings = LongclawSettings.for_site(site)
|
||||
all_countries = settings.default_shipping_enabled
|
||||
if all_countries:
|
||||
queryset = Country.objects.all()
|
||||
else:
|
||||
queryset = Country.objects.exclude(shippingrate=None)
|
||||
self.fields['country'] = ModelChoiceField(queryset)
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.5 on 2017-05-16 16:29
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('longclawshipping', '0002_auto_20170410_1620'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Country',
|
||||
fields=[
|
||||
('iso', models.CharField(max_length=2, primary_key=True, serialize=False)),
|
||||
('name_official', models.CharField(max_length=128)),
|
||||
('name', models.CharField(max_length=128)),
|
||||
('sort_priority', models.PositiveIntegerField(default=0)),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': 'Countries',
|
||||
'ordering': ('-sort_priority', 'name'),
|
||||
},
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='shippingrate',
|
||||
name='countries',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='shippingrate',
|
||||
name='countries',
|
||||
field=models.ManyToManyField(to='longclawshipping.Country'),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,21 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.5 on 2017-05-18 10:26
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('longclawshipping', '0003_auto_20170516_1629'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='address',
|
||||
name='country',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='longclawshipping.Country'),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,21 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.5 on 2017-05-18 10:58
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('longclawshipping', '0004_auto_20170518_0526'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='address',
|
||||
name='country',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='longclawshipping.Country'),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,20 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.5 on 2017-05-21 08:31
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('longclawshipping', '0005_auto_20170518_0558'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='shippingrate',
|
||||
name='name',
|
||||
field=models.CharField(help_text='Unique name to refer to this shipping rate by', max_length=32, unique=True),
|
||||
),
|
||||
]
|
|
@ -3,7 +3,6 @@ from django.utils.encoding import python_2_unicode_compatible
|
|||
|
||||
from wagtail.wagtailadmin.edit_handlers import FieldPanel
|
||||
from wagtail.wagtailsnippets.models import register_snippet
|
||||
from django_countries.fields import CountryField
|
||||
|
||||
@register_snippet
|
||||
@python_2_unicode_compatible
|
||||
|
@ -13,7 +12,7 @@ class Address(models.Model):
|
|||
line_2 = models.CharField(max_length=128, blank=True)
|
||||
city = models.CharField(max_length=64)
|
||||
postcode = models.CharField(max_length=10)
|
||||
country = CountryField()
|
||||
country = models.ForeignKey('longclawshipping.Country', blank=True, null=True)
|
||||
|
||||
panels = [
|
||||
FieldPanel('name'),
|
||||
|
@ -33,11 +32,15 @@ class ShippingRate(models.Model):
|
|||
An individual shipping rate. This can be applied to
|
||||
multiple countries.
|
||||
'''
|
||||
name = models.CharField(max_length=32, unique=True)
|
||||
name = models.CharField(
|
||||
max_length=32,
|
||||
unique=True,
|
||||
help_text="Unique name to refer to this shipping rate by"
|
||||
)
|
||||
rate = models.DecimalField(max_digits=12, decimal_places=2)
|
||||
carrier = models.CharField(max_length=64)
|
||||
description = models.CharField(max_length=128)
|
||||
countries = CountryField(multiple=True)
|
||||
countries = models.ManyToManyField('longclawshipping.Country')
|
||||
|
||||
panels = [
|
||||
FieldPanel('name'),
|
||||
|
@ -49,3 +52,33 @@ class ShippingRate(models.Model):
|
|||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class Country(models.Model):
|
||||
"""
|
||||
International Organization for Standardization (ISO) 3166-1 Country list
|
||||
Instance Variables:
|
||||
iso -- ISO 3166-1 alpha-2
|
||||
name -- Official country names (in all caps) used by the ISO 3166
|
||||
display_name -- Country names in title format
|
||||
sort_priority -- field that allows for customizing the default ordering
|
||||
0 is the default value, and the higher the value the closer to the
|
||||
beginning of the list it will be. An example use case would be you will
|
||||
primarily have addresses for one country, so you want that particular
|
||||
country to be the first option in an html dropdown box. To do this, you
|
||||
would simply change the value in the json file or alter
|
||||
country_grabber.py's priority dictionary and run it to regenerate
|
||||
the json
|
||||
"""
|
||||
iso = models.CharField(max_length=2, primary_key=True)
|
||||
name_official = models.CharField(max_length=128)
|
||||
name = models.CharField(max_length=128)
|
||||
sort_priority = models.PositiveIntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = 'Countries'
|
||||
ordering = ('-sort_priority', 'name',)
|
||||
|
||||
def __str__(self):
|
||||
''' Return the display form of the country name'''
|
||||
return self.name
|
||||
|
|
|
@ -1,19 +1,19 @@
|
|||
from rest_framework import serializers
|
||||
from django_countries.serializer_fields import CountryField
|
||||
|
||||
from longclaw.longclawshipping.models import Address, ShippingRate
|
||||
from longclaw.longclawshipping.models import Address, ShippingRate, Country
|
||||
|
||||
class AddressSerializer(serializers.ModelSerializer):
|
||||
country = CountryField()
|
||||
|
||||
country = serializers.PrimaryKeyRelatedField(queryset=Country.objects.all())
|
||||
class Meta:
|
||||
model = Address
|
||||
fields = "__all__"
|
||||
|
||||
class ShippingRateSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = ShippingRate
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class CountrySerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Country
|
||||
fields = "__all__"
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
from longclaw.tests.utils import LongclawTestCase
|
||||
|
||||
from django.test import TestCase
|
||||
from django.forms.models import model_to_dict
|
||||
from longclaw.tests.utils import LongclawTestCase, AddressFactory, CountryFactory
|
||||
from longclaw.longclawshipping.forms import AddressForm
|
||||
|
||||
class AddressTest(LongclawTestCase):
|
||||
def setUp(self):
|
||||
self.country = CountryFactory()
|
||||
def test_create_address(self):
|
||||
"""
|
||||
Test creating an address object via the api
|
||||
|
@ -11,6 +15,16 @@ class AddressTest(LongclawTestCase):
|
|||
'line_1': 'Bobstreet',
|
||||
'city': 'Bobsville',
|
||||
'postcode': 'BOB22 2BO',
|
||||
'country': 'UK'
|
||||
'country': self.country.pk
|
||||
}
|
||||
self.post_test(data, 'longclaw_address_list')
|
||||
|
||||
|
||||
class AddressFormTest(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.address = AddressFactory()
|
||||
|
||||
def test_address_form(self):
|
||||
form = AddressForm(data=model_to_dict(self.address))
|
||||
self.assertTrue(form.is_valid(), form.errors.as_json())
|
||||
|
|
|
@ -7,12 +7,15 @@ class InvalidShippingRate(Exception):
|
|||
class InvalidShippingCountry(Exception):
|
||||
pass
|
||||
|
||||
def get_shipping_cost(country_code, option, settings):
|
||||
def get_shipping_cost(country_code, name, settings):
|
||||
"""
|
||||
Return the shipping cost for a given country code and shipping option (shipping rate name)
|
||||
"""
|
||||
try:
|
||||
qrs = models.ShippingRate.objects.filter(countries__contains=country_code)
|
||||
qrs = models.ShippingRate.objects.filter(countries__in=[country_code])
|
||||
try:
|
||||
if qrs.count() > 1:
|
||||
shipping_rate = qrs.filter(name=option)[0]
|
||||
shipping_rate = qrs.filter(name=name)[0]
|
||||
else:
|
||||
shipping_rate = qrs[0]
|
||||
return {
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
{% templatetag openblock %} extends "base.html" {% templatetag closeblock %}
|
|
@ -0,0 +1 @@
|
|||
{% templatetag openblock %} extends "base.html" {% templatetag closeblock %}
|
|
@ -23,6 +23,7 @@ STRIPE_PUBLISHABLE = getattr(settings, 'STRIPE_PUBLISHABLE', '')
|
|||
STRIPE_SECRET = getattr(settings, 'STRIPE_SECRET', '')
|
||||
|
||||
# Only required if using Braintree as the payment gateway
|
||||
BRAINTREE_SANDBOX = getattr(settings, 'BRAINTREE_SANDBOX', False)
|
||||
BRAINTREE_MERCHANT_ID = getattr(settings, 'BRAINTREE_MERCHANT_ID', '')
|
||||
BRAINTREE_PUBLIC_KEY = getattr(settings, 'BRAINTREE_PUBLIC_KEY', '')
|
||||
BRAINTREE_PRIVATE_KEY = getattr(settings, 'BRAINTREE_PRIVATE_KEY', '')
|
||||
|
|
|
@ -1,27 +1,27 @@
|
|||
# -*- coding: utf-8
|
||||
from __future__ import unicode_literals, absolute_import
|
||||
|
||||
import os
|
||||
import django
|
||||
|
||||
DEBUG = True
|
||||
USE_TZ = True
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = "kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk"
|
||||
SECRET_KEY = 'kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk'
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": ":memory:",
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': ':memory:',
|
||||
}
|
||||
}
|
||||
|
||||
ROOT_URLCONF = "tests.urls"
|
||||
ROOT_URLCONF = 'longclaw.tests.urls'
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sites",
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sites',
|
||||
'django.contrib.sessions',
|
||||
|
||||
'wagtail.wagtailforms',
|
||||
|
@ -42,24 +42,39 @@ INSTALLED_APPS = [
|
|||
'taggit',
|
||||
'rest_framework',
|
||||
'django_extensions',
|
||||
'django_countries',
|
||||
|
||||
"longclaw.longclawsettings",
|
||||
"longclaw.longclawshipping",
|
||||
"longclaw.longclawproducts",
|
||||
"longclaw.longclaworders",
|
||||
"longclaw.longclawcheckout",
|
||||
"longclaw.longclawbasket",
|
||||
"longclaw.tests.products"
|
||||
'longclaw.longclawsettings',
|
||||
'longclaw.longclawshipping',
|
||||
'longclaw.longclawproducts',
|
||||
'longclaw.longclaworders',
|
||||
'longclaw.longclawcheckout',
|
||||
'longclaw.longclawbasket',
|
||||
'longclaw.tests.products',
|
||||
|
||||
]
|
||||
|
||||
SITE_ID = 1
|
||||
|
||||
if django.VERSION >= (1, 10):
|
||||
MIDDLEWARE = ()
|
||||
else:
|
||||
MIDDLEWARE_CLASSES = ()
|
||||
MIDDLEWARE = [
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'wagtail.wagtailcore.middleware.SiteMiddleware',
|
||||
'wagtail.wagtailredirects.middleware.RedirectMiddleware',
|
||||
]
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [
|
||||
os.path.join(os.path.dirname(__file__), 'templates'),
|
||||
],
|
||||
'APP_DIRS': True,
|
||||
},
|
||||
]
|
||||
|
||||
if django.VERSION >= (1, 10):
|
||||
MIDDLEWARE = MIDDLEWARE
|
||||
else:
|
||||
MIDDLEWARE_CLASSES = MIDDLEWARE
|
||||
|
||||
PRODUCT_VARIANT_MODEL = 'products.ProductVariant'
|
||||
|
|
|
@ -8,8 +8,55 @@ from wagtail_factories import PageFactory
|
|||
|
||||
from longclaw.longclawproducts.models import Product
|
||||
from longclaw.longclawbasket.models import BasketItem
|
||||
from longclaw.longclaworders.models import Order, OrderItem
|
||||
from longclaw.longclawshipping.models import Address, Country, ShippingRate
|
||||
from longclaw.utils import ProductVariant
|
||||
|
||||
class OrderFactory(factory.django.DjangoModelFactory):
|
||||
class Meta:
|
||||
model = Order
|
||||
|
||||
class CountryFactory(factory.django.DjangoModelFactory):
|
||||
class Meta:
|
||||
model = Country
|
||||
|
||||
iso = factory.Faker('pystr', max_chars=2, min_chars=2)
|
||||
name_official = factory.Faker('text', max_nb_chars=128)
|
||||
name = factory.Faker('text', max_nb_chars=128)
|
||||
|
||||
|
||||
class AddressFactory(factory.django.DjangoModelFactory):
|
||||
|
||||
class Meta:
|
||||
model = Address
|
||||
|
||||
name = factory.Faker('text', max_nb_chars=64)
|
||||
line_1 = factory.Faker('text', max_nb_chars=128)
|
||||
line_2 = factory.Faker('text', max_nb_chars=128)
|
||||
city = factory.Faker('text', max_nb_chars=64)
|
||||
postcode = factory.Faker('text', max_nb_chars=10)
|
||||
country = factory.SubFactory(CountryFactory)
|
||||
|
||||
class ShippingRateFactory(factory.django.DjangoModelFactory):
|
||||
class Meta:
|
||||
model = ShippingRate
|
||||
|
||||
name = factory.Faker('text', max_nb_chars=32)
|
||||
rate = 1.0
|
||||
carrier = 'Royal Mail'
|
||||
description = 'Test'
|
||||
|
||||
@factory.post_generation
|
||||
def countries(self, create, extracted, **kwargs):
|
||||
if not create:
|
||||
# Simple build, do nothing.
|
||||
return
|
||||
|
||||
if extracted:
|
||||
# A list of countries were passed in, use them
|
||||
for country in extracted:
|
||||
self.countries.add(country)
|
||||
|
||||
class ProductFactory(PageFactory):
|
||||
''' Create a random Product
|
||||
'''
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
django-countries>=4.1
|
||||
django-extensions==1.7.5
|
||||
django-ipware>=1.1.6
|
||||
djangorestframework==3.5.4
|
||||
wagtail>=1.7
|
||||
|
||||
|
|
3
setup.py
3
setup.py
|
@ -90,7 +90,8 @@ setup(
|
|||
'wagtail>=1.7',
|
||||
'django-countries>=4.3',
|
||||
'django-extensions>=1.7.5',
|
||||
'djangorestframework>=3.5.4'
|
||||
'djangorestframework>=3.5.4',
|
||||
'django-ipware>=1.1.6'
|
||||
],
|
||||
license="MIT",
|
||||
zip_safe=False,
|
||||
|
|
Ładowanie…
Reference in New Issue