from __future__ import absolute_import, unicode_literals
import logging
import re
from decimal import Decimal
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import F, ObjectDoesNotExist, Sum
from django.utils import timezone
from django.utils.encoding import python_2_unicode_compatible
from django.utils.translation import ugettext_lazy as _, ugettext
import plata
from django_countries.fields import CountryField
from plata.fields import CurrencyField, JSONField
try:
from django.urls.utils import get_callable
except ImportError:
from django.core.urlresolvers import get_callable
logger = logging.getLogger("plata.shop.order")
[docs]@python_2_unicode_compatible
class TaxClass(models.Model):
"""
Tax class, storing a tax rate
TODO informational / advisory currency or country fields?
"""
name = models.CharField(_("name"), max_length=100)
rate = models.DecimalField(
_("rate"), max_digits=10, decimal_places=2, help_text=_("Tax rate in percent.")
)
priority = models.PositiveIntegerField(
_("priority"),
default=0,
help_text=_("Used to order the tax classes in the administration interface."),
)
class Meta:
ordering = ["-priority"]
verbose_name = _("tax class")
verbose_name_plural = _("tax classes")
def __str__(self):
return self.name
[docs]class BillingShippingAddress(models.Model):
"""
Abstract base class for all models storing a billing and a shipping
address
"""
ADDRESS_FIELDS = [
"company",
"first_name",
"last_name",
"address",
"zip_code",
"city",
"country",
]
billing_company = models.CharField(_("company"), max_length=100, blank=True)
billing_first_name = models.CharField(_("first name"), max_length=100)
billing_last_name = models.CharField(_("last name"), max_length=100)
billing_address = models.TextField(_("address"))
billing_zip_code = models.CharField(
plata.settings.PLATA_ZIP_CODE_LABEL, max_length=50
)
billing_city = models.CharField(_("city"), max_length=100)
billing_country = CountryField(_("country"), blank=True)
shipping_same_as_billing = models.BooleanField(
_("shipping address equals billing address"), default=True
)
shipping_company = models.CharField(_("company"), max_length=100, blank=True)
shipping_first_name = models.CharField(_("first name"), max_length=100, blank=True)
shipping_last_name = models.CharField(_("last name"), max_length=100, blank=True)
shipping_address = models.TextField(_("address"), blank=True)
shipping_zip_code = models.CharField(
plata.settings.PLATA_ZIP_CODE_LABEL, max_length=50, blank=True
)
shipping_city = models.CharField(_("city"), max_length=100, blank=True)
shipping_country = CountryField(_("country"), blank=True)
class Meta:
abstract = True
[docs] def addresses(self):
"""
Return a ``dict`` containing a billing and a shipping address, taking
into account the value of the ``shipping_same_as_billing`` flag
"""
billing = dict(
(f, getattr(self, "billing_%s" % f)) for f in self.ADDRESS_FIELDS
)
if self.shipping_same_as_billing:
shipping = billing
else:
shipping = dict(
(f, getattr(self, "shipping_%s" % f)) for f in self.ADDRESS_FIELDS
)
return {"billing": billing, "shipping": shipping}
@classmethod
def address_fields(cls, prefix=""):
return ["%s%s" % (prefix, f) for f in cls.ADDRESS_FIELDS]
[docs]@python_2_unicode_compatible
class Order(BillingShippingAddress):
"""The main order model. Used for carts and orders alike."""
#: Order object is a cart.
CART = 10
#: Checkout process has started.
CHECKOUT = 20
#: Order has been confirmed, but it not (completely) paid for yet.
CONFIRMED = 30
#: For invoice payment methods, when waiting for the money
PENDING = 35
#: Order has been completely paid for.
PAID = 40
#: Order has been completed. Plata itself never sets this state,
#: it is only meant for use by the shop owners.
COMPLETED = 50
STATUS_CHOICES = (
(CART, _("Is a cart")),
(CHECKOUT, _("Checkout process started")),
(CONFIRMED, _("Order has been confirmed")),
(PENDING, _("Order is pending payment")),
(PAID, _("Order has been paid")),
(COMPLETED, _("Order has been completed")),
)
created = models.DateTimeField(_("created"), default=timezone.now)
confirmed = models.DateTimeField(_("confirmed"), blank=True, null=True)
user = models.ForeignKey(
getattr(settings, "AUTH_USER_MODEL", "auth.User"),
blank=True,
null=True,
verbose_name=_("user"),
related_name="orders",
on_delete=models.SET_NULL,
)
language_code = models.CharField(
_("language"), max_length=10, default="", blank=True
)
status = models.PositiveIntegerField(
_("status"), choices=STATUS_CHOICES, default=CART
)
_order_id = models.CharField(_("order ID"), max_length=20, blank=True)
email = models.EmailField(_("e-mail address"))
currency = CurrencyField()
price_includes_tax = models.BooleanField(
_("price includes tax"), default=plata.settings.PLATA_PRICE_INCLUDES_TAX
)
items_subtotal = models.DecimalField(
_("subtotal"), max_digits=18, decimal_places=2, default=Decimal("0.00")
)
items_discount = models.DecimalField(
_("items discount"), max_digits=18, decimal_places=2, default=Decimal("0.00")
)
items_tax = models.DecimalField(
_("items tax"), max_digits=18, decimal_places=2, default=Decimal("0.00")
)
shipping_method = models.CharField(_("shipping method"), max_length=100, blank=True)
shipping_cost = models.DecimalField(
_("shipping cost"), max_digits=18, decimal_places=2, blank=True, null=True
)
shipping_discount = models.DecimalField(
_("shipping discount"), max_digits=18, decimal_places=2, blank=True, null=True
)
shipping_tax = models.DecimalField(
_("shipping tax"), max_digits=18, decimal_places=2, default=Decimal("0.00")
)
total = models.DecimalField(
_("total"), max_digits=18, decimal_places=2, default=Decimal("0.00")
)
paid = models.DecimalField(
_("paid"),
max_digits=18,
decimal_places=2,
default=Decimal("0.00"),
help_text=_("This much has been paid already."),
)
notes = models.TextField(_("notes"), blank=True)
data = JSONField(
_("data"),
blank=True,
help_text=_("JSON-encoded additional data about the order payment."),
default=dict,
)
class Meta:
verbose_name = _("order")
verbose_name_plural = _("orders")
get_latest_by = "created"
def __str__(self):
return self.order_id
[docs] def save(self, *args, **kwargs):
"""Sequential order IDs for completed orders."""
if not self._order_id and self.status >= self.PAID:
try:
order = Order.objects.exclude(_order_id="").order_by("-_order_id")[0]
latest = int(re.sub(r"[^0-9]", "", order._order_id))
except (IndexError, ValueError):
latest = 0
self._order_id = "O-%09d" % (latest + 1)
super(Order, self).save(*args, **kwargs)
save.alters_data = True
@property
def order_id(self):
"""
Returns ``_order_id`` (if it has been set) or a generic ID for this
order.
"""
if self._order_id:
return self._order_id
return ugettext("No. %d") % self.id
[docs] def recalculate_total(self, save=True):
"""
Recalculates totals, discounts, taxes.
"""
items = list(self.items.all())
shared_state = {}
processor_classes = [
get_callable(processor)
for processor in plata.settings.PLATA_ORDER_PROCESSORS
]
for p in (cls(shared_state) for cls in processor_classes):
p.process(self, items)
if save:
self.save()
[item.save() for item in items]
@property
def subtotal(self):
"""
Returns the order subtotal.
"""
# TODO: What about shipping?
return sum(
(item.subtotal for item in self.items.all()), Decimal("0.00")
).quantize(Decimal("0.00"))
@property
def discount(self):
"""
Returns the discount total.
"""
# TODO: What about shipping?
return (
sum((item.subtotal for item in self.items.all()), Decimal("0.00"))
- sum(
(item.discounted_subtotal for item in self.items.all()), Decimal("0.00")
)
).quantize(Decimal("0.00"))
@property
def shipping(self):
"""
Returns the shipping cost, with or without tax depending on this
order's ``price_includes_tax`` field.
"""
if self.price_includes_tax:
if self.shipping_cost is None:
return None
return self.shipping_cost - self.shipping_discount + self.shipping_tax
else:
logger.error(
"Shipping calculation with"
" PLATA_PRICE_INCLUDES_TAX=False is not implemented yet"
)
raise NotImplementedError
@property
def tax(self):
"""
Returns the tax total for this order, meaning tax on order items and
tax on shipping.
"""
return (self.items_tax + self.shipping_tax).quantize(Decimal("0.00"))
@property
def balance_remaining(self):
"""
Returns the balance which needs to be paid by the customer to fully
pay this order. This value is not necessarily the same as the order
total, because there can be more than one order payment in principle.
"""
return (self.total - self.paid).quantize(Decimal("0.00"))
def is_paid(self):
import warnings
warnings.warn(
"Order.is_paid() has been deprecated because its name is"
" misleading. Test for `order.status >= order.PAID` or"
" `not order.balance_remaining yourself.",
DeprecationWarning,
stacklevel=2,
)
return self.balance_remaining <= 0
#: This validator is always called; basic consistency checks such as
#: whether the currencies in the order match should be added here.
VALIDATE_BASE = 10
#: A cart which fails the criteria added to the ``VALIDATE_CART`` group
#: isn't considered a valid cart and the user cannot proceed to the
#: checkout form. Stuff such as stock checking, minimal order total
#: checking, or maximal items checking might be added here.
VALIDATE_CART = 20
#: This should not be used while registering a validator, it's mostly
#: useful as an argument to :meth:`~plata.shop.models.Order.validate`
#: when you want to run all validators.
VALIDATE_ALL = 100
VALIDATORS = {}
[docs] @classmethod
def register_validator(cls, validator, group):
"""
Registers another order validator in a validation group
A validator is a callable accepting an order (and only an order).
There are several types of order validators:
- Base validators are always called
- Cart validators: Need to validate for a valid cart
- Checkout validators: Need to validate in the checkout process
"""
cls.VALIDATORS.setdefault(group, []).append(validator)
[docs] def validate(self, group):
"""
Validates this order
The argument determines which order validators are called:
- ``Order.VALIDATE_BASE``
- ``Order.VALIDATE_CART``
- ``Order.VALIDATE_CHECKOUT``
- ``Order.VALIDATE_ALL``
"""
for g in sorted(g for g in self.VALIDATORS.keys() if g <= group):
for validator in self.VALIDATORS[g]:
validator(self)
[docs] def is_confirmed(self):
"""
Returns ``True`` if this order has already been confirmed and
therefore cannot be modified anymore.
"""
return self.status >= self.CONFIRMED
[docs] def modify_item(
self,
product,
relative=None,
absolute=None,
recalculate=True,
data=None,
item=None,
force_new=False,
):
"""
Updates order with the given product
- ``relative`` or ``absolute``: Add/subtract or define order item
amount exactly
- ``recalculate``: Recalculate order after cart modification
(defaults to ``True``)
- ``data``: Additional data for the order item; replaces the contents
of the JSON field if it is not ``None``. Pass an empty dictionary
if you want to reset the contents.
- ``item``: The order item which should be modified. Will be
automatically detected using the product if unspecified.
- ``force_new``: Force the creation of a new order item, even if the
product exists already in the cart (especially useful if the
product is configurable).
Returns the ``OrderItem`` instance; if quantity is zero, the order
item instance is deleted, the ``pk`` attribute set to ``None`` but
the order item is returned anyway.
"""
assert (relative is None) != (
absolute is None
), "One of relative or absolute must be provided."
assert not (
force_new and item
), "Cannot set item and force_new at the same time."
if self.is_confirmed():
raise ValidationError(
_("Cannot modify order once it has been confirmed."),
code="order_sealed",
)
if item is None and not force_new:
try:
item = self.items.get(product=product)
except self.items.model.DoesNotExist:
# Ok, product does not exist in cart yet.
pass
except self.items.model.MultipleObjectsReturned:
# Oops. Product already exists several times. Stay on the
# safe side and add a new one instead of trying to modify
# another.
if not force_new:
raise ValidationError(
_(
"The product already exists several times in the"
" cart, and neither item nor force_new were"
" given."
),
code="multiple",
)
if item is None:
item = self.items.model(
order=self, product=product, quantity=0, currency=self.currency
)
if relative is not None:
item.quantity += relative
else:
item.quantity = absolute
if item.quantity > 0:
if data is not None:
item.data = data
try:
price = product.get_price(currency=self.currency, orderitem=item)
except ObjectDoesNotExist:
logger.error(
"No price could be found for %s with currency %s"
% (product, self.currency)
)
raise ValidationError(
_("The price could not be determined."), code="unknown_price"
)
price.handle_order_item(item)
product.handle_order_item(item)
item.save()
else:
if item.pk:
item.delete()
item.pk = None
if self.data == "":
# happens if the cart is new, might be an error of JSONField
self.data = {}
if recalculate:
self.recalculate_total()
# Reload item instance from DB to preserve field values
# changed in recalculate_total
if item.pk:
item = self.items.get(pk=item.pk)
try:
self.validate(self.VALIDATE_BASE)
except ValidationError:
if item.pk:
item.delete()
raise
return item
@property
def discount_remaining(self):
"""Remaining discount amount excl. tax"""
return self.applied_discounts.remaining()
[docs] def update_status(self, status, notes):
"""
Update the order status
"""
if status >= Order.CHECKOUT:
if not self.items.count():
raise ValidationError(
_("Cannot proceed to checkout without order items."),
code="order_empty",
)
logger.info("Promoting %s to status %s" % (self, status))
instance = OrderStatus(order=self, status=status, notes=notes)
instance.save()
[docs] def reload(self):
"""
Return this order instance, reloaded from the database
Used f.e. inside the payment processors when adding new payment
records etc.
"""
return self.__class__._default_manager.get(pk=self.id)
[docs] def items_in_order(self):
"""
Returns the item count in the order
This is different from ``order.items.count()`` because it counts items,
not distinct products.
"""
return self.items.aggregate(q=Sum("quantity"))["q"] or 0
[docs]def validate_order_currencies(order):
"""Check whether order contains more than one or an invalid currency"""
currencies = set(order.items.values_list("currency", flat=True))
if currencies and (len(currencies) > 1 or order.currency not in currencies):
raise ValidationError(
_("Order contains more than one currency."), code="multiple_currency"
)
Order.register_validator(validate_order_currencies, Order.VALIDATE_BASE)
[docs]@python_2_unicode_compatible
class OrderItem(models.Model):
"""Single order line item"""
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="items")
product = models.ForeignKey(
plata.settings.PLATA_SHOP_PRODUCT,
verbose_name=_("product"),
blank=True,
null=True,
on_delete=models.SET_NULL,
)
name = models.CharField(_("name"), max_length=100, blank=True)
sku = models.CharField(_("SKU"), max_length=100, blank=True)
quantity = models.IntegerField(_("quantity"))
currency = CurrencyField()
_unit_price = models.DecimalField(
_("unit price"),
max_digits=18,
decimal_places=10,
help_text=_("Unit price excl. tax"),
)
_unit_tax = models.DecimalField(_("unit tax"), max_digits=18, decimal_places=10)
tax_rate = models.DecimalField(_("tax rate"), max_digits=10, decimal_places=2)
tax_class = models.ForeignKey(
TaxClass,
verbose_name=_("tax class"),
blank=True,
null=True,
on_delete=models.SET_NULL,
)
is_sale = models.BooleanField(_("is sale"), default=False)
_line_item_price = models.DecimalField(
_("line item price"),
max_digits=18,
decimal_places=10,
default=0,
help_text=_("Line item price excl. tax"),
)
_line_item_discount = models.DecimalField(
_("line item discount"),
max_digits=18,
decimal_places=10,
blank=True,
null=True,
help_text=_("Discount excl. tax"),
)
_line_item_tax = models.DecimalField(
_("line item tax"), max_digits=18, decimal_places=10, default=0
)
data = JSONField(
_("data"),
blank=True,
help_text=_("JSON-encoded additional data about the order payment."),
default=dict,
)
class Meta:
ordering = ("product",)
verbose_name = _("order item")
verbose_name_plural = _("order items")
def __str__(self):
return _("%(quantity)s of %(name)s") % {
"quantity": self.quantity,
"name": self.name,
}
@property
def unit_price(self):
if self.order.price_includes_tax:
return self._unit_price + self._unit_tax
return self._unit_price
@property
def line_item_discount_excl_tax(self):
return self._line_item_discount or 0
@property
def line_item_discount_incl_tax(self):
return self.line_item_discount_excl_tax * (1 + self.tax_rate / 100)
@property
def line_item_discount(self):
if self.order.price_includes_tax:
return self.line_item_discount_incl_tax
else:
return self.line_item_discount_excl_tax
@property
def subtotal(self):
return self.unit_price * self.quantity
@property
def discounted_subtotal_excl_tax(self):
return self._line_item_price - (self._line_item_discount or 0)
@property
def discounted_subtotal_incl_tax(self):
return self.discounted_subtotal_excl_tax + self._line_item_tax
@property
def discounted_subtotal(self):
if self.order.price_includes_tax:
return self.discounted_subtotal_incl_tax
else:
return self.discounted_subtotal_excl_tax
[docs]@python_2_unicode_compatible
class OrderStatus(models.Model):
"""
Order status
Stored in separate model so that the order status changes stay
visible for analysis after the fact.
"""
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="statuses")
created = models.DateTimeField(_("created"), default=timezone.now)
status = models.PositiveIntegerField(_("status"), choices=Order.STATUS_CHOICES)
notes = models.TextField(_("notes"), blank=True)
class Meta:
ordering = ("created", "id")
verbose_name = _("order status")
verbose_name_plural = _("order statuses")
def __str__(self):
return _("Status %(status)s for %(order)s") % {
"status": self.get_status_display(),
"order": self.order,
}
def save(self, *args, **kwargs):
super(OrderStatus, self).save(*args, **kwargs)
self.order.status = self.status
if self.status == Order.CONFIRMED:
self.order.confirmed = timezone.now()
elif self.status > Order.CONFIRMED and not self.order.confirmed:
self.order.confirmed = timezone.now()
elif self.status < Order.CONFIRMED:
# Ensure that the confirmed date is not set
self.order.confirmed = None
self.order.save()
save.alters_data = True
class OrderPaymentManager(models.Manager):
def pending(self):
return self.filter(status=self.model.PENDING)
def authorized(self):
return self.filter(authorized__isnull=False)
[docs]@python_2_unicode_compatible
class OrderPayment(models.Model):
"""
Order payment
Stores additional data from the payment interface for analysis
and accountability.
"""
PENDING = 10
PROCESSED = 20
AUTHORIZED = 30
STATUS_CHOICES = (
(PENDING, _("pending")),
(PROCESSED, _("processed")),
(AUTHORIZED, _("authorized")),
)
order = models.ForeignKey(
Order,
on_delete=models.CASCADE,
verbose_name=_("order"),
related_name="payments",
)
timestamp = models.DateTimeField(_("timestamp"), default=timezone.now)
status = models.PositiveIntegerField(
_("status"), choices=STATUS_CHOICES, default=PENDING
)
currency = CurrencyField()
amount = models.DecimalField(_("amount"), max_digits=10, decimal_places=2)
payment_module_key = models.CharField(
_("payment module key"),
max_length=20,
help_text=_("Machine-readable identifier for the payment module used."),
)
payment_module = models.CharField(
_("payment module"),
max_length=50,
blank=True,
help_text=_("For example 'Cash on delivery', 'PayPal', ..."),
)
payment_method = models.CharField(
_("payment method"),
max_length=50,
blank=True,
help_text=_("For example 'MasterCard', 'VISA' or some other card."),
)
transaction_id = models.CharField(
_("transaction ID"),
max_length=50,
blank=True,
help_text=_("Unique ID identifying this payment in the foreign system."),
)
authorized = models.DateTimeField(
_("authorized"),
blank=True,
null=True,
help_text=_("Point in time when payment has been authorized."),
)
notes = models.TextField(_("notes"), blank=True)
data = JSONField(
_("data"),
blank=True,
help_text=_("JSON-encoded additional data about the order payment."),
default=dict,
)
transaction_fee = models.DecimalField(
_("transaction fee"),
max_digits=10,
decimal_places=2,
null=True,
blank=True,
help_text=_("Fee charged by the payment processor."),
)
class Meta:
ordering = ("-timestamp",)
verbose_name = _("order payment")
verbose_name_plural = _("order payments")
objects = OrderPaymentManager()
def __str__(self):
return _("%(authorized)s of %(currency)s %(amount).2f for %(order)s") % {
"authorized": (self.authorized and _("Authorized") or _("Not authorized")),
"currency": self.currency,
"amount": self.amount,
"order": self.order,
}
def _recalculate_paid(self):
paid = (
OrderPayment.objects.authorized()
.filter(order=self.order_id, currency=F("order__currency"))
.aggregate(total=Sum("amount"))["total"]
or 0
)
Order.objects.filter(id=self.order_id).update(paid=paid)
def save(self, *args, **kwargs):
super(OrderPayment, self).save(*args, **kwargs)
self._recalculate_paid()
if self.currency != self.order.currency:
self.order.notes += (
"\n" + _("Currency of payment %s does not match.") % self
)
self.order.save()
save.alters_data = True
def delete(self, *args, **kwargs):
super(OrderPayment, self).delete(*args, **kwargs)
self._recalculate_paid()
delete.alters_data = True
[docs]@python_2_unicode_compatible
class PriceBase(models.Model):
"""
Price for a given product, currency, tax class and time period
Prices should not be changed or deleted but replaced by more recent
prices. (Deleting old prices does not hurt, but the price history cannot
be reconstructed anymore if you'd need it.)
The concrete implementation needs to provide a foreign key to the
product model.
"""
class Meta:
abstract = True
ordering = ["-id"]
verbose_name = _("price")
verbose_name_plural = _("prices")
currency = CurrencyField()
_unit_price = models.DecimalField(_("unit price"), max_digits=18, decimal_places=10)
tax_included = models.BooleanField(
_("tax included"),
help_text=_("Is tax included in given unit price?"),
default=plata.settings.PLATA_PRICE_INCLUDES_TAX,
)
tax_class = models.ForeignKey(
TaxClass,
on_delete=models.CASCADE,
verbose_name=_("tax class"),
related_name="+",
)
def __str__(self):
return _("%(currency)s %(value).2f") % {
"currency": self.currency,
"value": self._unit_price,
}
def __cmp__(self, other):
return int((self.unit_price_excl_tax - other.unit_price_excl_tax) * 100)
def __hash__(self):
return int(self.unit_price_excl_tax * 100)
[docs] def handle_order_item(self, item):
"""
Set price data on the ``OrderItem`` passed
"""
item._unit_price = self.unit_price_excl_tax
item._unit_tax = self.unit_tax
item.tax_rate = self.tax_class.rate
item.tax_class = self.tax_class
@property
def unit_tax(self):
return self.unit_price_excl_tax * (self.tax_class.rate / 100)
@property
def unit_price_incl_tax(self):
if self.tax_included:
return self._unit_price
return self._unit_price * (1 + self.tax_class.rate / 100)
@property
def unit_price_excl_tax(self):
if not self.tax_included:
return self._unit_price
return self._unit_price / (1 + self.tax_class.rate / 100)
@property
def unit_price(self):
# TODO Fix this. We _should_ use shop.price_includes_tax here,
# but there's no request and no order around...
return self.unit_price_incl_tax