您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符

models.py 30KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760
  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. raise NotImplementedError("This method should never be called - "
  157. "ensure you are using the correct proxy model")
  158. def is_satisfied(self, basket):
  159. """
  160. Determines whether a given basket meets this condition. This is
  161. stubbed in this top-class object. The subclassing proxies are
  162. responsible for implementing it correctly.
  163. """
  164. return False
  165. def is_partially_satisfied(self, basket):
  166. """
  167. Determine if the basket partially meets the condition. This is useful
  168. for up-selling messages to entice customers to buy something more in
  169. order to qualify for an offer.
  170. """
  171. return False
  172. def get_upsell_message(self, basket):
  173. return None
  174. def can_apply_condition(self, product):
  175. """
  176. Determines whether the condition can be applied to a given product
  177. """
  178. return (self.range.contains_product(product)
  179. and product.is_discountable)
  180. class Benefit(models.Model):
  181. PERCENTAGE, FIXED, MULTIBUY, FIXED_PRICE = ("Percentage", "Absolute", "Multibuy", "Fixed price")
  182. TYPE_CHOICES = (
  183. (PERCENTAGE, _("Discount is a % of the product's value")),
  184. (FIXED, _("Discount is a fixed amount off the product's value")),
  185. (MULTIBUY, _("Discount is to give the cheapest product for free")),
  186. (FIXED_PRICE, _("Get the products that meet the condition for a fixed price")),
  187. )
  188. range = models.ForeignKey('offer.Range', null=True, blank=True)
  189. type = models.CharField(_('Type'), max_length=128, choices=TYPE_CHOICES)
  190. value = PositiveDecimalField(_('Value'), decimal_places=2, max_digits=12,
  191. null=True, blank=True)
  192. price_field = 'price_incl_tax'
  193. # If this is not set, then there is no upper limit on how many products
  194. # can be discounted by this benefit.
  195. max_affected_items = models.PositiveIntegerField(_('Max Affected Items'), blank=True, null=True,
  196. help_text=_("""Set this to prevent the discount consuming all items within the range that are in the basket."""))
  197. class Meta:
  198. verbose_name = _("Benefit")
  199. verbose_name_plural = _("Benefits")
  200. def __unicode__(self):
  201. if self.type == self.PERCENTAGE:
  202. desc = _("%(value)s%% discount on %(range)s") % {'value': self.value, 'range': unicode(self.range).lower()}
  203. elif self.type == self.MULTIBUY:
  204. desc = _("Cheapest product is free from %s") % unicode(self.range).lower()
  205. elif self.type == self.FIXED_PRICE:
  206. desc = _("The products that meet the condition are sold for %s") % self.value
  207. else:
  208. desc = _("%(value).2f discount on %(range)s") % {'value': float(self.value),
  209. 'range': unicode(self.range).lower()}
  210. if self.max_affected_items:
  211. desc += ungettext(" (max 1 item)", " (max %d items)", self.max_affected_items) % self.max_affected_items
  212. return desc
  213. description = __unicode__
  214. def apply(self, basket, condition=None):
  215. return Decimal('0.00')
  216. def clean(self):
  217. if self.value is None:
  218. if not self.type:
  219. raise ValidationError(_("Benefit requires a value"))
  220. elif self.type != self.MULTIBUY:
  221. raise ValidationError(_("Benefits of type %s need a value") % self.type)
  222. elif self.value > 100 and self.type == 'Percentage':
  223. raise ValidationError(_("Percentage benefit value can't be greater than 100"))
  224. # All benefits need a range apart from FIXED_PRICE
  225. if self.type and self.type != self.FIXED_PRICE and not self.range:
  226. raise ValidationError(_("Benefits of type %s need a range") % self.type)
  227. def round(self, amount):
  228. """
  229. Apply rounding to discount amount
  230. """
  231. if hasattr(settings, 'OSCAR_OFFER_ROUNDING_FUNCTION'):
  232. return settings.OSCAR_OFFER_ROUNDING_FUNCTION(amount)
  233. return amount.quantize(Decimal('.01'), ROUND_DOWN)
  234. def _effective_max_affected_items(self):
  235. if not self.max_affected_items:
  236. max_affected_items = 10000
  237. else:
  238. max_affected_items = self.max_affected_items
  239. return max_affected_items
  240. def can_apply_benefit(self, product):
  241. """
  242. Determines whether the benefit can be applied to a given product
  243. """
  244. return product.is_discountable
  245. class Range(models.Model):
  246. """
  247. Represents a range of products that can be used within an offer
  248. """
  249. name = models.CharField(_("Name"), max_length=128, unique=True)
  250. includes_all_products = models.BooleanField(_('Includes All Products'), default=False)
  251. included_products = models.ManyToManyField('catalogue.Product', related_name='includes', blank=True)
  252. excluded_products = models.ManyToManyField('catalogue.Product', related_name='excludes', blank=True)
  253. classes = models.ManyToManyField('catalogue.ProductClass', related_name='classes', blank=True)
  254. included_categories = models.ManyToManyField('catalogue.Category', related_name='includes', blank=True)
  255. date_created = models.DateTimeField(auto_now_add=True)
  256. __included_product_ids = None
  257. __excluded_product_ids = None
  258. __class_ids = None
  259. class Meta:
  260. verbose_name = _("Range")
  261. verbose_name_plural = _("Ranges")
  262. def __unicode__(self):
  263. return self.name
  264. def contains_product(self, product):
  265. """
  266. Check whether the passed product is part of this range
  267. """
  268. # We look for shortcircuit checks first before
  269. # the tests that require more database queries.
  270. if settings.OSCAR_OFFER_BLACKLIST_PRODUCT and \
  271. settings.OSCAR_OFFER_BLACKLIST_PRODUCT(product):
  272. return False
  273. excluded_product_ids = self._excluded_product_ids()
  274. if product.id in excluded_product_ids:
  275. return False
  276. if self.includes_all_products:
  277. return True
  278. if product.product_class_id in self._class_ids():
  279. return True
  280. included_product_ids = self._included_product_ids()
  281. if product.id in included_product_ids:
  282. return True
  283. test_categories = self.included_categories.all()
  284. if test_categories:
  285. for category in product.categories.all():
  286. for test_category in test_categories:
  287. if category == test_category or category.is_descendant_of(test_category):
  288. return True
  289. return False
  290. def _included_product_ids(self):
  291. if None == self.__included_product_ids:
  292. self.__included_product_ids = [row['id'] for row in self.included_products.values('id')]
  293. return self.__included_product_ids
  294. def _excluded_product_ids(self):
  295. if None == self.__excluded_product_ids:
  296. self.__excluded_product_ids = [row['id'] for row in self.excluded_products.values('id')]
  297. return self.__excluded_product_ids
  298. def _class_ids(self):
  299. if None == self.__class_ids:
  300. self.__class_ids = [row['id'] for row in self.classes.values('id')]
  301. return self.__class_ids
  302. def num_products(self):
  303. if self.includes_all_products:
  304. return None
  305. return self.included_products.all().count()
  306. class CountCondition(Condition):
  307. """
  308. An offer condition dependent on the NUMBER of matching items from the basket.
  309. """
  310. class Meta:
  311. proxy = True
  312. verbose_name = _("Count Condition")
  313. verbose_name_plural = _("Count Conditions")
  314. def is_satisfied(self, basket):
  315. """
  316. Determines whether a given basket meets this condition
  317. """
  318. num_matches = 0
  319. for line in basket.all_lines():
  320. if (self.can_apply_condition(line.product)
  321. and line.quantity_without_discount > 0):
  322. num_matches += line.quantity_without_discount
  323. if num_matches >= self.value:
  324. return True
  325. return False
  326. def _get_num_matches(self, basket):
  327. if hasattr(self, '_num_matches'):
  328. return getattr(self, '_num_matches')
  329. num_matches = 0
  330. for line in basket.all_lines():
  331. if (self.can_apply_condition(line.product)
  332. and line.quantity_without_discount > 0):
  333. num_matches += line.quantity_without_discount
  334. self._num_matches = num_matches
  335. return num_matches
  336. def is_partially_satisfied(self, basket):
  337. num_matches = self._get_num_matches(basket)
  338. return 0 < num_matches < self.value
  339. def get_upsell_message(self, basket):
  340. num_matches = self._get_num_matches(basket)
  341. delta = self.value - num_matches
  342. return ungettext('Buy %(delta)d more product from %(range)s',
  343. 'Buy %(delta)d more products from %(range)s', delta) % {
  344. 'delta': delta, 'range': self.range}
  345. def consume_items(self, basket, lines=None, value=None):
  346. """
  347. Marks items within the basket lines as consumed so they
  348. can't be reused in other offers.
  349. """
  350. lines = lines or basket.all_lines()
  351. consumed_products = []
  352. value = self.value if value is None else value
  353. for line in lines:
  354. if self.can_apply_condition(line.product):
  355. quantity_to_consume = min(line.quantity_without_discount,
  356. value - len(consumed_products))
  357. line.consume(quantity_to_consume)
  358. consumed_products.extend((line.product,)*int(quantity_to_consume))
  359. if len(consumed_products) == value:
  360. break
  361. return consumed_products
  362. class CoverageCondition(Condition):
  363. """
  364. An offer condition dependent on the number of DISTINCT matching items from the basket.
  365. """
  366. class Meta:
  367. proxy = True
  368. verbose_name = _("Coverage Condition")
  369. verbose_name_plural = _("Coverage Conditions")
  370. def is_satisfied(self, basket):
  371. """
  372. Determines whether a given basket meets this condition
  373. """
  374. covered_ids = []
  375. for line in basket.all_lines():
  376. if not line.is_available_for_discount:
  377. continue
  378. product = line.product
  379. if (self.can_apply_condition(product) and product.id not in covered_ids):
  380. covered_ids.append(product.id)
  381. if len(covered_ids) >= self.value:
  382. return True
  383. return False
  384. def _get_num_covered_products(self, basket):
  385. covered_ids = []
  386. for line in basket.all_lines():
  387. if not line.is_available_for_discount:
  388. continue
  389. product = line.product
  390. if (self.can_apply_condition(product) and product.id not in covered_ids):
  391. covered_ids.append(product.id)
  392. return len(covered_ids)
  393. def get_upsell_message(self, basket):
  394. delta = self.value - self._get_num_covered_products(basket)
  395. return ungettext('Buy %(delta)d more product from %(range)s',
  396. 'Buy %(delta)d more products from %(range)s', delta) % {
  397. 'delta': delta, 'range': self.range}
  398. def is_partially_satisfied(self, basket):
  399. return 0 < self._get_num_covered_products(basket) < self.value
  400. def consume_items(self, basket, lines=None, value=None):
  401. """
  402. Marks items within the basket lines as consumed so they
  403. can't be reused in other offers.
  404. """
  405. lines = lines or basket.all_lines()
  406. consumed_products = []
  407. value = self.value if value is None else value
  408. for line in basket.all_lines():
  409. product = line.product
  410. if (line.is_available_for_discount and self.can_apply_condition(product)
  411. and product not in consumed_products):
  412. line.consume(1)
  413. consumed_products.append(line.product)
  414. if len(consumed_products) >= value:
  415. break
  416. return consumed_products
  417. def get_value_of_satisfying_items(self, basket):
  418. covered_ids = []
  419. value = Decimal('0.00')
  420. for line in basket.all_lines():
  421. if (self.can_apply_condition(line.product) and line.product.id not in covered_ids):
  422. covered_ids.append(line.product.id)
  423. value += line.unit_price_incl_tax
  424. if len(covered_ids) >= self.value:
  425. return value
  426. return value
  427. class ValueCondition(Condition):
  428. """
  429. An offer condition dependent on the VALUE of matching items from the basket.
  430. """
  431. price_field = 'price_incl_tax'
  432. class Meta:
  433. proxy = True
  434. verbose_name = _("Value Condition")
  435. verbose_name_plural = _("Value Conditions")
  436. def is_satisfied(self, basket):
  437. """Determines whether a given basket meets this condition"""
  438. value_of_matches = Decimal('0.00')
  439. for line in basket.all_lines():
  440. product = line.product
  441. if (self.can_apply_condition(product) and product.has_stockrecord
  442. and line.quantity_without_discount > 0):
  443. price = getattr(product.stockrecord, self.price_field)
  444. value_of_matches += price * int(line.quantity_without_discount)
  445. if value_of_matches >= self.value:
  446. return True
  447. return False
  448. def _get_value_of_matches(self, basket):
  449. if hasattr(self, '_value_of_matches'):
  450. return getattr(self, '_value_of_matches')
  451. value_of_matches = Decimal('0.00')
  452. for line in basket.all_lines():
  453. product = line.product
  454. if (self.can_apply_condition(product) and product.has_stockrecord
  455. and line.quantity_without_discount > 0):
  456. price = getattr(product.stockrecord, self.price_field)
  457. value_of_matches += price * int(line.quantity_without_discount)
  458. self._value_of_matches = value_of_matches
  459. return value_of_matches
  460. def is_partially_satisfied(self, basket):
  461. value_of_matches = self._get_value_of_matches(basket)
  462. return Decimal('0.00') < value_of_matches < self.value
  463. def get_upsell_message(self, basket):
  464. value_of_matches = self._get_value_of_matches(basket)
  465. return _('Spend %(value)s more from %(range)s') % {'value': value_of_matches, 'range': self.range}
  466. def consume_items(self, basket, lines=None, value=None):
  467. """
  468. Marks items within the basket lines as consumed so they
  469. can't be reused in other offers.
  470. We allow lines to be passed in as sometimes we want them sorted
  471. in a specific order.
  472. """
  473. value_of_matches = Decimal('0.00')
  474. lines = lines or basket.all_lines()
  475. consumed_products = []
  476. value = self.value if value is None else value
  477. for line in basket.all_lines():
  478. product = line.product
  479. if (self.can_apply_condition(product) and product.has_stockrecord):
  480. price = getattr(product.stockrecord, self.price_field)
  481. if not price:
  482. continue
  483. quantity_to_consume = min(line.quantity_without_discount,
  484. int(((value - value_of_matches)/price).quantize(Decimal(1),
  485. ROUND_UP)))
  486. value_of_matches += price * quantity_to_consume
  487. line.consume(quantity_to_consume)
  488. consumed_products.extend((line.product,)*int(quantity_to_consume))
  489. if value_of_matches >= value:
  490. break
  491. return consumed_products
  492. # ========
  493. # Benefits
  494. # ========
  495. class PercentageDiscountBenefit(Benefit):
  496. """
  497. An offer benefit that gives a percentage discount
  498. """
  499. class Meta:
  500. proxy = True
  501. verbose_name = _("Percentage Discount Benefit")
  502. verbose_name_plural = _("Percentage Discount Benefits")
  503. def apply(self, basket, condition=None):
  504. discount = Decimal('0.00')
  505. affected_items = 0
  506. max_affected_items = self._effective_max_affected_items()
  507. for line in basket.all_lines():
  508. if affected_items >= max_affected_items:
  509. break
  510. product = line.product
  511. if (self.range.contains_product(product) and product.has_stockrecord
  512. and self.can_apply_benefit(product)):
  513. price = getattr(product.stockrecord, self.price_field)
  514. quantity = min(line.quantity_without_discount,
  515. max_affected_items - affected_items)
  516. line_discount = self.round(self.value/100 * price * int(quantity))
  517. line.discount(line_discount, quantity)
  518. affected_items += quantity
  519. discount += line_discount
  520. if discount > 0 and condition:
  521. condition.consume_items(basket)
  522. return discount
  523. class AbsoluteDiscountBenefit(Benefit):
  524. """
  525. An offer benefit that gives an absolute discount
  526. """
  527. class Meta:
  528. proxy = True
  529. verbose_name = _("Absolute Discount Benefit")
  530. verbose_name_plural = _("Absolute Discount Benefits")
  531. def apply(self, basket, condition=None):
  532. discount = Decimal('0.00')
  533. affected_items = 0
  534. max_affected_items = self._effective_max_affected_items()
  535. for line in basket.all_lines():
  536. if affected_items >= max_affected_items:
  537. break
  538. product = line.product
  539. if (self.range.contains_product(product) and product.has_stockrecord
  540. and self.can_apply_benefit(product)):
  541. price = getattr(product.stockrecord, self.price_field)
  542. if not price:
  543. # Avoid zero price products
  544. continue
  545. remaining_discount = self.value - discount
  546. quantity_affected = int(min(line.quantity_without_discount,
  547. max_affected_items - affected_items,
  548. math.ceil(remaining_discount / price)))
  549. # Update line with discounts
  550. line_discount = self.round(min(remaining_discount, quantity_affected * price))
  551. if condition:
  552. # Pass zero as quantity to avoid double consumption
  553. line.discount(line_discount, 0)
  554. else:
  555. line.discount(line_discount, quantity_affected)
  556. # Update loop vars
  557. affected_items += quantity_affected
  558. remaining_discount -= line_discount
  559. discount += line_discount
  560. if discount > 0 and condition:
  561. condition.consume_items(basket)
  562. return discount
  563. class FixedPriceBenefit(Benefit):
  564. """
  565. An offer benefit that gives the items in the condition for a
  566. fixed price. This is useful for "bundle" offers.
  567. Note that we ignore the benefit range here and only give a fixed price
  568. for the products in the condition range.
  569. The condition should be a count condition
  570. """
  571. class Meta:
  572. proxy = True
  573. verbose_name = _("Fixed Price Benefit")
  574. verbose_name_plural = _("Fixed Price Benefits")
  575. def apply(self, basket, condition=None):
  576. num_covered = 0
  577. num_permitted = int(condition.value)
  578. covered_lines = []
  579. product_total = Decimal('0.00')
  580. for line in basket.all_lines():
  581. product = line.product
  582. if (condition.range.contains_product(product) and line.quantity_without_discount > 0
  583. and self.can_apply_benefit(product)):
  584. # Line is available - determine quantity to consume and
  585. # record the total of the consumed products
  586. if isinstance(condition, CoverageCondition):
  587. quantity = 1
  588. else:
  589. quantity = min(line.quantity_without_discount, num_permitted - num_covered)
  590. num_covered += quantity
  591. product_total += quantity*line.unit_price_incl_tax
  592. covered_lines.append((line, quantity))
  593. if num_covered >= num_permitted:
  594. break
  595. discount = max(product_total - self.value, Decimal('0.00'))
  596. if not discount:
  597. return discount
  598. # Apply discount weighted by original value of line
  599. discount_applied = Decimal('0.00')
  600. last_line = covered_lines[-1][0]
  601. for line, quantity in covered_lines:
  602. if line == last_line:
  603. # If last line, we just take the difference to ensure that
  604. # a rounding doesn't lead to an off-by-one error
  605. line_discount = discount - discount_applied
  606. else:
  607. line_discount = self.round(discount * (line.unit_price_incl_tax * quantity) / product_total)
  608. line.discount(line_discount, quantity)
  609. discount_applied += line_discount
  610. return discount
  611. class MultibuyDiscountBenefit(Benefit):
  612. class Meta:
  613. proxy = True
  614. verbose_name = _("Multibuy Discount Benefit")
  615. verbose_name_plural = _("Multibuy Discount Benefits")
  616. def apply(self, basket, condition=None):
  617. benefit_lines = [line for line in basket.all_lines() if (self.range.contains_product(line.product) and
  618. line.quantity_without_discount > 0 and
  619. line.product.has_stockrecord and
  620. self.can_apply_benefit(line.product))]
  621. if not benefit_lines:
  622. return self.round(Decimal('0.00'))
  623. # Determine cheapest line to give for free
  624. line_price_getter = lambda line: getattr(line.product.stockrecord,
  625. self.price_field)
  626. free_line = min(benefit_lines, key=line_price_getter)
  627. discount = line_price_getter(free_line)
  628. if condition:
  629. compare = lambda l1, l2: cmp(line_price_getter(l2),
  630. line_price_getter(l1))
  631. lines_with_price = [line for line in basket.all_lines() if line.product.has_stockrecord]
  632. sorted_lines = sorted(lines_with_price, compare)
  633. free_line.discount(discount, 1)
  634. if condition.range.contains_product(free_line.product):
  635. condition.consume_items(basket, lines=sorted_lines,
  636. value=condition.value-1)
  637. else:
  638. condition.consume_items(basket, lines=sorted_lines)
  639. else:
  640. free_line.discount(discount, 0)
  641. return self.round(discount)