# This file is part of Shuup.
#
# Copyright (c) 2012-2021, Shuup Commerce Inc. All rights reserved.
#
# This source code is licensed under the OSL-3.0 license found in the
# LICENSE file in the root directory of this source tree.
from django.db import models
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _
from enumfields import Enum, EnumIntegerField
from polymorphic.models import PolymorphicModel
from shuup.campaigns.utils.campaigns import get_product_ids_and_quantities, get_total_price_of_products
from shuup.campaigns.utils.time_range import is_in_time_range
from shuup.core.fields import MoneyValueField
from shuup.core.models import Category, Contact, ContactGroup, Product, ProductMode, ShopProduct
from shuup.core.pricing import PricingContext
from shuup.utils.django_compat import force_text
from shuup.utils.properties import MoneyPropped, PriceProperty
[docs]class BasketCondition(PolymorphicModel):
model = None
active = models.BooleanField(default=True)
name = _("Basket condition")
[docs] def matches(self, basket, lines):
return False
def __str__(self):
return force_text(self.name)
[docs]class BasketTotalProductAmountCondition(BasketCondition):
identifier = "basket_product_condition"
name = _("Basket product count")
product_count = models.DecimalField(
verbose_name=_("product count in basket"), blank=True, null=True, max_digits=36, decimal_places=9
)
[docs] def matches(self, basket, lines):
# if the campaign has a supplier, count only products from that supplier
campaign = self.campaign.first()
supplier = campaign.supplier if hasattr(campaign, "supplier") and campaign.supplier else None
return basket.count_products(supplier) >= self.product_count
@property
def description(self):
return _("Limit the campaign to match when basket has at least the product count entered here.")
@property
def value(self):
return self.product_count
@value.setter
def value(self, value):
self.product_count = value
[docs]class BasketTotalAmountCondition(MoneyPropped, BasketCondition):
identifier = "basket_amount_condition"
name = _("Basket total value")
amount = PriceProperty("amount_value", "campaign.shop.currency", "campaign.shop.prices_include_tax")
amount_value = MoneyValueField(default=None, blank=True, null=True, verbose_name=_("basket total amount"))
[docs] def matches(self, basket, lines):
campaign = self.campaign.first()
total_of_products = get_total_price_of_products(basket, campaign)
return total_of_products.value >= self.amount_value
@property
def description(self):
return _("Limit the campaign to match when it has at least the total value entered here worth of products.")
@property
def value(self):
return self.amount_value
@value.setter
def value(self, value):
self.amount_value = value
[docs]class BasketTotalUndiscountedProductAmountCondition(MoneyPropped, BasketCondition):
identifier = "basket_amount_condition_undiscounted"
name = _("Undiscounted basket total value")
amount = PriceProperty("amount_value", "campaign.shop.currency", "campaign.shop.prices_include_tax")
amount_value = MoneyValueField(default=None, blank=True, null=True, verbose_name=_("basket total amount"))
[docs] def matches(self, basket, lines):
from shuup.campaigns.models import CatalogCampaign
campaign = self.campaign.first()
total_of_products = get_total_price_of_products(basket, campaign)
product_lines = basket.get_product_lines()
if hasattr(campaign, "supplier") and campaign.supplier:
product_lines = [line for line in product_lines if line.supplier == campaign.supplier]
total_undiscounted_price_value = total_of_products.value
shop = basket.shop
context = PricingContext(shop, basket.customer)
for line in product_lines:
if CatalogCampaign.get_matching(context, line.product.get_shop_instance(shop)):
total_undiscounted_price_value -= line.price.value
return total_undiscounted_price_value >= self.amount_value
@property
def description(self):
return _(
"Limit the campaign to match when it has at least the total value "
"entered here worth of products which doesn't have already discounts."
)
@property
def value(self):
return self.amount_value
@value.setter
def value(self, value):
self.amount_value = value
[docs]class BasketMaxTotalProductAmountCondition(BasketCondition):
identifier = "basket_max_product_condition"
name = _("Basket maximum product count")
product_count = models.DecimalField(
verbose_name=_("maximum product count in basket"), blank=True, null=True, max_digits=36, decimal_places=9
)
[docs] def matches(self, basket, lines):
campaign = self.campaign.first()
supplier = campaign.supplier if hasattr(campaign, "supplier") and campaign.supplier else None
return basket.count_products(supplier) <= self.product_count
@property
def description(self):
return _("Limit the campaign to match when basket has at maximum the product count entered here.")
@property
def value(self):
return self.product_count
@value.setter
def value(self, value):
self.product_count = value
[docs]class BasketMaxTotalAmountCondition(MoneyPropped, BasketCondition):
identifier = "basket_max_amount_condition"
name = _("Basket maximum total value")
amount = PriceProperty("amount_value", "campaign.shop.currency", "campaign.shop.prices_include_tax")
amount_value = MoneyValueField(default=None, blank=True, null=True, verbose_name=_("maximum basket total amount"))
[docs] def matches(self, basket, lines):
campaign = self.campaign.first()
total_of_products = get_total_price_of_products(basket, campaign)
return total_of_products.value <= self.amount_value
@property
def description(self):
return _("Limit the campaign to match when it has at maximum the total value entered here worth of products.")
@property
def value(self):
return self.amount_value
@value.setter
def value(self, value):
self.amount_value = value
[docs]class ComparisonOperator(Enum):
EQUALS = 0
GTE = 1
class Labels:
EQUALS = _("Exactly")
GTE = _("Greater than or equal to")
[docs]class ProductsInBasketCondition(BasketCondition):
identifier = "basket_products_condition"
name = _("Products in basket")
model = Product
operator = EnumIntegerField(ComparisonOperator, default=ComparisonOperator.GTE, verbose_name=_("operator"))
quantity = models.PositiveIntegerField(default=1, verbose_name=_("quantity"))
products = models.ManyToManyField(Product, verbose_name=_("products"), blank=True)
[docs] def matches(self, basket, lines):
campaign = self.campaign.first()
supplier = campaign.supplier if hasattr(campaign, "supplier") and campaign.supplier else None
product_id_to_qty = get_product_ids_and_quantities(basket, supplier)
product_ids = self.products.filter(id__in=product_id_to_qty.keys()).values_list("id", flat=True)
for product_id in product_ids:
if self.operator == ComparisonOperator.GTE:
return product_id_to_qty[product_id] >= self.quantity
elif self.operator == ComparisonOperator.EQUALS:
return product_id_to_qty[product_id] == self.quantity
return False
@property
def description(self):
return _("Limit the campaign to have the selected products in basket.")
@property
def values(self):
return self.products
@values.setter
def values(self, value):
self.products.set(value)
[docs]class CategoryProductsBasketCondition(BasketCondition):
model = Category
identifier = "basket_category_condition"
name = _("Category products in basket")
operator = EnumIntegerField(ComparisonOperator, default=ComparisonOperator.GTE, verbose_name=_("operator"))
quantity = models.PositiveIntegerField(default=1, verbose_name=_("quantity"))
categories = models.ManyToManyField(Category, related_name="+", verbose_name=_("categories"))
excluded_categories = models.ManyToManyField(
Category,
blank=True,
related_name="+",
verbose_name=_("excluded categories"),
help_text=_(
"If the customer has even a single product in the basket from these categories "
"this rule won't match thus the campaign cannot be applied to the basket."
),
)
[docs] def matches(self, basket, lines):
product_id_to_qty = get_product_ids_and_quantities(basket)
if ShopProduct.objects.filter(
product_id__in=product_id_to_qty.keys(), categories__in=self.excluded_categories.all()
).exists():
return False
product_ids = ShopProduct.objects.filter(
categories__in=self.categories.all(), product_id__in=product_id_to_qty.keys()
).values_list("product_id", flat=True)
product_count = sum(product_id_to_qty[product_id] for product_id in product_ids)
if self.operator == ComparisonOperator.EQUALS:
return bool(product_count == self.quantity)
else:
return bool(product_count >= self.quantity)
@property
def description(self):
return _("Limit the campaign to match the products from selected categories.")
[docs]class HourBasketCondition(BasketCondition):
identifier = "hour_condition"
name = _("Day and hour")
hour_start = models.TimeField(
verbose_name=_("start time"), help_text=_("12pm is considered noon and 12am as midnight.")
)
hour_end = models.TimeField(
verbose_name=_("end time"),
help_text=_("12pm is considered noon and 12am as midnight. End time is not considered match."),
)
days = models.CharField(max_length=255, verbose_name=_("days"))
[docs] def matches(self, basket, lines):
return is_in_time_range(timezone.now(), self.hour_start, self.hour_end, self.values)
@property
def description(self):
return _("Limit the campaign to selected days.")
@property
def values(self):
return [v for v in map(int, self.days.split(","))]
[docs]class ChildrenProductCondition(BasketCondition):
identifier = "is_product_child_condition"
name = _("Product Variation Child")
model = Product
product = models.ForeignKey(
Product,
on_delete=models.PROTECT,
limit_choices_to={"mode__in": [ProductMode.SIMPLE_VARIATION_PARENT, ProductMode.VARIABLE_VARIATION_PARENT]},
null=True,
)
[docs] def matches(self, basket, lines):
for line in basket.get_lines():
if not line.product:
continue
if line.product.variation_parent == self.product:
return True
return False
@property
def description(self):
return _("Limit the campaign to match only variation children of the selected product.")
@property
def values(self):
return self.product
@values.setter
def values(self, value):
self.product = value