from __future__ import absolute_import, unicode_literals
import random
from datetime import date
from decimal import Decimal
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import ObjectDoesNotExist, Q
from django.utils.encoding import python_2_unicode_compatible
from django.utils.translation import ugettext_lazy as _
import plata
from plata.fields import CurrencyField, JSONField
from plata.shop.models import Order, TaxClass
[docs]@python_2_unicode_compatible
class DiscountBase(models.Model):
"""Base class for discounts and applied discounts"""
AMOUNT_VOUCHER_EXCL_TAX = 10
AMOUNT_VOUCHER_INCL_TAX = 20
PERCENTAGE_VOUCHER = 30
MEANS_OF_PAYMENT = 40
TYPE_CHOICES = (
(
AMOUNT_VOUCHER_EXCL_TAX,
_("amount voucher excl. tax (reduces total tax on order)"),
),
(
AMOUNT_VOUCHER_INCL_TAX,
_("amount voucher incl. tax (reduces total tax on order)"),
),
(PERCENTAGE_VOUCHER, _("percentage voucher (reduces total tax on order)")),
(MEANS_OF_PAYMENT, _("means of payment (does not change total tax on order)")),
)
#: You can add and remove options at will, except for 'all': This option
#: must always be available, and it cannot have any form fields
CONFIG_OPTIONS = [
("all", {"title": _("All products")}),
(
"exclude_sale",
{
"title": _("Exclude sale prices"),
"orderitem_query": lambda **values: Q(is_sale=False),
},
),
]
name = models.CharField(_("name"), max_length=100)
type = models.PositiveIntegerField(_("type"), choices=TYPE_CHOICES)
value = models.DecimalField(_("value"), max_digits=18, decimal_places=10)
currency = CurrencyField(
blank=True, null=True, help_text=_("Only required for amount discounts.")
)
tax_class = models.ForeignKey(
TaxClass,
on_delete=models.CASCADE,
verbose_name=_("tax class"),
blank=True,
null=True,
help_text=_("Only required for amount discounts incl. tax."),
)
config = JSONField(
_("configuration"),
blank=True,
help_text=_("If you edit this field directly, changes below will be ignored."),
default=dict,
)
class Meta:
abstract = True
def __str__(self):
return self.name
def save(self, *args, **kwargs):
self.full_clean()
super(DiscountBase, self).save(*args, **kwargs)
save.alters_data = True
def clean(self):
if self.type == self.PERCENTAGE_VOUCHER:
if self.currency or self.tax_class:
raise ValidationError(
_("Percentage discounts cannot have currency and tax class set.")
)
elif self.type == self.AMOUNT_VOUCHER_EXCL_TAX:
if not self.currency:
raise ValidationError(_("Amount discounts excl. tax need a currency."))
if self.tax_class:
raise ValidationError(
_("Amount discounts excl. tax cannot have tax class set.")
)
elif self.type == self.AMOUNT_VOUCHER_INCL_TAX:
if not (self.currency and self.tax_class):
raise ValidationError(
_("Amount discounts incl. tax need a currency and a tax class.")
)
elif self.type == self.MEANS_OF_PAYMENT:
if not self.currency:
raise ValidationError(_("Means of payment need a currency."))
if self.tax_class:
raise ValidationError(_("Means of payment cannot have tax class set."))
else:
raise ValidationError(_("Unknown discount type."))
def _eligible_products(self, order, items):
"""
Return a list of products which are eligible for discounting using
the discount configuration.
"""
product_model = plata.product_model()
products = product_model._default_manager.filter(
id__in=[item.product_id for item in items]
)
orderitems = order.items.model._default_manager.filter(
id__in=[item.id for item in items]
)
for key, parameters in self.config.items():
parameters = dict((str(k), v) for k, v in parameters.items())
cfg = dict(self.CONFIG_OPTIONS)[key]
if "product_query" in cfg:
products = products.filter(cfg["product_query"](**parameters))
if "orderitem_query" in cfg:
orderitems = orderitems.filter(cfg["orderitem_query"](**parameters))
return products.filter(id__in=orderitems.values("product_id"))
def apply(self, order, items, **kwargs):
if not items:
return
if self.type == self.AMOUNT_VOUCHER_EXCL_TAX:
self._apply_amount_discount(order, items, tax_included=False)
elif self.type == self.AMOUNT_VOUCHER_INCL_TAX:
self._apply_amount_discount(order, items, tax_included=True)
elif self.type == self.PERCENTAGE_VOUCHER:
self._apply_percentage_discount(order, items)
elif self.type == self.MEANS_OF_PAYMENT:
self._apply_means_of_payment(order, items)
else:
raise NotImplementedError("Unknown discount type %s" % self.type)
def _apply_amount_discount(self, order, items, tax_included):
"""
Apply amount discount evenly to all eligible order items
Aggregates remaining discount (if discount is bigger than order total)
"""
eligible_products = self._eligible_products(order, items).values_list(
"id", flat=True
)
eligible_items = [
item for item in items if item.product_id in eligible_products
]
if tax_included:
discount = self.value / (1 + self.tax_class.rate / 100)
else:
discount = self.value
items_subtotal = sum(
[item.discounted_subtotal_excl_tax for item in eligible_items],
Decimal("0.00"),
)
# Don't allow bigger discounts than the items subtotal
if discount > items_subtotal:
self.remaining = (discount - items_subtotal).quantize(Decimal("0E-10"))
self.save()
discount = items_subtotal
for item in eligible_items:
item._line_item_discount += (
item.discounted_subtotal_excl_tax / items_subtotal * discount
)
def _apply_means_of_payment(self, order, items):
items_tax = sum((item._line_item_tax for item in items), Decimal("0.00"))
for item in items:
items_tax
discount = self.value
# items_subtotal = order.subtotal if \
# order.price_includes_tax else order.subtotal + items_tax
# CHECK: items_subtotal is unused!
# Don't allow bigger discounts than the items subtotal
remaining = discount
for item in items:
if order.price_includes_tax:
items_subtotal_inkl_taxes = item.subtotal
# items_subtotal_excl_taxes = item._unit_price * item.quantity
else:
items_subtotal_inkl_taxes = item.subtotal + item._line_item_tax
# items_subtotal_excl_taxes = item.subtotal
# CHECK: items_subtotal_excl_taxes is unused!
if remaining >= items_subtotal_inkl_taxes - item._line_item_discount:
if item._line_item_discount < items_subtotal_inkl_taxes:
new_discount = items_subtotal_inkl_taxes - item._line_item_discount
item._line_item_discount += new_discount
remaining -= new_discount
else:
item._line_item_discount += remaining
remaining = 0
self.remaining = remaining
self.save()
def _apply_percentage_discount(self, order, items):
"""
Apply percentage discount evenly to all eligible order items
"""
eligible_products = self._eligible_products(order, items).values_list(
"id", flat=True
)
factor = self.value / 100
for item in items:
if item.product_id not in eligible_products:
continue
item._line_item_discount += item.discounted_subtotal_excl_tax * factor
# Nearly all letters and digits, excluding those which can be easily confounded
RANDOM_CODE_CHARACTERS = "23456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ"
def generate_random_code():
return "".join(random.sample(RANDOM_CODE_CHARACTERS, 10))
[docs]class Discount(DiscountBase):
code = models.CharField(
_("code"), max_length=30, unique=True, default=generate_random_code
)
is_active = models.BooleanField(_("is active"), default=True)
valid_from = models.DateField(_("valid from"), default=date.today)
valid_until = models.DateField(_("valid until"), blank=True, null=True)
allowed_uses = models.IntegerField(
_("number of allowed uses"),
blank=True,
null=True,
help_text=_(
"Leave empty if there is no limit on the number of uses"
" of this discount."
),
)
used = models.IntegerField(_("number of times already used"), default=0)
class Meta:
verbose_name = _("discount")
verbose_name_plural = _("discounts")
[docs] def validate(self, order):
"""
Validate whether this discount can be applied on the given order
"""
messages = []
if not self.is_active:
messages.append(_("Discount is inactive."))
today = date.today()
if today < self.valid_from:
messages.append(_("Discount is not active yet."))
if self.valid_until and today > self.valid_until:
messages.append(_("Discount is expired."))
if self.allowed_uses and self.used >= self.allowed_uses:
messages.append(
_("Allowed uses for this discount has already been reached.")
)
if self.currency != order.currency and self.type in (
self.AMOUNT_VOUCHER_EXCL_TAX,
self.AMOUNT_VOUCHER_INCL_TAX,
self.MEANS_OF_PAYMENT,
):
messages.append(_("Discount and order currencies do not match."))
if messages:
raise ValidationError(messages)
return True
[docs] def add_to(self, order, recalculate=True):
"""
Add discount to passed order
Removes the previous discount if a discount with this code has
already been added to the order before.
"""
self.validate(order)
try:
order.applied_discounts.get(code=self.code).delete()
except ObjectDoesNotExist:
pass
instance = order.applied_discounts.create(
code=self.code,
type=self.type,
name=self.name,
value=self.value,
currency=self.currency,
tax_class=self.tax_class,
config=self.config,
)
if recalculate:
order.recalculate_total()
return instance
[docs]class AppliedDiscountManager(models.Manager):
"""
Default manager for the ``AppliedDiscount`` model
"""
[docs] def remaining(self, order=None):
"""
Calculate remaining discount excl. tax
Can either be used as related manager::
order.applied_discounts.remaining()
or directly::
AppliedDiscount.objects.remaining(order)
"""
queryset = self.all()
if order:
queryset = queryset.filter(order=order)
return sum((d.remaining for d in queryset), Decimal("0.00"))
[docs]class AppliedDiscount(DiscountBase):
"""
Stores an applied discount, so that deletion of discounts does not
affect orders.
"""
order = models.ForeignKey(
Order,
on_delete=models.CASCADE,
related_name="applied_discounts",
verbose_name=_("order"),
)
# We could make this a ForeignKey to Discount.code, but we do not
# want deletions to cascade to this table and we still need the code
# for the PDF generation or whatever anyway.
code = models.CharField(_("code"), max_length=30)
remaining = models.DecimalField(
_("remaining"),
max_digits=18,
decimal_places=10,
default=0,
help_text=_(
"Discount amount excl. tax remaining after discount has been applied."
),
)
class Meta:
ordering = ["type", "name"]
verbose_name = _("applied discount")
verbose_name_plural = _("applied discounts")
objects = AppliedDiscountManager()