| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573 |
- from decimal import Decimal
- import math
- import datetime
-
- from django.contrib.auth.models import User
- from django.db import models
- from django.utils.translation import ugettext as _
- from django.core.exceptions import ValidationError
-
- from oscar.apps.offer.managers import ActiveOfferManager
-
- SITE, VOUCHER, USER, SESSION = ("Site", "Voucher", "User", "Session")
-
- class ConditionalOffer(models.Model):
- u"""
- A conditional offer (eg buy 1, get 10% off)
- """
- name = models.CharField(max_length=128)
- description = models.TextField(blank=True, null=True)
-
- # 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.
- 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)
-
- condition = models.ForeignKey('offer.Condition')
- benefit = models.ForeignKey('offer.Benefit')
-
- # 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_date = models.DateField(blank=True, null=True)
- end_date = models.DateField(blank=True, null=True)
-
- # Some complicated situations require offers to be applied in a set order.
- priority = models.IntegerField(default=0, help_text="The highest priority offers are applied first")
-
- # We track some information on usage
- total_discount = models.DecimalField(decimal_places=2, max_digits=12, default=Decimal('0.00'))
-
- date_created = models.DateTimeField(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']
-
- def __unicode__(self):
- return self.name
-
- def is_active(self, test_date=None):
- if not test_date:
- test_date = datetime.date.today()
- return self.start_date <= test_date and test_date < self.end_date
-
- def is_condition_satisfied(self, basket):
- return self._proxy_condition().is_satisfied(basket)
-
- def apply_benefit(self, basket):
- u"""
- Applies the benefit to the given basket and returns the discount.
- """
- if not self.is_condition_satisfied(basket):
- return Decimal('0.00')
- return self._proxy_benefit().apply(basket, self._proxy_condition())
-
- def set_voucher(self, voucher):
- self._voucher = voucher
-
- def get_voucher(self):
- return self._voucher
-
- def _proxy_condition(self):
- u"""
- Returns the appropriate proxy model for the condition
- """
- field_dict = self.condition.__dict__
- if '_state' in field_dict:
- del field_dict['_state']
- if self.condition.type == self.condition.COUNT:
- return CountCondition(**field_dict)
- elif self.condition.type == self.condition.VALUE:
- return ValueCondition(**field_dict)
- elif self.condition.type == self.condition.COVERAGE:
- return CoverageCondition(**field_dict)
- return self.condition
-
- def _proxy_benefit(self):
- u"""
- Returns the appropriate proxy model for the condition
- """
- field_dict = self.benefit.__dict__
- if '_state' in field_dict:
- del field_dict['_state']
- if self.benefit.type == self.benefit.PERCENTAGE:
- return PercentageDiscountBenefit(**field_dict)
- elif self.benefit.type == self.benefit.FIXED:
- return AbsoluteDiscountBenefit(**field_dict)
- elif self.benefit.type == self.benefit.MULTIBUY:
- return MultibuyDiscountBenefit(**field_dict)
- elif self.benefit.type == self.benefit.FIXED_PRICE:
- return FixedPriceBenefit(**field_dict)
- return self.benefit
-
-
- 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')
- type = models.CharField(max_length=128, choices=TYPE_CHOICES)
- value = models.DecimalField(decimal_places=2, max_digits=12)
-
- def __unicode__(self):
- if self.type == self.COUNT:
- return u"Basket includes %d item(s) from %s" % (self.value, str(self.range).lower())
- elif self.type == self.COVERAGE:
- return u"Basket includes %d distinct products from %s" % (self.value, str(self.range).lower())
- return u"Basket includes %.2f value from %s" % (self.value, str(self.range).lower())
-
- def consume_items(self, basket):
- 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
-
-
- class Benefit(models.Model):
- PERCENTAGE, FIXED, MULTIBUY, FIXED_PRICE = ("Percentage", "Absolute", "Multibuy", "Fixed price")
- TYPE_CHOICES = (
- (PERCENTAGE, _("Discount is a % of the product's value")),
- (FIXED, _("Discount is a fixed amount off 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")),
- )
- range = models.ForeignKey('offer.Range', null=True, blank=True)
- type = models.CharField(max_length=128, choices=TYPE_CHOICES)
- value = models.DecimalField(decimal_places=2, max_digits=12)
-
- price_field = 'price_incl_tax'
-
- # 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(blank=True, null=True, help_text="""Set this
- to prevent the discount consuming all items within the range that are in the basket.""")
-
- def __unicode__(self):
- if self.type == self.PERCENTAGE:
- desc = u"%s%% discount on %s" % (self.value, str(self.range).lower())
- elif self.type == self.MULTIBUY:
- desc = u"Cheapest product is free from %s" % str(self.range)
- elif self.type == self.FIXED_PRICE:
- desc = u"The products that meet the condition are sold for %s" % self.value
- else:
- desc = u"%.2f discount on %s" % (self.value, str(self.range).lower())
- if self.max_affected_items == 1:
- desc += u" (max 1 item)"
- elif self.max_affected_items > 1:
- desc += u" (max %d items)" % self.max_affected_items
- return desc
-
- def apply(self, basket, condition=None):
- return Decimal('0.00')
-
- def clean(self):
- # All benefits need a range apart from FIXED_PRICE
- if self.type != self.FIXED_PRICE and not self.range:
- raise ValidationError("Benefits of type %s need a range" % self.type)
-
- def _effective_max_affected_items(self):
- if not self.max_affected_items:
- max_affected_items = 10000
- else:
- max_affected_items = self.max_affected_items
- return max_affected_items
-
-
- class Range(models.Model):
- u"""
- Represents a range of products that can be used within an offer
- """
- name = models.CharField(_("Name"), max_length=128)
- includes_all_products = models.BooleanField(default=False)
- included_products = models.ManyToManyField('product.Item', related_name='includes', blank=True)
- excluded_products = models.ManyToManyField('product.Item', related_name='excludes', blank=True)
- classes = models.ManyToManyField('product.ItemClass', related_name='classes', blank=True)
-
- __included_product_ids = None
- __excluded_product_ids = None
- __class_ids = None
-
- def __unicode__(self):
- return self.name
-
- def contains_product(self, 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.item_class_id in self._class_ids():
- return True
- included_product_ids = self._included_product_ids()
- return product.id in included_product_ids
-
- def _included_product_ids(self):
- if None == self.__included_product_ids:
- 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 None == self.__excluded_product_ids:
- 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
-
-
- class Voucher(models.Model):
- u"""
- A voucher. This is simply a link to a collection of offers
-
- Note that there are three possible "usage" models:
- (a) Single use
- (b) Multi-use
- (c) Once per customer
- """
- name = models.CharField(_("Name"), max_length=128,
- help_text="""This will be shown in the checkout and basket once the voucher is entered""")
- code = models.CharField(_("Code"), max_length=128, db_index=True, unique=True,
- help_text="""Case insensitive / No spaces allowed""")
- offers = models.ManyToManyField('offer.ConditionalOFfer', related_name='vouchers',
- limit_choices_to={'offer_type': VOUCHER})
-
- SINGLE_USE, MULTI_USE, ONCE_PER_CUSTOMER = ('Single use', 'Multi-use', 'Once per customer')
- USAGE_CHOICES = (
- (SINGLE_USE, "Can only be used by one customer"),
- (MULTI_USE, "Can only be used any number of times"),
- (ONCE_PER_CUSTOMER, "Can be used once by each customer"),
- )
- usage = models.CharField(_("Usage"), max_length=128, choices=USAGE_CHOICES, default=MULTI_USE)
-
- start_date = models.DateField()
- end_date = models.DateField()
-
- # Summary information
- num_basket_additions = models.PositiveIntegerField(default=0)
- num_orders = models.PositiveIntegerField(default=0)
- total_discount = models.DecimalField(decimal_places=2, max_digits=12, default=Decimal('0.00'))
-
- date_created = models.DateField(auto_now_add=True)
-
- class Meta:
- get_latest_by = 'date_created'
-
- def __unicode__(self):
- return self.name
-
- def save(self, *args, **kwargs):
- self.code = self.code.upper()
- super(Voucher, self).save(*args, **kwargs)
-
- def is_active(self, test_date=None):
- u"""
- Tests whether this voucher is currently active.
- """
- if not test_date:
- test_date = datetime.date.today()
- return self.start_date <= test_date and test_date < self.end_date
-
- def is_available_to_user(self, user=None):
- u"""
- Tests whether this voucher is available to the passed user.
-
- Returns a tuple of a boolean for whether it is successulf, and a message
- """
- is_available, message = False, ''
- if self.usage == self.SINGLE_USE:
- is_available = self.applications.count() == 0
- if not is_available:
- message = "This voucher has already been used"
- elif self.usage == self.MULTI_USE:
- is_available = True
- elif self.usage == self.ONCE_PER_CUSTOMER:
- if not user.is_authenticated():
- is_available = False
- message = "This voucher is only available to signed in users"
- else:
- is_available = self.applications.filter(voucher=self, user=user).count() == 0
- if not is_available:
- message = "You have already used this voucher in a previous order"
- return is_available, message
-
- def record_usage(self, order, user):
- u"""
- Records a usage of this voucher in an order.
- """
- self.applications.create(voucher=self, order=order, user=user)
-
-
- class VoucherApplication(models.Model):
- u"""
- For tracking how often a voucher has been used
- """
- voucher = models.ForeignKey('offer.Voucher', related_name="applications")
- # It is possible for an anonymous user to apply a voucher so we need to allow
- # the user to be nullable
- user = models.ForeignKey('auth.User', blank=True, null=True)
- order = models.ForeignKey('order.Order')
- date_created = models.DateField(auto_now_add=True)
-
- def __unicode__(self):
- return u"'%s' used by '%s'" % (self.voucher, self.user)
-
-
- class CountCondition(Condition):
- u"""
- An offer condition dependent on the NUMBER of matching items from the basket.
- """
-
- class Meta:
- proxy = True
-
- def is_satisfied(self, basket):
- u"""Determines whether a given basket meets this condition"""
- num_matches = 0
- for line in basket.all_lines():
- if self.range.contains_product(line.product):
- num_matches += line.quantity
- if num_matches >= self.value:
- return True
- return False
-
- def consume_items(self, basket):
- u"""
- Marks items within the basket lines as consumed so they
- can't be reused in other offers.
- """
- num_consumed = 0
- for line in basket.all_lines():
- if self.range.contains_product(line.product):
- quantity_to_consume = min(line.quantity_without_discount, self.value - num_consumed)
- line.consume(quantity_to_consume)
- if num_consumed == self.value:
- return
-
-
- class CoverageCondition(Condition):
- u"""
- An offer condition dependent on the NUMBER of matching items from the basket.
- """
-
- class Meta:
- proxy = True
-
- def is_satisfied(self, basket):
- u"""Determines whether a given basket meets this condition"""
- covered_ids = []
- for line in basket.all_lines():
- if not line.is_available_for_discount:
- continue
- if self.range.contains_product(line.product) and line.product.id not in covered_ids:
- covered_ids.append(line.product.id)
- if len(covered_ids) >= self.value:
- return True
- return False
-
- def consume_items(self, basket):
- u"""
- Marks items within the basket lines as consumed so they
- can't be reused in other offers.
- """
- covered_ids = []
- for line in basket.all_lines():
- if self.range.contains_product(line.product) and line.product.id not in covered_ids:
- line.consume(1)
- covered_ids.append(line.product.id)
- if len(covered_ids) >= self.value:
- return
-
- def get_value_of_satisfying_items(self, basket):
- covered_ids = []
- value = Decimal('0.00')
- for line in basket.all_lines():
- if self.range.contains_product(line.product) 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):
- u"""
- An offer condition dependent on the VALUE of matching items from the basket.
- """
- price_field = 'price_incl_tax'
-
- class Meta:
- proxy = True
-
- def is_satisfied(self, basket):
- u"""Determines whether a given basket meets this condition"""
- value_of_matches = Decimal('0.00')
- for line in basket.all_lines():
- if self.range.contains_product(line.product) and line.product.has_stockrecord:
- price = getattr(line.product.stockrecord, self.price_field)
- value_of_matches += price * line.quantity
- if value_of_matches >= self.value:
- return True
- return False
-
- def consume_items(self, basket):
- u"""
- Marks items within the basket lines as consumed so they
- can't be reused in other offers.
- """
- value_of_matches = Decimal('0.00')
- for line in basket.all_lines():
- if self.range.contains_product(line.product) and line.product.has_stockrecord:
- price = getattr(line.product.stockrecord, self.price_field)
- quantity_to_consume = min(line.quantity_without_discount,
- math.floor((self.value - value_of_matches)/price))
- value_of_matches += price * int(quantity_to_consume)
- line.consume(quantity_to_consume)
- if value_of_matches >= self.value:
- return
-
- # ========
- # Benefits
- # ========
-
- class PercentageDiscountBenefit(Benefit):
- u"""
- An offer benefit that gives a percentage discount
- """
-
- class Meta:
- proxy = True
-
- def apply(self, basket, condition=None):
- discount = Decimal('0.00')
- affected_items = 0
- max_affected_items = self._effective_max_affected_items()
-
- for line in basket.all_lines():
- if affected_items >= max_affected_items:
- break
- if self.range.contains_product(line.product) and line.product.has_stockrecord:
- price = getattr(line.product.stockrecord, self.price_field)
- quantity = min(line.quantity_without_discount,
- max_affected_items - affected_items)
- discount += self.value/100 * price * quantity
- affected_items += quantity
- line.discount(discount, quantity)
- if discount > 0 and condition:
- condition.consume_items(basket)
- return discount
-
-
- class AbsoluteDiscountBenefit(Benefit):
- u"""
- An offer benefit that gives an absolute discount
- """
-
- class Meta:
- proxy = True
-
- def apply(self, basket, condition=None):
- discount = Decimal('0.00')
- affected_items = 0
- max_affected_items = self._effective_max_affected_items()
-
- for line in basket.all_lines():
- if affected_items >= max_affected_items:
- break
- if self.range.contains_product(line.product) and line.product.has_stockrecord:
- price = getattr(line.product.stockrecord, self.price_field)
- remaining_discount = self.value - discount
- quantity = min(line.quantity_without_discount,
- max_affected_items - affected_items,
- math.floor(remaining_discount / price))
- discount += price * Decimal(str(quantity))
- affected_items += quantity
- line.discount(discount, quantity)
- if discount > 0 and condition:
- condition.consume_items(basket)
- return discount
-
-
- class FixedPriceBenefit(Benefit):
- u"""
- 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.
- """
- class Meta:
- proxy = True
-
- def apply(self, basket, condition=None):
- covered_lines = []
- product_total = Decimal('0.00')
- for line in basket.all_lines():
- if condition.range.contains_product(line.product) and line not in covered_lines:
- covered_lines.append(line)
- product_total += line.unit_price_incl_tax
- if len(covered_lines) >= condition.value:
- break
- discount = max(product_total - self.value, Decimal('0.00'))
-
- # Apply discount weighted by original value of line
- for line in covered_lines:
- line_discount = (line.unit_price_incl_tax / product_total) * discount
- line.discount(line_discount.quantize(Decimal('.01')), 1)
- return discount
-
-
- class MultibuyDiscountBenefit(Benefit):
-
- class Meta:
- proxy = True
-
- def apply(self, basket, condition=None):
- # We want cheapest item not in an offer and that becomes the discount
- discount = Decimal('0.00')
- line = self._get_cheapest_line(basket)
- if line:
- discount = getattr(line.product.stockrecord, self.price_field)
- line.discount(discount, 1)
- if discount > 0 and condition:
- condition.consume_items(basket)
- return discount
-
- def _get_cheapest_line(self, basket):
- min_price = Decimal('10000.00')
- cheapest_line = None
- for line in basket.all_lines():
- if line.quantity_without_discount > 0 and getattr(line.product.stockrecord, self.price_field) < min_price:
- min_price = getattr(line.product.stockrecord, self.price_field)
- cheapest_line = line
- return cheapest_line
-
- # We need to import receivers at the bottom of this script
- from oscar.apps.offer.receivers import receive_basket_voucher_change
|