from decimal import Decimal as D, ROUND_DOWN, ROUND_UP from django.core import exceptions from django.db.models import get_model from django.template.defaultfilters import date as date_filter from django.db import models from django.utils.timezone import now, get_current_timezone from django.utils.translation import ungettext, ugettext as _ from django.utils.importlib import import_module from django.core.exceptions import ValidationError from django.core.urlresolvers import reverse from django.conf import settings from oscar.core.utils import slugify from oscar.apps.offer.managers import ActiveOfferManager from oscar.templatetags.currency_filters import currency from oscar.models.fields import PositiveDecimalField, ExtendedURLField from oscar.core.loading import get_class BrowsableRangeManager = get_class('offer.managers', 'BrowsableRangeManager') def load_proxy(proxy_class): module, classname = proxy_class.rsplit('.', 1) try: mod = import_module(module) except ImportError, e: raise exceptions.ImproperlyConfigured( "Error importing module %s: %s" % (module, e)) try: return getattr(mod, classname) except AttributeError: raise exceptions.ImproperlyConfigured( "Module %s does not define a %s" % (module, classname)) def range_anchor(range): return '%s' % ( reverse('dashboard:range-update', kwargs={'pk': range.pk}), range.name) class ConditionalOffer(models.Model): """ A conditional offer (eg buy 1, get 10% off) """ name = models.CharField( _("Name"), max_length=128, unique=True, help_text=_("This is displayed within the customer's basket")) slug = models.SlugField(_("Slug"), max_length=128, unique=True, null=True) description = models.TextField(_("Description"), blank=True, help_text=_("This is displayed on the offer browsing page")) # Offers come in a few different types: # (a) Offers that are available to all customers on the site. Eg a # 3-for-2 offer. # (b) Offers that are linked to a voucher, and only become available once # that voucher has been applied to the basket # (c) Offers that are linked to a user. Eg, all students get 10% off. The # code to apply this offer needs to be coded # (d) Session offers - these are temporarily available to a user after some # trigger event. Eg, users coming from some affiliate site get 10% # off. SITE, VOUCHER, USER, SESSION = ("Site", "Voucher", "User", "Session") TYPE_CHOICES = ( (SITE, _("Site offer - available to all users")), (VOUCHER, _("Voucher offer - only available after entering " "the appropriate voucher code")), (USER, _("User offer - available to certain types of user")), (SESSION, _("Session offer - temporary offer, available for " "a user for the duration of their session")), ) offer_type = models.CharField( _("Type"), choices=TYPE_CHOICES, default=SITE, max_length=128) # We track a status variable so it's easier to load offers that are # 'available' in some sense. OPEN, SUSPENDED, CONSUMED = "Open", "Suspended", "Consumed" status = models.CharField(_("Status"), max_length=64, default=OPEN) condition = models.ForeignKey( 'offer.Condition', verbose_name=_("Condition")) benefit = models.ForeignKey('offer.Benefit', verbose_name=_("Benefit")) # Some complicated situations require offers to be applied in a set order. priority = models.IntegerField(_("Priority"), default=0, help_text=_("The highest priority offers are applied first")) # AVAILABILITY # Range of availability. Note that if this is a voucher offer, then these # dates are ignored and only the dates from the voucher are used to # determine availability. start_datetime = models.DateTimeField(_("Start date"), blank=True, null=True) end_datetime = models.DateTimeField( _("End date"), blank=True, null=True, help_text=_("Offers are active until the end of the 'end date'")) # Use this field to limit the number of times this offer can be applied in # total. Note that a single order can apply an offer multiple times so # this is not the same as the number of orders that can use it. max_global_applications = models.PositiveIntegerField( _("Max global applications"), help_text=_("The number of times this offer can be used before it " "is unavailable"), blank=True, null=True) # Use this field to limit the number of times this offer can be used by a # single user. This only works for signed-in users - it doesn't really # make sense for sites that allow anonymous checkout. max_user_applications = models.PositiveIntegerField( _("Max user applications"), help_text=_("The number of times a single user can use this offer"), blank=True, null=True) # Use this field to limit the number of times this offer can be applied to # a basket (and hence a single order). max_basket_applications = models.PositiveIntegerField( _("Max basket applications"), blank=True, null=True, help_text=_("The number of times this offer can be applied to a " "basket (and order)")) # Use this field to limit the amount of discount an offer can lead to. # This can be helpful with budgeting. max_discount = models.DecimalField( _("Max discount"), decimal_places=2, max_digits=12, null=True, blank=True, help_text=_("When an offer has given more discount to orders " "than this threshold, then the offer becomes " "unavailable")) # TRACKING total_discount = models.DecimalField( _("Total Discount"), decimal_places=2, max_digits=12, default=D('0.00')) num_applications = models.PositiveIntegerField( _("Number of applications"), default=0) num_orders = models.PositiveIntegerField( _("Number of Orders"), default=0) redirect_url = ExtendedURLField(_("URL redirect (optional)"), blank=True) date_created = models.DateTimeField(_("Date Created"), auto_now_add=True) objects = models.Manager() active = ActiveOfferManager() # We need to track the voucher that this offer came from (if it is a # voucher offer) _voucher = None class Meta: ordering = ['-priority'] verbose_name = _("Conditional offer") verbose_name_plural = _("Conditional offers") # The way offers are looked up involves the fields (offer_type, status, # start_datetime, end_datetime). Ideally, you want a DB index that # covers these 4 fields (will add support for this in Django 1.5) def save(self, *args, **kwargs): if not self.slug: self.slug = slugify(self.name) # Check to see if consumption thresholds have been broken if not self.is_suspended: if self.get_max_applications() == 0: self.status = self.CONSUMED else: self.status = self.OPEN return super(ConditionalOffer, self).save(*args, **kwargs) def get_absolute_url(self): return reverse('offer:detail', kwargs={'slug': self.slug}) def __unicode__(self): return self.name def clean(self): if (self.start_datetime and self.end_datetime and self.start_datetime > self.end_datetime): raise exceptions.ValidationError( _('End date should be later than start date')) @property def is_open(self): return self.status == self.OPEN @property def is_suspended(self): return self.status == self.SUSPENDED def suspend(self): self.status = self.SUSPENDED self.save() suspend.alters_data = True def unsuspend(self): self.status = self.OPEN self.save() suspend.alters_data = True def is_available(self, user=None, test_date=None): """ Test whether this offer is available to be used """ if self.is_suspended: return False if test_date is None: test_date = now() predicates = [] if self.start_datetime: predicates.append(self.start_datetime > test_date) if self.end_datetime: predicates.append(test_date > self.end_datetime) if any(predicates): return 0 return self.get_max_applications(user) > 0 def is_condition_satisfied(self, basket): return self.condition.proxy().is_satisfied(basket) def is_condition_partially_satisfied(self, basket): return self.condition.proxy().is_partially_satisfied(basket) def get_upsell_message(self, basket): return self.condition.proxy().get_upsell_message(basket) def apply_benefit(self, basket): """ Applies the benefit to the given basket and returns the discount. """ if not self.is_condition_satisfied(basket): return ZERO_DISCOUNT return self.benefit.proxy().apply( basket, self.condition.proxy(), self) def apply_deferred_benefit(self, basket): """ Applies any deferred benefits. These are things like adding loyalty points to somone's account. """ return self.benefit.proxy().apply_deferred(basket) def set_voucher(self, voucher): self._voucher = voucher def get_voucher(self): return self._voucher def get_max_applications(self, user=None): """ Return the number of times this offer can be applied to a basket for a given user. """ if self.max_discount and self.total_discount >= self.max_discount: return 0 # Hard-code a maximum value as we need some sensible upper limit for # when there are not other caps. limits = [10000] if self.max_user_applications and user: limits.append(max(0, self.max_user_applications - self.get_num_user_applications(user))) if self.max_basket_applications: limits.append(self.max_basket_applications) if self.max_global_applications: limits.append( max(0, self.max_global_applications - self.num_applications)) return min(limits) def get_num_user_applications(self, user): OrderDiscount = models.get_model('order', 'OrderDiscount') aggregates = OrderDiscount.objects.filter( offer_id=self.id, order__user=user).aggregate( total=models.Sum('frequency')) return aggregates['total'] if aggregates['total'] is not None else 0 def shipping_discount(self, charge): return self.benefit.proxy().shipping_discount(charge) def record_usage(self, discount): self.num_applications += discount['freq'] self.total_discount += discount['discount'] self.num_orders += 1 self.save() record_usage.alters_data = True def availability_description(self): """ Return a description of when this offer is available """ restrictions = self.availability_restrictions() descriptions = [r['description'] for r in restrictions] return "
".join(descriptions) def availability_restrictions(self): restrictions = [] if self.is_suspended: restrictions.append({ 'description': _("Offer is suspended"), 'is_satisfied': False}) if self.max_global_applications: remaining = self.max_global_applications - self.num_applications desc = _( "Limited to %(total)d uses " "(%(remainder)d remaining)") % { 'total': self.max_global_applications, 'remainder': remaining} restrictions.append({ 'description': desc, 'is_satisfied': remaining > 0}) if self.max_user_applications: if self.max_user_applications == 1: desc = _("Limited to 1 use per user") else: desc = _( "Limited to %(total)d uses per user") % { 'total': self.max_user_applications} restrictions.append({ 'description': desc, 'is_satisfied': True}) if self.max_basket_applications: if self.max_user_applications == 1: desc = _("Limited to 1 use per basket") else: desc = _( "Limited to %(total)d uses per basket") % { 'total': self.max_basket_applications} restrictions.append({ 'description': desc, 'is_satisfied': True}) def hide_time_if_zero(dt): # Only show hours/minutes if they have been specified localtime = dt.astimezone(get_current_timezone()) if localtime.hour == 0 and localtime.minute == 0: return date_filter(localtime, settings.DATE_FORMAT) return date_filter(localtime, settings.DATETIME_FORMAT) if self.start_datetime or self.end_datetime: today = now() if self.start_datetime and self.end_datetime: desc = _("Available between %(start)s and %(end)s") % { 'start': hide_time_if_zero(self.start_datetime), 'end': hide_time_if_zero(self.end_datetime)} is_satisfied = self.start_datetime <= today <= self.end_datetime elif self.start_datetime: desc = _("Available from %(start)s") % { 'start': hide_time_if_zero(self.start_datetime)} is_satisfied = today >= self.start_datetime elif self.end_datetime: desc = _("Available until %(end)s") % { 'end': hide_time_if_zero(self.end_datetime)} is_satisfied = today <= self.end_datetime restrictions.append({ 'description': desc, 'is_satisfied': is_satisfied}) if self.max_discount: desc = _("Limited to a cost of %(max)s") % { 'max': currency(self.max_discount)} restrictions.append({ 'description': desc, 'is_satisfied': self.total_discount < self.max_discount}) return restrictions @property def has_products(self): return self.condition.range is not None def products(self): """ Return a queryset of products in this offer """ Product = get_model('catalogue', 'Product') if not self.has_products: return Product.objects.none() cond_range = self.condition.range if cond_range.includes_all_products: # Return ALL the products return Product.browsable.select_related( 'product_class', 'stockrecord').filter( is_discountable=True).prefetch_related( 'variants', 'images', 'product_class__options', 'product_options') return cond_range.included_products.filter(is_discountable=True) class Condition(models.Model): COUNT, VALUE, COVERAGE = ("Count", "Value", "Coverage") TYPE_CHOICES = ( (COUNT, _("Depends on number of items in basket that are in " "condition range")), (VALUE, _("Depends on value of items in basket that are in " "condition range")), (COVERAGE, _("Needs to contain a set number of DISTINCT items " "from the condition range"))) range = models.ForeignKey( 'offer.Range', verbose_name=_("Range"), null=True, blank=True) type = models.CharField(_('Type'), max_length=128, choices=TYPE_CHOICES, null=True, blank=True) value = PositiveDecimalField(_('Value'), decimal_places=2, max_digits=12, null=True, blank=True) proxy_class = models.CharField(_("Custom class"), null=True, blank=True, max_length=255, unique=True, default=None) class Meta: verbose_name = _("Condition") verbose_name_plural = _("Conditions") def proxy(self): """ Return the proxy model """ field_dict = dict(self.__dict__) for field in field_dict.keys(): if field.startswith('_'): del field_dict[field] if self.proxy_class: klass = load_proxy(self.proxy_class) return klass(**field_dict) klassmap = { self.COUNT: CountCondition, self.VALUE: ValueCondition, self.COVERAGE: CoverageCondition} if self.type in klassmap: return klassmap[self.type](**field_dict) return self def __unicode__(self): return self.proxy().name @property def name(self): return self.description @property def description(self): return self.proxy().description def consume_items(self, basket, affected_lines): pass def is_satisfied(self, basket): """ Determines whether a given basket meets this condition. This is stubbed in this top-class object. The subclassing proxies are responsible for implementing it correctly. """ return False def is_partially_satisfied(self, basket): """ Determine if the basket partially meets the condition. This is useful for up-selling messages to entice customers to buy something more in order to qualify for an offer. """ return False def get_upsell_message(self, basket): return None def can_apply_condition(self, line): """ Determines whether the condition can be applied to a given basket line """ if not line.stockrecord: return False product = line.product return self.range.contains_product(product) and product.is_discountable def get_applicable_lines(self, basket, most_expensive_first=True): """ Return line data for the lines that can be consumed by this condition """ line_tuples = [] for line in basket.all_lines(): if not self.can_apply_condition(line): continue # We only include products where we know the tax charged if not line.stockinfo.price.is_tax_known: continue price = line.unit_price_incl_tax if not price: continue line_tuples.append((price, line)) if most_expensive_first: return sorted(line_tuples, reverse=True) return sorted(line_tuples) class Benefit(models.Model): range = models.ForeignKey( 'offer.Range', null=True, blank=True, verbose_name=_("Range")) # Benefit types PERCENTAGE, FIXED, MULTIBUY, FIXED_PRICE = ( "Percentage", "Absolute", "Multibuy", "Fixed price") SHIPPING_PERCENTAGE, SHIPPING_ABSOLUTE, SHIPPING_FIXED_PRICE = ( 'Shipping percentage', 'Shipping absolute', 'Shipping fixed price') TYPE_CHOICES = ( (PERCENTAGE, _("Discount is a percentage off of the product's value")), (FIXED, _("Discount is a fixed amount off of the product's value")), (MULTIBUY, _("Discount is to give the cheapest product for free")), (FIXED_PRICE, _("Get the products that meet the condition for a fixed price")), (SHIPPING_ABSOLUTE, _("Discount is a fixed amount of the shipping cost")), (SHIPPING_FIXED_PRICE, _("Get shipping for a fixed price")), (SHIPPING_PERCENTAGE, _("Discount is a percentage off of the shipping cost")), ) type = models.CharField( _("Type"), max_length=128, choices=TYPE_CHOICES, blank=True) # The value to use with the designated type. This can be either an integer # (eg for multibuy) or a decimal (eg an amount) which is slightly # confusing. value = PositiveDecimalField( _("Value"), decimal_places=2, max_digits=12, null=True, blank=True) # If this is not set, then there is no upper limit on how many products # can be discounted by this benefit. max_affected_items = models.PositiveIntegerField( _("Max Affected Items"), blank=True, null=True, help_text=_("Set this to prevent the discount consuming all items " "within the range that are in the basket.")) # A custom benefit class can be used instead. This means the # type/value/max_affected_items fields should all be None. proxy_class = models.CharField(_("Custom class"), null=True, blank=True, max_length=255, unique=True, default=None) class Meta: verbose_name = _("Benefit") verbose_name_plural = _("Benefits") def proxy(self): field_dict = dict(self.__dict__) for field in field_dict.keys(): if field.startswith('_'): del field_dict[field] if self.proxy_class: klass = load_proxy(self.proxy_class) return klass(**field_dict) klassmap = { self.PERCENTAGE: PercentageDiscountBenefit, self.FIXED: AbsoluteDiscountBenefit, self.MULTIBUY: MultibuyDiscountBenefit, self.FIXED_PRICE: FixedPriceBenefit, self.SHIPPING_ABSOLUTE: ShippingAbsoluteDiscountBenefit, self.SHIPPING_FIXED_PRICE: ShippingFixedPriceBenefit, self.SHIPPING_PERCENTAGE: ShippingPercentageDiscountBenefit} if self.type in klassmap: return klassmap[self.type](**field_dict) raise RuntimeError("Unrecognised benefit type (%s)" % self.type) def __unicode__(self): name = self.proxy().name if self.max_affected_items: name += ungettext( " (max %d item)", " (max %d items)", self.max_affected_items) % self.max_affected_items return name @property def name(self): return self.description @property def description(self): return self.proxy().description def apply(self, basket, condition, offer=None): return ZERO_DISCOUNT def apply_deferred(self, basket): return None def clean(self): if not self.type: return method_name = 'clean_%s' % self.type.lower().replace(' ', '_') if hasattr(self, method_name): getattr(self, method_name)() def clean_multibuy(self): if not self.range: raise ValidationError( _("Multibuy benefits require a product range")) if self.value: raise ValidationError( _("Multibuy benefits don't require a value")) if self.max_affected_items: raise ValidationError( _("Multibuy benefits don't require a 'max affected items' " "attribute")) def clean_percentage(self): if not self.range: raise ValidationError( _("Percentage benefits require a product range")) if self.value > 100: raise ValidationError( _("Percentage discount cannot be greater than 100")) def clean_shipping_absolute(self): if not self.value: raise ValidationError( _("A discount value is required")) if self.range: raise ValidationError( _("No range should be selected as this benefit does not " "apply to products")) if self.max_affected_items: raise ValidationError( _("Shipping discounts don't require a 'max affected items' " "attribute")) def clean_shipping_percentage(self): if self.value > 100: raise ValidationError( _("Percentage discount cannot be greater than 100")) if self.range: raise ValidationError( _("No range should be selected as this benefit does not " "apply to products")) if self.max_affected_items: raise ValidationError( _("Shipping discounts don't require a 'max affected items' " "attribute")) def clean_shipping_fixed_price(self): if self.range: raise ValidationError( _("No range should be selected as this benefit does not " "apply to products")) if self.max_affected_items: raise ValidationError( _("Shipping discounts don't require a 'max affected items' " "attribute")) def clean_fixed_price(self): if self.range: raise ValidationError( _("No range should be selected as the condition range will " "be used instead.")) def clean_absolute(self): if not self.range: raise ValidationError( _("Fixed discount benefits require a product range")) if not self.value: raise ValidationError( _("Fixed discount benefits require a value")) def round(self, amount): """ Apply rounding to discount amount """ if hasattr(settings, 'OSCAR_OFFER_ROUNDING_FUNCTION'): return settings.OSCAR_OFFER_ROUNDING_FUNCTION(amount) return amount.quantize(D('.01'), ROUND_DOWN) def _effective_max_affected_items(self): """ Return the maximum number of items that can have a discount applied during the application of this benefit """ return self.max_affected_items if self.max_affected_items else 10000 def can_apply_benefit(self, line): """ Determines whether the benefit can be applied to a given basket line """ return line.stockrecord and line.product.is_discountable def get_applicable_lines(self, basket, range=None): """ Return the basket lines that are available to be discounted :basket: The basket :range: The range of products to use for filtering. The fixed-price benefit ignores its range and uses the condition range """ if range is None: range = self.range line_tuples = [] for line in basket.all_lines(): product = line.product # We only include products where we know the tax charged if not line.stockinfo.price.is_tax_known: continue if (not range.contains(product) or not self.can_apply_benefit(line)): continue price = line.unit_price_incl_tax if not price: # Avoid zero price products continue if line.quantity_without_discount == 0: continue line_tuples.append((price, line)) # We sort lines to be cheapest first to ensure consistent applications return sorted(line_tuples) def shipping_discount(self, charge): return D('0.00') class Range(models.Model): """ Represents a range of products that can be used within an offer """ name = models.CharField(_("Name"), max_length=128, unique=True) slug = models.SlugField(_('Slug'), max_length=128, unique=True, null=True) description = models.TextField(blank=True) # Whether this range is public is_public = models.BooleanField( _('Is public?'), default=False, help_text=_("Public ranges have a customer-facing page")) includes_all_products = models.BooleanField( _('Includes all products?'), default=False) included_products = models.ManyToManyField( 'catalogue.Product', related_name='includes', blank=True, verbose_name=_("Included Products")) excluded_products = models.ManyToManyField( 'catalogue.Product', related_name='excludes', blank=True, verbose_name=_("Excluded Products")) classes = models.ManyToManyField( 'catalogue.ProductClass', related_name='classes', blank=True, verbose_name=_("Product Classes")) included_categories = models.ManyToManyField( 'catalogue.Category', related_name='includes', blank=True, verbose_name=_("Included Categories")) # Allow a custom range instance to be specified proxy_class = models.CharField( _("Custom class"), null=True, blank=True, max_length=255, default=None, unique=True) date_created = models.DateTimeField(_("Date Created"), auto_now_add=True) __included_product_ids = None __excluded_product_ids = None __class_ids = None objects = models.Manager() browsable = BrowsableRangeManager() class Meta: verbose_name = _("Range") verbose_name_plural = _("Ranges") def __unicode__(self): return self.name def get_absolute_url(self): return reverse('catalogue:range', kwargs={ 'slug': self.slug}) def save(self, *args, **kwargs): if not self.slug: self.slug = slugify(self.name) # Save Range super(Range, self).save(*args, **kwargs) def contains_product(self, product): """ Check whether the passed product is part of this range """ # We look for shortcircuit checks first before # the tests that require more database queries. if settings.OSCAR_OFFER_BLACKLIST_PRODUCT and \ settings.OSCAR_OFFER_BLACKLIST_PRODUCT(product): return False # Delegate to a proxy class if one is provided if self.proxy_class: return load_proxy(self.proxy_class)().contains_product(product) excluded_product_ids = self._excluded_product_ids() if product.id in excluded_product_ids: return False if self.includes_all_products: return True if product.product_class_id in self._class_ids(): return True included_product_ids = self._included_product_ids() if product.id in included_product_ids: return True test_categories = self.included_categories.all() if test_categories: for category in product.categories.all(): for test_category in test_categories: if category == test_category or category.is_descendant_of(test_category): return True return False # Shorter alias contains = contains_product def _included_product_ids(self): if self.__included_product_ids is None: self.__included_product_ids = [row['id'] for row in self.included_products.values('id')] return self.__included_product_ids def _excluded_product_ids(self): if not self.id: return [] if self.__excluded_product_ids is None: self.__excluded_product_ids = [row['id'] for row in self.excluded_products.values('id')] return self.__excluded_product_ids def _class_ids(self): if None == self.__class_ids: self.__class_ids = [row['id'] for row in self.classes.values('id')] return self.__class_ids def num_products(self): if self.includes_all_products: return None return self.included_products.all().count() @property def is_editable(self): """ Test whether this product can be edited in the dashboard """ return self.proxy_class is None # ========== # Conditions # ========== class CountCondition(Condition): """ An offer condition dependent on the NUMBER of matching items from the basket. """ _description = _("Basket includes %(count)d item(s) from %(range)s") @property def name(self): return self._description % { 'count': self.value, 'range': unicode(self.range).lower()} @property def description(self): return self._description % { 'count': self.value, 'range': range_anchor(self.range)} class Meta: proxy = True verbose_name = _("Count Condition") verbose_name_plural = _("Count Conditions") def is_satisfied(self, basket): """ Determines whether a given basket meets this condition """ num_matches = 0 for line in basket.all_lines(): if (self.can_apply_condition(line) and line.quantity_without_discount > 0): num_matches += line.quantity_without_discount if num_matches >= self.value: return True return False def _get_num_matches(self, basket): if hasattr(self, '_num_matches'): return getattr(self, '_num_matches') num_matches = 0 for line in basket.all_lines(): if (self.can_apply_condition(line) and line.quantity_without_discount > 0): num_matches += line.quantity_without_discount self._num_matches = num_matches return num_matches def is_partially_satisfied(self, basket): num_matches = self._get_num_matches(basket) return 0 < num_matches < self.value def get_upsell_message(self, basket): num_matches = self._get_num_matches(basket) delta = self.value - num_matches return ungettext('Buy %(delta)d more product from %(range)s', 'Buy %(delta)d more products from %(range)s', delta) % { 'delta': delta, 'range': self.range} def consume_items(self, basket, affected_lines): """ Marks items within the basket lines as consumed so they can't be reused in other offers. :basket: The basket :affected_lines: The lines that have been affected by the discount. This should be list of tuples (line, discount, qty) """ # We need to count how many items have already been consumed as part of # applying the benefit, so we don't consume too many items. num_consumed = 0 for line, __, quantity in affected_lines: num_consumed += quantity to_consume = max(0, self.value - num_consumed) if to_consume == 0: return for __, line in self.get_applicable_lines(basket, most_expensive_first=True): quantity_to_consume = min(line.quantity_without_discount, to_consume) line.consume(quantity_to_consume) to_consume -= quantity_to_consume if to_consume == 0: break class CoverageCondition(Condition): """ An offer condition dependent on the number of DISTINCT matching items from the basket. """ _description = _("Basket includes %(count)d distinct item(s) from %(range)s") @property def name(self): return self._description % { 'count': self.value, 'range': unicode(self.range).lower()} @property def description(self): return self._description % { 'count': self.value, 'range': range_anchor(self.range)} class Meta: proxy = True verbose_name = _("Coverage Condition") verbose_name_plural = _("Coverage Conditions") def is_satisfied(self, basket): """ Determines whether a given basket meets this condition """ covered_ids = [] for line in basket.all_lines(): if not line.is_available_for_discount: continue product = line.product if (self.can_apply_condition(line) and product.id not in covered_ids): covered_ids.append(product.id) if len(covered_ids) >= self.value: return True return False def _get_num_covered_products(self, basket): covered_ids = [] for line in basket.all_lines(): if not line.is_available_for_discount: continue product = line.product if (self.can_apply_condition(line) and product.id not in covered_ids): covered_ids.append(product.id) return len(covered_ids) def get_upsell_message(self, basket): delta = self.value - self._get_num_covered_products(basket) return ungettext('Buy %(delta)d more product from %(range)s', 'Buy %(delta)d more products from %(range)s', delta) % { 'delta': delta, 'range': self.range} def is_partially_satisfied(self, basket): return 0 < self._get_num_covered_products(basket) < self.value def consume_items(self, basket, affected_lines): """ Marks items within the basket lines as consumed so they can't be reused in other offers. """ # Determine products that have already been consumed by applying the # benefit consumed_products = [] for line, __, quantity in affected_lines: consumed_products.append(line.product) to_consume = max(0, self.value - len(consumed_products)) if to_consume == 0: return for line in basket.all_lines(): product = line.product if not self.can_apply_condition(line): continue if product in consumed_products: continue if not line.is_available_for_discount: continue # Only consume a quantity of 1 from each line line.consume(1) consumed_products.append(product) to_consume -= 1 if to_consume == 0: break def get_value_of_satisfying_items(self, basket): covered_ids = [] value = D('0.00') for line in basket.all_lines(): if (self.can_apply_condition(line) and line.product.id not in covered_ids): covered_ids.append(line.product.id) value += line.unit_price_incl_tax if len(covered_ids) >= self.value: return value return value class ValueCondition(Condition): """ An offer condition dependent on the VALUE of matching items from the basket. """ _description = _("Basket includes %(amount)s from %(range)s") @property def name(self): return self._description % { 'amount': currency(self.value), 'range': unicode(self.range).lower()} @property def description(self): return self._description % { 'amount': currency(self.value), 'range': range_anchor(self.range)} class Meta: proxy = True verbose_name = _("Value Condition") verbose_name_plural = _("Value Conditions") def is_satisfied(self, basket): """ Determine whether a given basket meets this condition """ value_of_matches = D('0.00') for line in basket.all_lines(): if (self.can_apply_condition(line) and line.quantity_without_discount > 0): price = line.unit_price_incl_tax value_of_matches += price * int(line.quantity_without_discount) if value_of_matches >= self.value: return True return False def _get_value_of_matches(self, basket): if hasattr(self, '_value_of_matches'): return getattr(self, '_value_of_matches') value_of_matches = D('0.00') for line in basket.all_lines(): if (self.can_apply_condition(line) and line.quantity_without_discount > 0): price = line.unit_price_incl_tax value_of_matches += price * int(line.quantity_without_discount) self._value_of_matches = value_of_matches return value_of_matches def is_partially_satisfied(self, basket): value_of_matches = self._get_value_of_matches(basket) return D('0.00') < value_of_matches < self.value def get_upsell_message(self, basket): value_of_matches = self._get_value_of_matches(basket) return _('Spend %(value)s more from %(range)s') % { 'value': currency(self.value - value_of_matches), 'range': self.range} def consume_items(self, basket, affected_lines): """ Marks items within the basket lines as consumed so they can't be reused in other offers. We allow lines to be passed in as sometimes we want them sorted in a specific order. """ # Determine value of items already consumed as part of discount value_consumed = D('0.00') for line, __, qty in affected_lines: price = line.unit_price_incl_tax value_consumed += price * qty to_consume = max(0, self.value - value_consumed) if to_consume == 0: return for price, line in self.get_applicable_lines( basket, most_expensive_first=True): quantity_to_consume = min( line.quantity_without_discount, (to_consume / price).quantize(D(1), ROUND_UP)) line.consume(quantity_to_consume) to_consume -= price * quantity_to_consume if to_consume <= 0: break # ============ # Result types # ============ class ApplicationResult(object): is_final = is_successful = False # Basket discount discount = D('0.00') description = None # Offer applications can affect 3 distinct things # (a) Give a discount off the BASKET total # (b) Give a discount off the SHIPPING total # (a) Trigger a post-order action BASKET, SHIPPING, POST_ORDER = range(0, 3) affects = None @property def affects_basket(self): return self.affects == self.BASKET @property def affects_shipping(self): return self.affects == self.SHIPPING @property def affects_post_order(self): return self.affects == self.POST_ORDER class BasketDiscount(ApplicationResult): """ For when an offer application leads to a simple discount off the basket's total """ affects = ApplicationResult.BASKET def __init__(self, amount): self.discount = amount @property def is_successful(self): return self.discount > 0 # Helper global as returning zero discount is quite common ZERO_DISCOUNT = BasketDiscount(D('0.00')) class ShippingDiscount(ApplicationResult): """ For when an offer application leads to a discount from the shipping cost """ is_successful = is_final = True affects = ApplicationResult.SHIPPING SHIPPING_DISCOUNT = ShippingDiscount() class PostOrderAction(ApplicationResult): """ For when an offer condition is met but the benefit is deferred until after the order has been placed. Eg buy 2 books and get 100 loyalty points. """ is_final = is_successful = True affects = ApplicationResult.POST_ORDER def __init__(self, description): self.description = description # ======== # Benefits # ======== class PercentageDiscountBenefit(Benefit): """ An offer benefit that gives a percentage discount """ _description = _("%(value)s%% discount on %(range)s") @property def name(self): return self._description % { 'value': self.value, 'range': self.range.name.lower()} @property def description(self): return self._description % { 'value': self.value, 'range': range_anchor(self.range)} class Meta: proxy = True verbose_name = _("Percentage discount benefit") verbose_name_plural = _("Percentage discount benefits") def apply(self, basket, condition, offer=None): line_tuples = self.get_applicable_lines(basket) discount = D('0.00') affected_items = 0 max_affected_items = self._effective_max_affected_items() affected_lines = [] for price, line in line_tuples: if affected_items >= max_affected_items: break quantity_affected = min(line.quantity_without_discount, max_affected_items - affected_items) line_discount = self.round(self.value / D('100.0') * price * int(quantity_affected)) line.discount(line_discount, quantity_affected) affected_lines.append((line, line_discount, quantity_affected)) affected_items += quantity_affected discount += line_discount if discount > 0: condition.consume_items(basket, affected_lines) return BasketDiscount(discount) class AbsoluteDiscountBenefit(Benefit): """ An offer benefit that gives an absolute discount """ _description = _("%(value)s discount on %(range)s") @property def name(self): return self._description % { 'value': currency(self.value), 'range': self.range.name.lower()} @property def description(self): return self._description % { 'value': currency(self.value), 'range': range_anchor(self.range)} class Meta: proxy = True verbose_name = _("Absolute discount benefit") verbose_name_plural = _("Absolute discount benefits") def apply(self, basket, condition, offer=None): # Fetch basket lines that are in the range and available to be used in # an offer. line_tuples = self.get_applicable_lines(basket) if not line_tuples: return ZERO_DISCOUNT # Determine which lines can have the discount applied to them max_affected_items = self._effective_max_affected_items() num_affected_items = 0 affected_items_total = D('0.00') lines_to_discount = [] for price, line in line_tuples: if num_affected_items >= max_affected_items: break qty = min(line.quantity_without_discount, max_affected_items - num_affected_items) lines_to_discount.append((line, price, qty)) num_affected_items += qty affected_items_total += qty * price # Guard against zero price products causing problems if not affected_items_total: return ZERO_DISCOUNT # Ensure we don't try to apply a discount larger than the total of the # matching items. discount = min(self.value, affected_items_total) # Apply discount equally amongst them affected_lines = [] applied_discount = D('0.00') for i, (line, price, qty) in enumerate(lines_to_discount): if i == len(lines_to_discount) - 1: # If last line, then take the delta as the discount to ensure # the total discount is correct and doesn't mismatch due to # rounding. line_discount = discount - applied_discount else: # Calculate a weighted discount for the line line_discount = self.round( ((price * qty) / affected_items_total) * discount) line.discount(line_discount, qty) affected_lines.append((line, line_discount, qty)) applied_discount += line_discount condition.consume_items(basket, affected_lines) return BasketDiscount(discount) class FixedPriceBenefit(Benefit): """ An offer benefit that gives the items in the condition for a fixed price. This is useful for "bundle" offers. Note that we ignore the benefit range here and only give a fixed price for the products in the condition range. The condition cannot be a value condition. We also ignore the max_affected_items setting. """ _description = _("The products that meet the condition are sold " "for %(amount)s") def __unicode__(self): return self._description % { 'amount': currency(self.value)} @property def description(self): return self.__unicode__() class Meta: proxy = True verbose_name = _("Fixed price benefit") verbose_name_plural = _("Fixed price benefits") def apply(self, basket, condition, offer=None): if isinstance(condition, ValueCondition): return ZERO_DISCOUNT # Fetch basket lines that are in the range and available to be used in # an offer. line_tuples = self.get_applicable_lines(basket, range=condition.range) if not line_tuples: return ZERO_DISCOUNT # Determine the lines to consume num_permitted = int(condition.value) num_affected = 0 value_affected = D('0.00') covered_lines = [] for price, line in line_tuples: if isinstance(condition, CoverageCondition): quantity_affected = 1 else: quantity_affected = min( line.quantity_without_discount, num_permitted - num_affected) num_affected += quantity_affected value_affected += quantity_affected * price covered_lines.append((price, line, quantity_affected)) if num_affected >= num_permitted: break discount = max(value_affected - self.value, D('0.00')) if not discount: return ZERO_DISCOUNT # Apply discount to the affected lines discount_applied = D('0.00') last_line = covered_lines[-1][1] for price, line, quantity in covered_lines: if line == last_line: # If last line, we just take the difference to ensure that # rounding doesn't lead to an off-by-one error line_discount = discount - discount_applied else: line_discount = self.round( discount * (price * quantity) / value_affected) line.discount(line_discount, quantity) discount_applied += line_discount return BasketDiscount(discount) class MultibuyDiscountBenefit(Benefit): _description = _("Cheapest product from %(range)s is free") @property def name(self): return self._description % { 'range': self.range.name.lower()} @property def description(self): return self._description % { 'range': range_anchor(self.range)} class Meta: proxy = True verbose_name = _("Multibuy discount benefit") verbose_name_plural = _("Multibuy discount benefits") def apply(self, basket, condition, offer=None): line_tuples = self.get_applicable_lines(basket) if not line_tuples: return ZERO_DISCOUNT # Cheapest line gives free product discount, line = line_tuples[0] line.discount(discount, 1) affected_lines = [(line, discount, 1)] condition.consume_items(basket, affected_lines) return BasketDiscount(discount) # ================= # Shipping benefits # ================= class ShippingBenefit(Benefit): def apply(self, basket, condition, offer=None): condition.consume_items(basket, affected_lines=()) return SHIPPING_DISCOUNT class Meta: proxy = True class ShippingAbsoluteDiscountBenefit(ShippingBenefit): _description = _("%(amount)s off shipping cost") @property def description(self): return self._description % { 'amount': currency(self.value)} class Meta: proxy = True verbose_name = _("Shipping absolute discount benefit") verbose_name_plural = _("Shipping absolute discount benefits") def shipping_discount(self, charge): return min(charge, self.value) class ShippingFixedPriceBenefit(ShippingBenefit): _description = _("Get shipping for %(amount)s") @property def description(self): return self._description % { 'amount': currency(self.value)} class Meta: proxy = True verbose_name = _("Fixed price shipping benefit") verbose_name_plural = _("Fixed price shipping benefits") def shipping_discount(self, charge): if charge < self.value: return D('0.00') return charge - self.value class ShippingPercentageDiscountBenefit(ShippingBenefit): _description = _("%(value)s%% off of shipping cost") @property def description(self): return self._description % { 'value': self.value} class Meta: proxy = True verbose_name = _("Shipping percentage discount benefit") verbose_name_plural = _("Shipping percentage discount benefits") def shipping_discount(self, charge): return charge * self.value / D('100.0')