You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

models.py 30KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754
  1. from decimal import Decimal, ROUND_DOWN, ROUND_UP
  2. import math
  3. import datetime
  4. from django.core import exceptions
  5. from django.template.defaultfilters import slugify
  6. from django.db import models
  7. from django.utils.translation import ungettext, ugettext as _
  8. from django.core.exceptions import ValidationError
  9. from django.core.urlresolvers import reverse
  10. from django.conf import settings
  11. from oscar.apps.offer.managers import ActiveOfferManager
  12. from oscar.models.fields import PositiveDecimalField, ExtendedURLField
  13. class ConditionalOffer(models.Model):
  14. """
  15. A conditional offer (eg buy 1, get 10% off)
  16. """
  17. name = models.CharField(_('Name'), max_length=128, unique=True,
  18. help_text=_("""This is displayed within the customer's
  19. basket"""))
  20. slug = models.SlugField(_('Slug'), max_length=128, unique=True, null=True)
  21. description = models.TextField(_('Description'), blank=True, null=True)
  22. # Offers come in a few different types:
  23. # (a) Offers that are available to all customers on the site. Eg a
  24. # 3-for-2 offer.
  25. # (b) Offers that are linked to a voucher, and only become available once
  26. # that voucher has been applied to the basket
  27. # (c) Offers that are linked to a user. Eg, all students get 10% off. The code
  28. # to apply this offer needs to be coded
  29. # (d) Session offers - these are temporarily available to a user after some trigger
  30. # event. Eg, users coming from some affiliate site get 10% off.
  31. SITE, VOUCHER, USER, SESSION = ("Site", "Voucher", "User", "Session")
  32. TYPE_CHOICES = (
  33. (SITE, _("Site offer - available to all users")),
  34. (VOUCHER, _("Voucher offer - only available after entering the appropriate voucher code")),
  35. (USER, _("User offer - available to certain types of user")),
  36. (SESSION, _("Session offer - temporary offer, available for a user for the duration of their session")),
  37. )
  38. offer_type = models.CharField(_('Type'), choices=TYPE_CHOICES, default=SITE, max_length=128)
  39. condition = models.ForeignKey('offer.Condition')
  40. benefit = models.ForeignKey('offer.Benefit')
  41. # Range of availability. Note that if this is a voucher offer, then these
  42. # dates are ignored and only the dates from the voucher are used to determine
  43. # availability.
  44. start_date = models.DateField(_('Start Date'), blank=True, null=True)
  45. end_date = models.DateField(_('End Date'), blank=True, null=True,
  46. help_text=_("""Offers are not active on their end
  47. date, only the days preceding"""))
  48. # Some complicated situations require offers to be applied in a set order.
  49. priority = models.IntegerField(_('Priority'), default=0,
  50. help_text=_("The highest priority offers are applied first"))
  51. # We track some information on usage
  52. total_discount = models.DecimalField(_('Total Discount'), decimal_places=2, max_digits=12, default=Decimal('0.00'))
  53. num_orders = models.PositiveIntegerField(_('Number of Orders'), default=0)
  54. date_created = models.DateTimeField(auto_now_add=True)
  55. objects = models.Manager()
  56. active = ActiveOfferManager()
  57. redirect_url = ExtendedURLField(_('URL redirect (optional)'), blank=True)
  58. # We need to track the voucher that this offer came from (if it is a voucher offer)
  59. _voucher = None
  60. class Meta:
  61. ordering = ['-priority']
  62. verbose_name = _("Conditional Offer")
  63. verbose_name_plural = _("Conditional Offers")
  64. def save(self, *args, **kwargs):
  65. if not self.slug:
  66. self.slug = slugify(self.name)
  67. return super(ConditionalOffer, self).save(*args, **kwargs)
  68. def get_absolute_url(self):
  69. return reverse('offer:detail', kwargs={'slug': self.slug})
  70. def __unicode__(self):
  71. return self.name
  72. def clean(self):
  73. if self.start_date and self.end_date and self.start_date > self.end_date:
  74. raise exceptions.ValidationError(_('End date should be later than start date'))
  75. def is_active(self, test_date=None):
  76. if not test_date:
  77. test_date = datetime.date.today()
  78. return self.start_date <= test_date and test_date < self.end_date
  79. def is_condition_satisfied(self, basket):
  80. return self._proxy_condition().is_satisfied(basket)
  81. def is_condition_partially_satisfied(self, basket):
  82. return self._proxy_condition().is_partially_satisfied(basket)
  83. def get_upsell_message(self, basket):
  84. return self._proxy_condition().get_upsell_message(basket)
  85. def apply_benefit(self, basket):
  86. """
  87. Applies the benefit to the given basket and returns the discount.
  88. """
  89. if not self.is_condition_satisfied(basket):
  90. return Decimal('0.00')
  91. return self._proxy_benefit().apply(basket, self._proxy_condition())
  92. def set_voucher(self, voucher):
  93. self._voucher = voucher
  94. def get_voucher(self):
  95. return self._voucher
  96. def _proxy_condition(self):
  97. """
  98. Returns the appropriate proxy model for the condition
  99. """
  100. field_dict = dict(self.condition.__dict__)
  101. if '_state' in field_dict:
  102. del field_dict['_state']
  103. if '_range_cache' in field_dict:
  104. del field_dict['_range_cache']
  105. if self.condition.type == self.condition.COUNT:
  106. return CountCondition(**field_dict)
  107. elif self.condition.type == self.condition.VALUE:
  108. return ValueCondition(**field_dict)
  109. elif self.condition.type == self.condition.COVERAGE:
  110. return CoverageCondition(**field_dict)
  111. return self.condition
  112. def _proxy_benefit(self):
  113. """
  114. Returns the appropriate proxy model for the condition
  115. """
  116. field_dict = dict(self.benefit.__dict__)
  117. if '_state' in field_dict:
  118. del field_dict['_state']
  119. if self.benefit.type == self.benefit.PERCENTAGE:
  120. return PercentageDiscountBenefit(**field_dict)
  121. elif self.benefit.type == self.benefit.FIXED:
  122. return AbsoluteDiscountBenefit(**field_dict)
  123. elif self.benefit.type == self.benefit.MULTIBUY:
  124. return MultibuyDiscountBenefit(**field_dict)
  125. elif self.benefit.type == self.benefit.FIXED_PRICE:
  126. return FixedPriceBenefit(**field_dict)
  127. return self.benefit
  128. def record_usage(self, discount):
  129. self.num_orders += 1
  130. self.total_discount += discount
  131. self.save()
  132. class Condition(models.Model):
  133. COUNT, VALUE, COVERAGE = ("Count", "Value", "Coverage")
  134. TYPE_CHOICES = (
  135. (COUNT, _("Depends on number of items in basket that are in condition range")),
  136. (VALUE, _("Depends on value of items in basket that are in condition range")),
  137. (COVERAGE, _("Needs to contain a set number of DISTINCT items from the condition range"))
  138. )
  139. range = models.ForeignKey('offer.Range')
  140. type = models.CharField(_('Type'), max_length=128, choices=TYPE_CHOICES)
  141. value = PositiveDecimalField(_('Value'), decimal_places=2, max_digits=12)
  142. class Meta:
  143. verbose_name = _("Condition")
  144. verbose_name_plural = _("Conditions")
  145. def __unicode__(self):
  146. if self.type == self.COUNT:
  147. return _("Basket includes %(count)d item(s) from %(range)s") % {
  148. 'count': self.value, 'range': unicode(self.range).lower()}
  149. elif self.type == self.COVERAGE:
  150. return _("Basket includes %(count)d distinct products from %(range)s") % {
  151. 'count': self.value, 'range': unicode(self.range).lower()}
  152. return _("Basket includes %(count)d value from %(range)s") % {
  153. 'count': self.value, 'range': unicode(self.range).lower()}
  154. description = __unicode__
  155. def consume_items(self, basket, lines=None):
  156. return ()
  157. def is_satisfied(self, basket):
  158. """
  159. Determines whether a given basket meets this condition. This is
  160. stubbed in this top-class object. The subclassing proxies are
  161. responsible for implementing it correctly.
  162. """
  163. return False
  164. def is_partially_satisfied(self, basket):
  165. """
  166. Determine if the basket partially meets the condition. This is useful
  167. for up-selling messages to entice customers to buy something more in
  168. order to qualify for an offer.
  169. """
  170. return False
  171. def get_upsell_message(self, basket):
  172. return None
  173. def can_apply_condition(self, product):
  174. """
  175. Determines whether the condition can be applied to a given product
  176. """
  177. return (self.range.contains_product(product)
  178. and product.is_discountable)
  179. class Benefit(models.Model):
  180. PERCENTAGE, FIXED, MULTIBUY, FIXED_PRICE = ("Percentage", "Absolute", "Multibuy", "Fixed price")
  181. TYPE_CHOICES = (
  182. (PERCENTAGE, _("Discount is a % of the product's value")),
  183. (FIXED, _("Discount is a fixed amount off the product's value")),
  184. (MULTIBUY, _("Discount is to give the cheapest product for free")),
  185. (FIXED_PRICE, _("Get the products that meet the condition for a fixed price")),
  186. )
  187. range = models.ForeignKey('offer.Range', null=True, blank=True)
  188. type = models.CharField(_('Type'), max_length=128, choices=TYPE_CHOICES)
  189. value = PositiveDecimalField(_('Value'), decimal_places=2, max_digits=12,
  190. null=True, blank=True)
  191. price_field = 'price_incl_tax'
  192. # If this is not set, then there is no upper limit on how many products
  193. # can be discounted by this benefit.
  194. max_affected_items = models.PositiveIntegerField(_('Max Affected Items'), blank=True, null=True,
  195. help_text=_("""Set this to prevent the discount consuming all items within the range that are in the basket."""))
  196. class Meta:
  197. verbose_name = _("Benefit")
  198. verbose_name_plural = _("Benefits")
  199. def __unicode__(self):
  200. if self.type == self.PERCENTAGE:
  201. desc = _("%(value)s%% discount on %(range)s") % {'value': self.value, 'range': unicode(self.range).lower()}
  202. elif self.type == self.MULTIBUY:
  203. desc = _("Cheapest product is free from %s") % unicode(self.range).lower()
  204. elif self.type == self.FIXED_PRICE:
  205. desc = _("The products that meet the condition are sold for %s") % self.value
  206. else:
  207. desc = _("%(value).2f discount on %(range)s") % {'value': float(self.value),
  208. 'range': unicode(self.range).lower()}
  209. if self.max_affected_items:
  210. desc += ungettext(" (max 1 item)", " (max %d items)", self.max_affected_items) % self.max_affected_items
  211. return desc
  212. description = __unicode__
  213. def apply(self, basket, condition=None):
  214. return Decimal('0.00')
  215. def clean(self):
  216. if self.value is None:
  217. if not self.type:
  218. raise ValidationError(_("Benefit requires a value"))
  219. elif self.type != self.MULTIBUY:
  220. raise ValidationError(_("Benefits of type %s need a value") % self.type)
  221. elif self.value > 100 and self.type == 'Percentage':
  222. raise ValidationError(_("Percentage benefit value can't be greater than 100"))
  223. # All benefits need a range apart from FIXED_PRICE
  224. if self.type and self.type != self.FIXED_PRICE and not self.range:
  225. raise ValidationError(_("Benefits of type %s need a range") % self.type)
  226. def round(self, amount):
  227. """
  228. Apply rounding to discount amount
  229. """
  230. if hasattr(settings, 'OSCAR_OFFER_ROUNDING_FUNCTION'):
  231. return settings.OSCAR_OFFER_ROUNDING_FUNCTION(amount)
  232. return amount.quantize(Decimal('.01'), ROUND_DOWN)
  233. def _effective_max_affected_items(self):
  234. if not self.max_affected_items:
  235. max_affected_items = 10000
  236. else:
  237. max_affected_items = self.max_affected_items
  238. return max_affected_items
  239. def can_apply_benefit(self, product):
  240. """
  241. Determines whether the benefit can be applied to a given product
  242. """
  243. return product.is_discountable
  244. class Range(models.Model):
  245. """
  246. Represents a range of products that can be used within an offer
  247. """
  248. name = models.CharField(_("Name"), max_length=128, unique=True)
  249. includes_all_products = models.BooleanField(_('Includes All Products'), default=False)
  250. included_products = models.ManyToManyField('catalogue.Product', related_name='includes', blank=True)
  251. excluded_products = models.ManyToManyField('catalogue.Product', related_name='excludes', blank=True)
  252. classes = models.ManyToManyField('catalogue.ProductClass', related_name='classes', blank=True)
  253. included_categories = models.ManyToManyField('catalogue.Category', related_name='includes', blank=True)
  254. date_created = models.DateTimeField(auto_now_add=True)
  255. __included_product_ids = None
  256. __excluded_product_ids = None
  257. __class_ids = None
  258. class Meta:
  259. verbose_name = _("Range")
  260. verbose_name_plural = _("Ranges")
  261. def __unicode__(self):
  262. return self.name
  263. def contains_product(self, product):
  264. """
  265. Check whether the passed product is part of this range
  266. """
  267. # We look for shortcircuit checks first before
  268. # the tests that require more database queries.
  269. if settings.OSCAR_OFFER_BLACKLIST_PRODUCT and \
  270. settings.OSCAR_OFFER_BLACKLIST_PRODUCT(product):
  271. return False
  272. excluded_product_ids = self._excluded_product_ids()
  273. if product.id in excluded_product_ids:
  274. return False
  275. if self.includes_all_products:
  276. return True
  277. if product.product_class_id in self._class_ids():
  278. return True
  279. included_product_ids = self._included_product_ids()
  280. if product.id in included_product_ids:
  281. return True
  282. test_categories = self.included_categories.all()
  283. if test_categories:
  284. for category in product.categories.all():
  285. for test_category in test_categories:
  286. if category == test_category or category.is_descendant_of(test_category):
  287. return True
  288. return False
  289. def _included_product_ids(self):
  290. if None == self.__included_product_ids:
  291. self.__included_product_ids = [row['id'] for row in self.included_products.values('id')]
  292. return self.__included_product_ids
  293. def _excluded_product_ids(self):
  294. if None == self.__excluded_product_ids:
  295. self.__excluded_product_ids = [row['id'] for row in self.excluded_products.values('id')]
  296. return self.__excluded_product_ids
  297. def _class_ids(self):
  298. if None == self.__class_ids:
  299. self.__class_ids = [row['id'] for row in self.classes.values('id')]
  300. return self.__class_ids
  301. def num_products(self):
  302. if self.includes_all_products:
  303. return None
  304. return self.included_products.all().count()
  305. class CountCondition(Condition):
  306. """
  307. An offer condition dependent on the NUMBER of matching items from the basket.
  308. """
  309. class Meta:
  310. proxy = True
  311. verbose_name = _("Count Condition")
  312. verbose_name_plural = _("Count Conditions")
  313. def is_satisfied(self, basket):
  314. """
  315. Determines whether a given basket meets this condition
  316. """
  317. num_matches = 0
  318. for line in basket.all_lines():
  319. if (self.can_apply_condition(line.product)
  320. and line.quantity_without_discount > 0):
  321. num_matches += line.quantity_without_discount
  322. if num_matches >= self.value:
  323. return True
  324. return False
  325. def _get_num_matches(self, basket):
  326. if hasattr(self, '_num_matches'):
  327. return getattr(self, '_num_matches')
  328. num_matches = 0
  329. for line in basket.all_lines():
  330. if (self.can_apply_condition(line.product)
  331. and line.quantity_without_discount > 0):
  332. num_matches += line.quantity_without_discount
  333. self._num_matches = num_matches
  334. return num_matches
  335. def is_partially_satisfied(self, basket):
  336. num_matches = self._get_num_matches(basket)
  337. return 0 < num_matches < self.value
  338. def get_upsell_message(self, basket):
  339. num_matches = self._get_num_matches(basket)
  340. delta = self.value - num_matches
  341. return ungettext('Buy %(delta)d more product from %(range)s',
  342. 'Buy %(delta)d more products from %(range)s', delta) % {
  343. 'delta': delta, 'range': self.range}
  344. def consume_items(self, basket, lines=None, value=None):
  345. """
  346. Marks items within the basket lines as consumed so they
  347. can't be reused in other offers.
  348. """
  349. lines = lines or basket.all_lines()
  350. consumed_products = []
  351. value = self.value if value is None else value
  352. for line in lines:
  353. if self.can_apply_condition(line.product):
  354. quantity_to_consume = min(line.quantity_without_discount,
  355. value - len(consumed_products))
  356. line.consume(quantity_to_consume)
  357. consumed_products.extend((line.product,)*int(quantity_to_consume))
  358. if len(consumed_products) == value:
  359. break
  360. return consumed_products
  361. class CoverageCondition(Condition):
  362. """
  363. An offer condition dependent on the number of DISTINCT matching items from the basket.
  364. """
  365. class Meta:
  366. proxy = True
  367. verbose_name = _("Coverage Condition")
  368. verbose_name_plural = _("Coverage Conditions")
  369. def is_satisfied(self, basket):
  370. """
  371. Determines whether a given basket meets this condition
  372. """
  373. covered_ids = []
  374. for line in basket.all_lines():
  375. if not line.is_available_for_discount:
  376. continue
  377. product = line.product
  378. if (self.can_apply_condition(product) and product.id not in covered_ids):
  379. covered_ids.append(product.id)
  380. if len(covered_ids) >= self.value:
  381. return True
  382. return False
  383. def _get_num_covered_products(self, basket):
  384. covered_ids = []
  385. for line in basket.all_lines():
  386. if not line.is_available_for_discount:
  387. continue
  388. product = line.product
  389. if (self.can_apply_condition(product) and product.id not in covered_ids):
  390. covered_ids.append(product.id)
  391. return len(covered_ids)
  392. def get_upsell_message(self, basket):
  393. delta = self.value - self._get_num_covered_products(basket)
  394. return ungettext('Buy %(delta)d more product from %(range)s',
  395. 'Buy %(delta)d more products from %(range)s', delta) % {
  396. 'delta': delta, 'range': self.range}
  397. def is_partially_satisfied(self, basket):
  398. return 0 < self._get_num_covered_products(basket) < self.value
  399. def consume_items(self, basket, lines=None, value=None):
  400. """
  401. Marks items within the basket lines as consumed so they
  402. can't be reused in other offers.
  403. """
  404. lines = lines or basket.all_lines()
  405. consumed_products = []
  406. value = self.value if value is None else value
  407. for line in basket.all_lines():
  408. product = line.product
  409. if (line.is_available_for_discount and self.can_apply_condition(product)
  410. and product not in consumed_products):
  411. line.consume(1)
  412. consumed_products.append(line.product)
  413. if len(consumed_products) >= value:
  414. break
  415. return consumed_products
  416. def get_value_of_satisfying_items(self, basket):
  417. covered_ids = []
  418. value = Decimal('0.00')
  419. for line in basket.all_lines():
  420. if (self.can_apply_condition(line.product) and line.product.id not in covered_ids):
  421. covered_ids.append(line.product.id)
  422. value += line.unit_price_incl_tax
  423. if len(covered_ids) >= self.value:
  424. return value
  425. return value
  426. class ValueCondition(Condition):
  427. """
  428. An offer condition dependent on the VALUE of matching items from the basket.
  429. """
  430. price_field = 'price_incl_tax'
  431. class Meta:
  432. proxy = True
  433. verbose_name = _("Value Condition")
  434. verbose_name_plural = _("Value Conditions")
  435. def is_satisfied(self, basket):
  436. """Determines whether a given basket meets this condition"""
  437. value_of_matches = Decimal('0.00')
  438. for line in basket.all_lines():
  439. product = line.product
  440. if (self.can_apply_condition(product) and product.has_stockrecord
  441. and line.quantity_without_discount > 0):
  442. price = getattr(product.stockrecord, self.price_field)
  443. value_of_matches += price * int(line.quantity_without_discount)
  444. if value_of_matches >= self.value:
  445. return True
  446. return False
  447. def _get_value_of_matches(self, basket):
  448. if hasattr(self, '_value_of_matches'):
  449. return getattr(self, '_value_of_matches')
  450. value_of_matches = Decimal('0.00')
  451. for line in basket.all_lines():
  452. product = line.product
  453. if (self.can_apply_condition(product) and product.has_stockrecord
  454. and line.quantity_without_discount > 0):
  455. price = getattr(product.stockrecord, self.price_field)
  456. value_of_matches += price * int(line.quantity_without_discount)
  457. self._value_of_matches = value_of_matches
  458. return value_of_matches
  459. def is_partially_satisfied(self, basket):
  460. value_of_matches = self._get_value_of_matches(basket)
  461. return Decimal('0.00') < value_of_matches < self.value
  462. def get_upsell_message(self, basket):
  463. value_of_matches = self._get_value_of_matches(basket)
  464. return _('Spend %(value)s more from %(range)s') % {'value': value_of_matches, 'range': self.range}
  465. def consume_items(self, basket, lines=None, value=None):
  466. """
  467. Marks items within the basket lines as consumed so they
  468. can't be reused in other offers.
  469. We allow lines to be passed in as sometimes we want them sorted
  470. in a specific order.
  471. """
  472. value_of_matches = Decimal('0.00')
  473. lines = lines or basket.all_lines()
  474. consumed_products = []
  475. value = self.value if value is None else value
  476. for line in basket.all_lines():
  477. product = line.product
  478. if (self.can_apply_condition(product) and product.has_stockrecord):
  479. price = getattr(product.stockrecord, self.price_field)
  480. if not price:
  481. continue
  482. quantity_to_consume = min(line.quantity_without_discount,
  483. int(((value - value_of_matches)/price).quantize(Decimal(1),
  484. ROUND_UP)))
  485. value_of_matches += price * quantity_to_consume
  486. line.consume(quantity_to_consume)
  487. consumed_products.extend((line.product,)*int(quantity_to_consume))
  488. if value_of_matches >= value:
  489. break
  490. return consumed_products
  491. # ========
  492. # Benefits
  493. # ========
  494. class PercentageDiscountBenefit(Benefit):
  495. """
  496. An offer benefit that gives a percentage discount
  497. """
  498. class Meta:
  499. proxy = True
  500. verbose_name = _("Percentage Discount Benefit")
  501. verbose_name_plural = _("Percentage Discount Benefits")
  502. def apply(self, basket, condition=None):
  503. discount = Decimal('0.00')
  504. affected_items = 0
  505. max_affected_items = self._effective_max_affected_items()
  506. for line in basket.all_lines():
  507. if affected_items >= max_affected_items:
  508. break
  509. product = line.product
  510. if (self.range.contains_product(product) and product.has_stockrecord
  511. and self.can_apply_benefit(product)):
  512. price = getattr(product.stockrecord, self.price_field)
  513. quantity = min(line.quantity_without_discount,
  514. max_affected_items - affected_items)
  515. line_discount = self.round(self.value/100 * price * int(quantity))
  516. line.discount(line_discount, quantity)
  517. affected_items += quantity
  518. discount += line_discount
  519. if discount > 0 and condition:
  520. condition.consume_items(basket)
  521. return discount
  522. class AbsoluteDiscountBenefit(Benefit):
  523. """
  524. An offer benefit that gives an absolute discount
  525. """
  526. class Meta:
  527. proxy = True
  528. verbose_name = _("Absolute Discount Benefit")
  529. verbose_name_plural = _("Absolute Discount Benefits")
  530. def apply(self, basket, condition=None):
  531. discount = Decimal('0.00')
  532. affected_items = 0
  533. max_affected_items = self._effective_max_affected_items()
  534. for line in basket.all_lines():
  535. if affected_items >= max_affected_items:
  536. break
  537. product = line.product
  538. if (self.range.contains_product(product) and product.has_stockrecord
  539. and self.can_apply_benefit(product)):
  540. price = getattr(product.stockrecord, self.price_field)
  541. if not price:
  542. # Avoid zero price products
  543. continue
  544. remaining_discount = self.value - discount
  545. quantity_affected = int(min(line.quantity_without_discount,
  546. max_affected_items - affected_items,
  547. math.ceil(remaining_discount / price)))
  548. # Update line with discounts
  549. line_discount = self.round(min(remaining_discount, quantity_affected * price))
  550. if not condition:
  551. line.discount(line_discount, quantity_affected)
  552. # Update loop vars
  553. affected_items += quantity_affected
  554. remaining_discount -= line_discount
  555. discount += line_discount
  556. if discount > 0 and condition:
  557. condition.consume_items(basket)
  558. return discount
  559. class FixedPriceBenefit(Benefit):
  560. """
  561. An offer benefit that gives the items in the condition for a
  562. fixed price. This is useful for "bundle" offers.
  563. Note that we ignore the benefit range here and only give a fixed price
  564. for the products in the condition range.
  565. The condition should be a count condition
  566. """
  567. class Meta:
  568. proxy = True
  569. verbose_name = _("Fixed Price Benefit")
  570. verbose_name_plural = _("Fixed Price Benefits")
  571. def apply(self, basket, condition=None):
  572. num_covered = 0
  573. num_permitted = int(condition.value)
  574. covered_lines = []
  575. product_total = Decimal('0.00')
  576. for line in basket.all_lines():
  577. product = line.product
  578. if (condition.range.contains_product(product) and line.quantity_without_discount > 0
  579. and self.can_apply_benefit(product)):
  580. # Line is available - determine quantity to consume and
  581. # record the total of the consumed products
  582. if isinstance(condition, CoverageCondition):
  583. quantity = 1
  584. else:
  585. quantity = min(line.quantity_without_discount, num_permitted - num_covered)
  586. num_covered += quantity
  587. product_total += quantity*line.unit_price_incl_tax
  588. covered_lines.append((line, quantity))
  589. if num_covered >= num_permitted:
  590. break
  591. discount = max(product_total - self.value, Decimal('0.00'))
  592. if not discount:
  593. return discount
  594. # Apply discount weighted by original value of line
  595. discount_applied = Decimal('0.00')
  596. last_line = covered_lines[-1][0]
  597. for line, quantity in covered_lines:
  598. if line == last_line:
  599. # If last line, we just take the difference to ensure that
  600. # a rounding doesn't lead to an off-by-one error
  601. line_discount = discount - discount_applied
  602. else:
  603. line_discount = self.round(discount * (line.unit_price_incl_tax * quantity) / product_total)
  604. line.discount(line_discount, quantity)
  605. discount_applied += line_discount
  606. return discount
  607. class MultibuyDiscountBenefit(Benefit):
  608. class Meta:
  609. proxy = True
  610. verbose_name = _("Multibuy Discount Benefit")
  611. verbose_name_plural = _("Multibuy Discount Benefits")
  612. def apply(self, basket, condition=None):
  613. benefit_lines = [line for line in basket.all_lines() if (self.range.contains_product(line.product) and
  614. line.quantity_without_discount > 0 and
  615. line.product.has_stockrecord and
  616. self.can_apply_benefit(line.product))]
  617. if not benefit_lines:
  618. return self.round(Decimal('0.00'))
  619. # Determine cheapest line to give for free
  620. line_price_getter = lambda line: getattr(line.product.stockrecord,
  621. self.price_field)
  622. free_line = min(benefit_lines, key=line_price_getter)
  623. discount = line_price_getter(free_line)
  624. if condition:
  625. compare = lambda l1, l2: cmp(line_price_getter(l2),
  626. line_price_getter(l1))
  627. lines_with_price = [line for line in basket.all_lines() if line.product.has_stockrecord]
  628. sorted_lines = sorted(lines_with_price, compare)
  629. free_line.discount(discount, 1)
  630. if condition.range.contains_product(free_line.product):
  631. condition.consume_items(basket, lines=sorted_lines,
  632. value=condition.value-1)
  633. else:
  634. condition.consume_items(basket, lines=sorted_lines)
  635. else:
  636. free_line.discount(discount, 0)
  637. return self.round(discount)