Commits (8)
......@@ -7,3 +7,5 @@ node_modules
......@@ -33,7 +33,7 @@ repos:
entry: prospector
language: system
files: \.py$
exclude: migrations\/[^/]*\.py$|settings\/[^/]*\.py$
exclude: migrations\/[^/]*\.py$|settings\/[^/]*\.py$|^docs/conf\.py$
- repo: https://github.com/prettier/prettier
sha: 1.7.0
strictness: medium
# Unused arguments are often needed to conform to an expected signature.
- unused-argument
# Pylint is sometimes quite bad at the type inference necessary for these.
- unsubscriptable-object
- unsupported-membership-test
# This one is too picky (it complains about *args, **kwargs) and errors of
# this type are trivial to catch with minimal actual testing
- arguments-differ
# If Pylint can't import something, it's usually Pylint's fault
- import-error
......@@ -67,9 +67,9 @@ author = 'Adam Brenecki'
# built documents.
# The short X.Y version.
version = '0.0.1'
version = '0.1.7'
# The full version, including alpha/beta/rc tags.
release = '0.0.1'
release = version
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
......@@ -11,6 +11,8 @@ Installation
4. Set the ``LORIKEET_EMAIL_INVOICE_TEMPLATE_TEXT`` variable in ``settings.py`` to a plain text template.
3. Set the ``LORIKEET_EMAIL_INVOICE_FROM_ADDRESS`` variable in ``settings.py`` to an email address.
You can also set the ``LORIKEET_EMAIL_INVOICE_COPY_ADDRESS`` setting in ``settings.py`` to an email address; if this setting is set, a copy of every invoice will be sent to that address as well.
Adjustments, Discounts and Coupons
In Lorikeet, anything that changes the total of the cart is an *adjustment*; adjustments are how you'll implement things like discounts and coupon codes.
Adjustments work just the same as line items; they have a model which subclasses :class:`~lorikeet.models.Adjustment`, a serialiser, and the two are associated with each other in the registry.
You just need to override the :meth:`~lorikeet.models.Adjustment.get_total` method; it's passed the subtotal before adjustments are applied, and it should return an amount that is added to the total. (In the case of discounts, this amount should be negative.)
.. note::
If you're doing any division, and you don't want to deal with fractional cents, your code in :meth:`~lorikeet.models.Adjustment.get_total` is responsible for rounding.
.. code-block:: python
:caption: models.py
from django.db import models
from lorikeet.models import Adjustment
class Coupon(models.Model):
code = models.CharField(max_length=128, unique=True)
valid_from = models.DateTimeField()
valid_to = models.DateTimeField()
percentage = models.PositiveSmallIntegerField()
class CouponAdjustment(Adjustment):
coupon = models.ForeignKey(CouponCode)
def get_total(self, subtotal):
discount = -subtotal * self.percentage / 100
# Rounding down (towards negative infinity) rounds in favour of
# the customer
return discount.quantize(Decimal('.01'), rounding=ROUND_DOWN)
.. todo::
Write example serializer etc
Cart Checkers
Preventing Checkout with Completeness and Cart Checkers
**Cart checkers** are functions that determine whether or not a cart can be checked out in its current state. If a cart is ready to be checked out, and all cart checkers pass, it is said to be **complete**.
If you think of the entire cart as being like a form, cart checkers are like validators. (We don't actually call them that, because Django REST Framework validators perform a separate function within Lorikeet; ensuring that individual instances of :class:`~lorikeet.models.LineItem`, :class:`~lorikeet.models.DeliveryAddress` and so on are valid.)
.. note::
**Cart checkers** and **completeness** are similar to the concept of **validators** and **validation** in Django and Django REST Framework, but not quite the same. Validators check for cases that are totally invalid; for instance, a delivery address missing a postcode, or a line item where the quantity is a string rather than a number might be invalid. In contrast,cart checkers check for cases that are valid states for the cart to be in, but for which checkout shouldn't be allowed; for instance, when there are no line items, or when a payment method is missing.
Cart checkers are run in two places. One is in the :http:get:`/_cart/` endpoint, where any checkers that fail are listed in ``incomplete_reasons``, so the client user interface can show details. The other is in the :http:post:`/_cart/checkout/` endpoint, where any checkers that fail will prevent checkout from happening, resulting in a **422** response with a reason of ``"incomplete"``.
......@@ -32,6 +34,17 @@ Once you've written your cart checker, add it to the :data:`LORIKEET_CART_COMPLE
The default value for :data:`LORIKEET_CART_COMPLETE_CHECKERS` contains important built-in checkers that you probably don't want to disable, because they prevent things like going through checkout with an empty cart. If you override this setting, make sure you include them!
Checking Individual Line Items and Adjustments
Line items and adjustments both provide a :meth:`~lorikeet.models.LineItem.is_complete` method, which work as the method equivalent of a cart checker; whenever Lorikeet needs to check if a cart is complete, that method is called on every line item and adjustment in the cart, and :class:`~lorikeet.exceptions.IncompleteCartError` exceptions are handled just as described above.
.. note::
Remember, just like cart checkers, these methods won't prevent a line item or adjustment from being created; they'll only prevent the cart from being checked out.
Before adding an ``is_complete`` method, consider whether it's more appropriate to use validation in your Django model or Django REST Framework serializer instead.
Built-in Cart Checkers
......@@ -16,6 +16,9 @@ Models
.. autoclass:: lorikeet.models.PaymentMethod
.. autoclass:: lorikeet.models.Adjustment
.. autoclass:: lorikeet.models.Order
......@@ -3,7 +3,7 @@ Installation
This tutorial assumes you have an existing Django project set up. If you don't, you can create one with `startproject <https://docs.djangoproject.com/en/1.10/ref/django-admin/#startproject>`_.
1. Install Lorikeet, by running ``pip install https://gitlab.com/abre/lorikeet.git``.
1. Install Lorikeet, by running ``pip install lorikeet``.
2. Add ``'lorikeet'`` to ``INSTALLED_APPS``.
3. Add ``'lorikeet.middleware.CartMiddleware'`` to ``MIDDLEWARE_CLASSES``.
4. Add a line that looks like ``url(r'^_cart/', include('lorikeet.urls', namespace='lorikeet')),`` to ``urls.py``. (You don't have to use ``_cart`` in your URL—anything will do.)
......@@ -22,6 +22,7 @@ class LineItemSerializerRegistry:
self.line_items = {}
self.payment_methods = {}
self.delivery_addresses = {}
self.adjustments = {}
def register(self, model, serializer):
"""Associate ``model`` with ``serializer``."""
......@@ -32,9 +33,12 @@ class LineItemSerializerRegistry:
self.payment_methods[model.__name__] = serializer
elif issubclass(model, models.DeliveryAddress):
self.delivery_addresses[model.__name__] = serializer
elif issubclass(model, models.Adjustment):
self.adjustments[model.__name__] = serializer
raise ValueError("model must be a subclass of "
"LineItem, PaymentMethod or DeliveryAddress")
"LineItem, PaymentMethod, DeliveryAddress or "
def get_serializer_class(self, instance):
if isinstance(instance, models.LineItem):
......@@ -43,8 +47,11 @@ class LineItemSerializerRegistry:
return self.payment_methods[instance.__class__.__name__]
if isinstance(instance, models.DeliveryAddress):
return self.delivery_addresses[instance.__class__.__name__]
if isinstance(instance, models.Adjustment):
return self.adjustments[instance.__class__.__name__]
raise ValueError("instance must be an instance of a "
"LineItem, PaymentMethod or DeliveryAddress subclass")
"LineItem, PaymentMethod, DeliveryAddress or "
"Adjustment subclass")
def get_serializer(self, instance):
return self.get_serializer_class(instance)(instance)
......@@ -158,6 +165,14 @@ class PaymentMethodSerializer(RegistryRelatedWithMetadataSerializer):
return reverse('lorikeet:payment-method', kwargs={'id': instance.id})
class AdjustmentSerializer(RegistryRelatedWithMetadataSerializer):
total = fields.SerializerMethodField()
def get_total(self, instance):
# TODO: Store subtotal so that it's only calculated once
return str(instance.get_total(instance.cart.get_subtotal()))
class SubclassListSerializer(serializers.ListSerializer):
def to_representation(self, instance, *args, **kwargs):
......@@ -172,6 +187,7 @@ class CartSerializer(serializers.ModelSerializer):
new_address_url = fields.SerializerMethodField()
payment_methods = fields.SerializerMethodField()
new_payment_method_url = fields.SerializerMethodField()
adjustments = SubclassListSerializer(child=AdjustmentSerializer())
grand_total = fields.DecimalField(
max_digits=7, decimal_places=2, source='get_grand_total')
is_complete = fields.SerializerMethodField()
......@@ -237,7 +253,7 @@ class CartSerializer(serializers.ModelSerializer):
'new_address_url', 'payment_methods',
'new_payment_method_url', 'grand_total', 'generated_at',
'is_complete', 'incomplete_reasons', 'checkout_url',
'is_authenticated', 'email')
'is_authenticated', 'email', 'adjustments')
class CartUpdateSerializer(serializers.ModelSerializer):
......@@ -144,6 +144,32 @@ class DeliveryAddressView(RetrieveUpdateDestroyAPIView):
instance, context={'cart': self.request.get_cart()}, *args, **kwargs)
class AdjustmentView(RetrieveUpdateDestroyAPIView):
def get_object(self):
cart = self.request.get_cart()
return cart.adjustments.get_subclass(id=self.kwargs['id'])
except models.Adjustment.DoesNotExist:
raise Http404()
def get_serializer(self, instance, *args, **kwargs):
return api_serializers.AdjustmentSerializer(
instance, context={'cart': self.request.get_cart()}, *args, **kwargs)
class NewAdjustmentView(CreateAPIView):
def get_serializer(self, data, *args, **kwargs):
ser_class = api_serializers.registry.adjustments[data['type']]
return ser_class(data=data['data'],
context={'request': self.request},
*args, **kwargs)
def perform_create(self, serializer):
class CheckoutView(APIView):
def post(self, request, format=None):
......@@ -151,6 +177,7 @@ class CheckoutView(APIView):
with atomic():
# Prepare the order object
cart = request.get_cart()
subtotal = cart.get_subtotal()
grand_total = cart.get_grand_total()
order = models.Order.objects.create(user=cart.user,
......@@ -165,7 +192,7 @@ class CheckoutView(APIView):
# Check the cart is ready to be checked out
cart.is_complete(raise_exc=True, for_checkout=True)
# copy items onto order, also calculate grand total
# copy items onto order
for item in cart.items.select_subclasses().all():
total = item.get_total()
item.total_when_charged = total
......@@ -175,6 +202,16 @@ class CheckoutView(APIView):
# copy adjustments onto order
for adj in cart.adjustments.select_subclasses().all():
total = adj.get_total(subtotal)
adj.total_when_charged = total
adj.order = order
adj.cart = None
adj._new_order = True
# copy delivery address over
order.delivery_address = cart.delivery_address
from decimal import ROUND_DOWN, Decimal
from json import loads
import pytest
from shop import models as smodels
from shop import factories
from shop import models as smodels
......@@ -18,6 +19,7 @@ def test_empty_cart(client):
'new_address_url': '/_cart/new-address/',
'payment_methods': [],
'new_payment_method_url': '/_cart/new-payment-method/',
'adjustments': [],
'grand_total': '0.00',
'incomplete_reasons': [
......@@ -61,6 +63,7 @@ def test_empty_cart_logged_in(admin_client):
'new_address_url': '/_cart/new-address/',
'payment_methods': [],
'new_payment_method_url': '/_cart/new-payment-method/',
'adjustments': [],
'grand_total': '0.00',
'incomplete_reasons': [
......@@ -113,6 +116,38 @@ def test_cart_contents(client, cart):
assert data['grand_total'] == str(i.product.unit_price * i.quantity)
def test_cart_contents_with_adjustment(client, cart):
# set up cart contents
i = factories.MyLineItemFactory(cart=cart)
# add some more line items not attached to the cart
# add an adjustment
a = factories.CartDiscountFactory(cart=cart)
# add more adjustments not attached to the cart
resp = client.get('/_cart/')
data = loads(resp.content.decode('utf-8'))
discount = (-cart.get_subtotal() * (Decimal(a.percentage) / 100)
).quantize(Decimal('.01'), rounding=ROUND_DOWN)
assert data['adjustments'] == [{
'type': 'CartDiscount',
# 'url': '/_cart/adjustment/{}/'.format(i.id),
'data': {
'percentage': a.percentage,
'total': str(discount)
assert data['grand_total'] == str(
(i.product.unit_price * i.quantity) + discount)
def test_cart_delivery_addresses(client, cart):
# set up cart contents
from json import dumps, loads
import pytest
from shop import factories as sfactories
from shop import models as smodels
def test_add_adjustment(client, cart):
resp = client.post('/_cart/new-adjustment/', dumps({
'type': "CartDiscount",
'data': {
'percentage': 25,
}), content_type='application/json')
assert resp.status_code == 201
assert smodels.CartDiscount.objects.count() == 1
assert cart.adjustments.count() == 1
def test_view_adjustment(client, cart):
a = sfactories.CartDiscountFactory(cart=cart)
url = '/_cart/adjustment/{}/'.format(a.id)
resp = client.get(url)
data = loads(resp.content.decode('utf-8'))
assert data == {
'type': 'CartDiscount',
'data': {
'percentage': a.percentage,
'total': '0.00',
def test_view_unowned_adjustment(other_cart, client):
a = sfactories.CartDiscountFactory(cart=other_cart)
url = '/_cart/adjustment/{}/'.format(a.id)
resp = client.get(url)
assert resp.status_code == 404
def test_delete_adjustment(client, cart):
a = sfactories.CartDiscountFactory(cart=cart)
url = '/_cart/adjustment/{}/'.format(a.id)
resp = client.delete(url)
assert resp.status_code == 204
with pytest.raises(smodels.CartDiscount.DoesNotExist):
......@@ -9,6 +9,8 @@ from . import models
def test_checkout(client, filled_cart):
expected_total = filled_cart.get_grand_total()
expected_item_count = filled_cart.items.count()
expected_adjustment_count = filled_cart.adjustments.count()
resp = client.post('/_cart/checkout/', dumps({}),
assert resp.status_code == 200
......@@ -19,9 +21,14 @@ def test_checkout(client, filled_cart):
assert filled_cart.items.count() == 0
assert models.Order.objects.count() == 1
assert models.Order.objects.first().grand_total == expected_total
for item in models.Order.objects.first().items.all():
order = models.Order.objects.first()
assert order.grand_total == expected_total
assert order.items.count() == expected_item_count
assert order.adjustments.count() == expected_adjustment_count
for item in order.items.all():
assert item.total_when_charged
for adj in order.adjustments.all():
assert adj.total_when_charged
import pytest
from faker import Faker
from shop import models as smodels
from shop import factories
from shop import models as smodels
from . import models
......@@ -9,6 +9,7 @@ from . import models
def fill_cart(cart):
factories.CartDiscountFactory(cart=cart, percentage=10)
cart.delivery_address = factories.AustralianDeliveryAddressFactory()
cart.payment_method = smodels.PipeCard.objects.create(card_id="Visa4242")
if not cart.user:
......@@ -25,6 +26,11 @@ def cart(client):
return cart
def other_cart():
return models.Cart.objects.create()
def filled_cart(cart):
from django.conf import settings
from django.conf import settings as s
"Your order ID {order.invoice_id}")
class Settings:
_defaults = {
'subject': "Your order ID {order.invoice_id}",
'from_address': 'orders@example.com',
'template_html': None,
'template_text': None,
'copy_address': None,
def __getattr__(self, attr):
if attr not in self._defaults:
raise KeyError(attr)
return getattr(s, 'LORIKEET_EMAIL_INVOICE_' + attr.upper(), self._defaults[attr])
def copy_address(self):
settings = Settings()
__all__ = ['settings']
......@@ -6,14 +6,15 @@ from django.template.loader import render_to_string
from lorikeet.signals import order_checked_out
from premailer import transform
from . import settings, textify
from . import textify
from .settings import settings
logger = getLogger(__name__)
def send_email_invoice(sender, order, request, **kwargs):
subject = settings.LORIKEET_EMAIL_INVOICE_SUBJECT.format(order=order)
subject = settings.subject.format(order=order)
recipient = order.user.email if order.user else order.guest_email
if recipient is None:
......@@ -24,17 +25,15 @@ def send_email_invoice(sender, order, request, **kwargs):
'order_url': request.build_absolute_uri(order.get_absolute_url(token=True)),
mail_kwargs = {}
html = render_to_string(settings.LORIKEET_EMAIL_INVOICE_TEMPLATE_HTML,
if settings.template_html:
html = render_to_string(settings.template_html, ctx)
html = transform(html, base_url=request.build_absolute_uri())
mail_kwargs['html_message'] = html
text = render_to_string(settings.LORIKEET_EMAIL_INVOICE_TEMPLATE_TEXT,
if settings.template_text:
text = render_to_string(settings.template_text, ctx)
mail_kwargs['message'] = text
elif settings.template_html:
mail_kwargs['message'] = textify.transform(html)
raise ValueError("No HTML or text template set")
......@@ -42,9 +41,16 @@ def send_email_invoice(sender, order, request, **kwargs):
logger.debug('Sending an invoice email to %s', recipient)
if settings.copy_address:
return {'invoice_email': recipient}
......@@ -16,3 +16,21 @@ def test_checkout(settings, client, filled_cart, mailoutbox):
assert 'Invoice ID: {}'.format(order.invoice_id) in m.body
assert m.from_email == 'orders@example.com'
assert list(m.to) == [order.guest_email]
def test_checkout_copy(settings, client, filled_cart, mailoutbox):
settings.LORIKEET_EMAIL_INVOICE_COPY_ADDRESS = 'invcopy@example.com'
if 'lorikeet.extras.email_invoice' not in settings.INSTALLED_APPS:
settings.INSTALLED_APPS += ['lorikeet.extras.email_invoice']
assert len(mailoutbox) == 2
order = models.Order.objects.first()
m = mailoutbox[1]
assert m.subject == 'Your order ID {}'.format(order.invoice_id)
assert 'Invoice ID: {}'.format(order.invoice_id) in m.body
assert m.from_email == 'orders@example.com'
assert list(m.to) == ['invcopy@example.com']
# -*- coding: utf-8 -*-
# Generated by Django 1.10 on 2018-07-18 23:11
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('lorikeet', '0014_order_purchased_on'),
operations = [
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('cart', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='adjustments', to='lorikeet.Cart')),
('order', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='adjustments', to='lorikeet.Order')),
# -*- coding: utf-8 -*-
# Generated by Django 1.10 on 2018-07-23 04:01
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('lorikeet', '0015_adjustment'),
operations = [
field=models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True),
from decimal import Decimal
from django.conf import settings
from django.core.urlresolvers import reverse
from django.db import models
......@@ -5,8 +7,8 @@ from django.utils.module_loading import import_string
from django.utils.timezone import now
from model_utils.managers import InheritanceManager
from . import settings as lorikeet_settings
from . import exceptions
from . import settings as lorikeet_settings
class Cart(models.Model):
......@@ -23,9 +25,18 @@ class Cart(models.Model):
payment_method = models.ForeignKey(
'lorikeet.PaymentMethod', blank=True, null=True)
def get_subtotal(self):
"""Calculate the subtotal for this cart.
This returns the sum of all of the item totals, but does not
include any adjustments applied to the cart.
return sum((x.get_total() for x in self.items.select_subclasses().all()), Decimal(0))
def get_grand_total(self):
"""Calculate the grand total for this cart."""
return sum(x.get_total() for x in self.items.select_subclasses().all())
subtotal = self.get_subtotal()
return subtotal + sum((x.get_total(subtotal) for x in self.adjustments.select_subclasses().all()), Decimal(0))
def delivery_address_subclass(self):
......@@ -81,6 +92,12 @@ class Cart(models.Model):
except exceptions.IncompleteCartError as e:
for adj in self.adjustments.all().select_subclasses():
except exceptions.IncompleteCartError as e:
if raise_exc and self.errors:
raise self.errors
......@@ -290,3 +307,97 @@ class LineItem(models.Model):
`select_for_update <https://docs.djangoproject.com/en/1.10/ref/models/querysets/#select-for-update>`_).
class Adjustment(models.Model):
"""An adjustment to the total on a cart.
Subclass this model only for adjustments that users can add to their carts
(e.g. discount codes).
This model doesn't do anything by itself; you'll need to subclass it.
cart = models.ForeignKey(
Cart, related_name='adjustments', blank=True, null=True)
order = models.ForeignKey(
Order, related_name='adjustments', blank=True, null=True)
total_when_charged = models.DecimalField(max_digits=7, decimal_places=2,
blank=True, null=True)
objects = InheritanceManager()
def total(self):
"""The total cost for this line item.
Returns the total actually charged to the customer if this
adjustment is attached to an :class:`~lorikeet.models.Order`, or
calls :func:`~lorikeet.models.Adjustment.get_total` otherwise.
if self.order_id:
return self.total_when_charged
return self.get_total()
def get_total(self, subtotal):
"""Returns the total adjustment to make to the cart.
By default this raises :class:`NotImplementedError`; subclasses will
need to override this.
If you want to know the total for this adjustment from your own
code, use the :func:`~lorikeet.models.Adjustment.total` property
rather than calling this function.
:param subtotal: The subtotal of all line items, which is passed in
as a convenience.
:type subtotal: decimal.Decimal
:return: The amount to add to the cart. This value can be
(and in most cases, will be) negative, in order to represent a
:rtype: decimal.Decimal
raise NotImplementedError("Provide a get_total method in your "
"Adjustment subclass {}."
def check_complete(self, for_checkout=False):
"""Checks that this adjustment is ready to be checked out.
This method should raise
:class:`~lorikeet.exceptions.IncompleteCartError` if the line
item is not ready to be checked out (e.g. there is insufficient
stock in inventory to fulfil this line item). By default it does
:param for_checkout: Set to ``True`` when the cart is about to
be checked out. See the documentation for
:meth:`prepare_for_checkout` for more details.
is going to be called within the current transaction, so you
should use things like
`select_for_update <https://docs.djangoproject.com/en/1.10/ref/models/querysets/#select-for-update>`_.
:type for_checkout: bool
def prepare_for_checkout(self):
"""Prepare this adjustment for checkout.
This is called in the checkout process, shortly before the
payment method is charged, within a database transaction that
will be rolled back if payment is unsuccessful.
This function shouldn't fail. (If it does, the transaction will
be rolled back and the payment won't be processed so nothing
disastrous will happen, but the user will get a 500 error which
you probably don't want.)
The :meth:`check_complete` method is guaranteed to be called
shortly before this method, within the same transaction, and
with the ``for_checkout`` parameter set to ``True``. Any checks
you need to perform to ensure checkout will succeed should be
performed there, and when ``for_checkout`` is true there you
should ensure that those checks remain valid for the remainder
of the database transaction (e.g. using
`select_for_update <https://docs.djangoproject.com/en/1.10/ref/models/querysets/#select-for-update>`_).
......@@ -15,5 +15,9 @@ urlpatterns = [
api_views.NewPaymentMethodView.as_view(), name='new-payment-method'),
api_views.AdjustmentView.as_view(), name='adjustment'),
url(r'^new-adjustment/$', api_views.NewAdjustmentView.as_view(),
url(r'^checkout/$', api_views.CheckoutView.as_view(), name='checkout'),
"name": "lorikeet",
"version": "0.1.5",
"version": "0.1.7",
"main": "index.js",
"repository": "gitlab:abre/lorikeet",
"files": ["index.js", "react.js"],
This diff is collapsed.
name = "lorikeet"
version = "0.1.7"
# Don't forget to also change the version in:
# - package.json
# - docs/conf.py
description = "A simple, generic, API-only shopping cart framework for Django"
authors = ["Adam Brenecki <adam@brenecki.id.au>"]
license = "MIT"
readme = "README.rst"
homepage = "https://lorikeet.readthedocs.io/"
repository = "https://gitlab.com/abre/lorikeet"
python = ">=3.4"
django-model-utils = "^2.6"
djangorestframework = "~3.5.3"
stripe = {version = "^1.44.0", optional = true}
premailer = {version = "^3.0.1", optional = true}
requests = {version = "^2.13.0", optional = true}
sphinx = "~1.5.1"
sphinx-rtd-theme = "^0.1.9"
sphinx-js = "~1.3"
sphinxcontrib_httpdomain = "^1.5.0"
factory-boy = "^2.11"
tox = "^3.1"
stripe = ["stripe"]
email_invoice = ["premailer"]
starshipit = ["requests"]
......@@ -29,6 +29,11 @@ setup(
'dev': [
......@@ -48,3 +48,9 @@ class PipeCardSerializer(serializers.ModelSerializer):
card_token = validated_data.pop('card_token')
validated_data['card_id'] = codecs.encode(card_token, 'rot13')
return super().create(validated_data)
class CartDiscountSerializer(serializers.ModelSerializer):
class Meta:
model = models.CartDiscount
fields = ('percentage',)
......@@ -14,3 +14,5 @@ class ShopConfig(AppConfig):
......@@ -30,3 +30,10 @@ class AustralianDeliveryAddressFactory(DjangoModelFactory):
class Meta:
model = models.AustralianDeliveryAddress
class CartDiscountFactory(DjangoModelFactory):
percentage = fuzzy.FuzzyInteger(1, 99)
class Meta:
model = models.CartDiscount
# -*- coding: utf-8 -*-
# Generated by Django 1.10 on 2018-07-19 00:40
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('lorikeet', '0015_adjustment'),
('shop', '0003_pipepayment'),
operations = [
('adjustment_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='lorikeet.Adjustment')),
('percentage', models.PositiveSmallIntegerField()),
from decimal import ROUND_DOWN, Decimal
from django.db import models
from lorikeet.exceptions import PaymentError
from lorikeet.models import DeliveryAddress, LineItem, Payment, PaymentMethod
from lorikeet.models import (Adjustment, DeliveryAddress, LineItem, Payment,
('NSW', 'New South Wales'),
......@@ -46,3 +49,12 @@ class PipeCard(PaymentMethod):
class PipePayment(Payment):
amount = models.DecimalField(max_digits=7, decimal_places=2)
class CartDiscount(Adjustment):
percentage = models.PositiveSmallIntegerField()
def get_total(self, subtotal):
assert isinstance(subtotal, Decimal)
discount = -subtotal * self.percentage / 100
return discount.quantize(Decimal('.01'), rounding=ROUND_DOWN)
......@@ -13,7 +13,7 @@ deps=
commands=pytest {posargs}