Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836
  1. from decimal import Decimal as D, 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', verbose_name=_("Condition"))
  40. benefit = models.ForeignKey('offer.Benefit', verbose_name=_("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. # Use this field to limit the number of times this offer can be applied to
  52. # a basket.
  53. max_applications = models.PositiveIntegerField(
  54. blank=True, null=True,
  55. help_text=_("This controls the maximum times an offer can "
  56. "be applied to a single basket"))
  57. # We track some information on usage
  58. total_discount = models.DecimalField(_("Total Discount"),
  59. decimal_places=2, max_digits=12,
  60. default=D('0.00'))
  61. num_orders = models.PositiveIntegerField(_("Number of Orders"), default=0)
  62. redirect_url = ExtendedURLField(_("URL redirect (optional)"), blank=True)
  63. date_created = models.DateTimeField(_("Date Created"), auto_now_add=True)
  64. objects = models.Manager()
  65. active = ActiveOfferManager()
  66. # We need to track the voucher that this offer came from (if it is a
  67. # voucher offer)
  68. _voucher = None
  69. class Meta:
  70. ordering = ['-priority']
  71. verbose_name = _("Conditional Offer")
  72. verbose_name_plural = _("Conditional Offers")
  73. def save(self, *args, **kwargs):
  74. if not self.slug:
  75. self.slug = slugify(self.name)
  76. return super(ConditionalOffer, self).save(*args, **kwargs)
  77. def get_absolute_url(self):
  78. return reverse('offer:detail', kwargs={'slug': self.slug})
  79. def __unicode__(self):
  80. return self.name
  81. def clean(self):
  82. if self.start_date and self.end_date and self.start_date > self.end_date:
  83. raise exceptions.ValidationError(_('End date should be later than start date'))
  84. def is_active(self, test_date=None):
  85. if not test_date:
  86. test_date = datetime.date.today()
  87. return self.start_date <= test_date and test_date < self.end_date
  88. def is_condition_satisfied(self, basket):
  89. return self._proxy_condition().is_satisfied(basket)
  90. def is_condition_partially_satisfied(self, basket):
  91. return self._proxy_condition().is_partially_satisfied(basket)
  92. def get_upsell_message(self, basket):
  93. return self._proxy_condition().get_upsell_message(basket)
  94. def apply_benefit(self, basket):
  95. """
  96. Applies the benefit to the given basket and returns the discount.
  97. """
  98. if not self.is_condition_satisfied(basket):
  99. return D('0.00')
  100. return self._proxy_benefit().apply(basket, self._proxy_condition())
  101. def set_voucher(self, voucher):
  102. self._voucher = voucher
  103. def get_voucher(self):
  104. return self._voucher
  105. def get_max_applications(self):
  106. if self.max_applications is None:
  107. # Default value to prevent infinite loops
  108. return 10000
  109. return self.max_applications
  110. def _proxy_condition(self):
  111. """
  112. Returns the appropriate proxy model for the condition
  113. """
  114. field_dict = dict(self.condition.__dict__)
  115. for field in field_dict.keys():
  116. if field.startswith('_'):
  117. del field_dict[field]
  118. klassmap = {
  119. self.condition.COUNT: CountCondition,
  120. self.condition.VALUE: ValueCondition,
  121. self.condition.COVERAGE: CoverageCondition}
  122. if self.condition.type in klassmap:
  123. return klassmap[self.condition.type](**field_dict)
  124. return self.condition
  125. def _proxy_benefit(self):
  126. """
  127. Returns the appropriate proxy model for the condition
  128. """
  129. field_dict = dict(self.benefit.__dict__)
  130. for field in field_dict.keys():
  131. if field.startswith('_'):
  132. del field_dict[field]
  133. klassmap = {
  134. self.benefit.PERCENTAGE: PercentageDiscountBenefit,
  135. self.benefit.FIXED: AbsoluteDiscountBenefit,
  136. self.benefit.MULTIBUY: MultibuyDiscountBenefit,
  137. self.benefit.FIXED_PRICE: FixedPriceBenefit}
  138. if self.benefit.type in klassmap:
  139. return klassmap[self.benefit.type](**field_dict)
  140. return self.benefit
  141. def record_usage(self, discount):
  142. self.num_orders += 1
  143. self.total_discount += discount
  144. self.save()
  145. record_usage.alters_data = True
  146. class Condition(models.Model):
  147. COUNT, VALUE, COVERAGE = ("Count", "Value", "Coverage")
  148. TYPE_CHOICES = (
  149. (COUNT, _("Depends on number of items in basket that are in condition range")),
  150. (VALUE, _("Depends on value of items in basket that are in condition range")),
  151. (COVERAGE, _("Needs to contain a set number of DISTINCT items from the condition range"))
  152. )
  153. range = models.ForeignKey('offer.Range', verbose_name=_("Range"))
  154. type = models.CharField(_('Type'), max_length=128, choices=TYPE_CHOICES)
  155. value = PositiveDecimalField(_('Value'), decimal_places=2, max_digits=12)
  156. class Meta:
  157. verbose_name = _("Condition")
  158. verbose_name_plural = _("Conditions")
  159. def __unicode__(self):
  160. if self.type == self.COUNT:
  161. return _("Basket includes %(count)d item(s) from %(range)s") % {
  162. 'count': self.value, 'range': unicode(self.range).lower()}
  163. elif self.type == self.COVERAGE:
  164. return _("Basket includes %(count)d distinct products from %(range)s") % {
  165. 'count': self.value, 'range': unicode(self.range).lower()}
  166. return _("Basket includes %(count)d value from %(range)s") % {
  167. 'count': self.value, 'range': unicode(self.range).lower()}
  168. description = __unicode__
  169. def consume_items(self, basket, lines):
  170. raise NotImplementedError("This method should never be called - "
  171. "ensure you are using the correct proxy model")
  172. def is_satisfied(self, basket):
  173. """
  174. Determines whether a given basket meets this condition. This is
  175. stubbed in this top-class object. The subclassing proxies are
  176. responsible for implementing it correctly.
  177. """
  178. return False
  179. def is_partially_satisfied(self, basket):
  180. """
  181. Determine if the basket partially meets the condition. This is useful
  182. for up-selling messages to entice customers to buy something more in
  183. order to qualify for an offer.
  184. """
  185. return False
  186. def get_upsell_message(self, basket):
  187. return None
  188. def can_apply_condition(self, product):
  189. """
  190. Determines whether the condition can be applied to a given product
  191. """
  192. return (self.range.contains_product(product)
  193. and product.is_discountable and product.has_stockrecord)
  194. def get_applicable_lines(self, basket, most_expensive_first=True):
  195. """
  196. Return line data for the lines that can be consumed by this condition
  197. """
  198. line_tuples = []
  199. for line in basket.all_lines():
  200. product = line.product
  201. if not self.can_apply_condition(product):
  202. continue
  203. price = line.unit_price_incl_tax
  204. if not price:
  205. continue
  206. line_tuples.append((price, line))
  207. if most_expensive_first:
  208. return sorted(line_tuples, reverse=True)
  209. return sorted(line_tuples)
  210. class Benefit(models.Model):
  211. PERCENTAGE, FIXED, MULTIBUY, FIXED_PRICE = ("Percentage", "Absolute", "Multibuy", "Fixed price")
  212. TYPE_CHOICES = (
  213. (PERCENTAGE, _("Discount is a % of the product's value")),
  214. (FIXED, _("Discount is a fixed amount off the product's value")),
  215. (MULTIBUY, _("Discount is to give the cheapest product for free")),
  216. (FIXED_PRICE, _("Get the products that meet the condition for a fixed price")),
  217. )
  218. range = models.ForeignKey('offer.Range', null=True, blank=True, verbose_name=_("Range"))
  219. type = models.CharField(_("Type"), max_length=128, choices=TYPE_CHOICES)
  220. value = PositiveDecimalField(_("Value"), decimal_places=2, max_digits=12,
  221. null=True, blank=True)
  222. # If this is not set, then there is no upper limit on how many products
  223. # can be discounted by this benefit.
  224. max_affected_items = models.PositiveIntegerField(_("Max Affected Items"), blank=True, null=True,
  225. help_text=_("Set this to prevent the discount consuming all items within the range that are in the basket."))
  226. class Meta:
  227. verbose_name = _("Benefit")
  228. verbose_name_plural = _("Benefits")
  229. def __unicode__(self):
  230. if self.type == self.PERCENTAGE:
  231. desc = _("%(value)s%% discount on %(range)s") % {'value': self.value, 'range': unicode(self.range).lower()}
  232. elif self.type == self.MULTIBUY:
  233. desc = _("Cheapest product is free from %s") % unicode(self.range).lower()
  234. elif self.type == self.FIXED_PRICE:
  235. desc = _("The products that meet the condition are sold for %s") % self.value
  236. else:
  237. desc = _("%(value).2f discount on %(range)s") % {'value': float(self.value),
  238. 'range': unicode(self.range).lower()}
  239. if self.max_affected_items:
  240. desc += ungettext(" (max %d item)", " (max %d items)", self.max_affected_items) % self.max_affected_items
  241. return desc
  242. description = __unicode__
  243. def apply(self, basket, condition):
  244. return D('0.00')
  245. def clean(self):
  246. if self.value is None:
  247. if not self.type:
  248. raise ValidationError(_("Benefit requires a value"))
  249. elif self.type != self.MULTIBUY:
  250. raise ValidationError(_("Benefits of type %s need a value") % self.type)
  251. elif self.value > 100 and self.type == 'Percentage':
  252. raise ValidationError(_("Percentage benefit value can't be greater than 100"))
  253. # All benefits need a range apart from FIXED_PRICE
  254. if self.type and self.type != self.FIXED_PRICE and not self.range:
  255. raise ValidationError(_("Benefits of type %s need a range") % self.type)
  256. def round(self, amount):
  257. """
  258. Apply rounding to discount amount
  259. """
  260. if hasattr(settings, 'OSCAR_OFFER_ROUNDING_FUNCTION'):
  261. return settings.OSCAR_OFFER_ROUNDING_FUNCTION(amount)
  262. return amount.quantize(D('.01'), ROUND_DOWN)
  263. def _effective_max_affected_items(self):
  264. """
  265. Return the maximum number of items that can have a discount applied
  266. during the application of this benefit
  267. """
  268. return self.max_affected_items if self.max_affected_items else 10000
  269. def can_apply_benefit(self, product):
  270. """
  271. Determines whether the benefit can be applied to a given product
  272. """
  273. return product.has_stockrecord and product.is_discountable
  274. def get_applicable_lines(self, basket, range=None):
  275. """
  276. Return the basket lines that are available to be discounted
  277. :basket: The basket
  278. :range: The range of products to use for filtering. The fixed-price
  279. benefit ignores its range and uses the condition range
  280. """
  281. if range is None:
  282. range = self.range
  283. line_tuples = []
  284. for line in basket.all_lines():
  285. product = line.product
  286. if (not range.contains(product) or
  287. not self.can_apply_benefit(product)):
  288. continue
  289. price = line.unit_price_incl_tax
  290. if not price:
  291. # Avoid zero price products
  292. continue
  293. if line.quantity_without_discount == 0:
  294. continue
  295. line_tuples.append((price, line))
  296. # We sort lines to be cheapest first to ensure consistent applications
  297. return sorted(line_tuples)
  298. class Range(models.Model):
  299. """
  300. Represents a range of products that can be used within an offer
  301. """
  302. name = models.CharField(_("Name"), max_length=128, unique=True)
  303. includes_all_products = models.BooleanField(_('Includes All Products'), default=False)
  304. included_products = models.ManyToManyField('catalogue.Product', related_name='includes', blank=True,
  305. verbose_name=_("Included Products"))
  306. excluded_products = models.ManyToManyField('catalogue.Product', related_name='excludes', blank=True,
  307. verbose_name=_("Excluded Products"))
  308. classes = models.ManyToManyField('catalogue.ProductClass', related_name='classes', blank=True,
  309. verbose_name=_("Product Classes"))
  310. included_categories = models.ManyToManyField('catalogue.Category', related_name='includes', blank=True,
  311. verbose_name=_("Included Categories"))
  312. date_created = models.DateTimeField(_("Date Created"), auto_now_add=True)
  313. __included_product_ids = None
  314. __excluded_product_ids = None
  315. __class_ids = None
  316. class Meta:
  317. verbose_name = _("Range")
  318. verbose_name_plural = _("Ranges")
  319. def __unicode__(self):
  320. return self.name
  321. def contains_product(self, product):
  322. """
  323. Check whether the passed product is part of this range
  324. """
  325. # We look for shortcircuit checks first before
  326. # the tests that require more database queries.
  327. if settings.OSCAR_OFFER_BLACKLIST_PRODUCT and \
  328. settings.OSCAR_OFFER_BLACKLIST_PRODUCT(product):
  329. return False
  330. excluded_product_ids = self._excluded_product_ids()
  331. if product.id in excluded_product_ids:
  332. return False
  333. if self.includes_all_products:
  334. return True
  335. if product.product_class_id in self._class_ids():
  336. return True
  337. included_product_ids = self._included_product_ids()
  338. if product.id in included_product_ids:
  339. return True
  340. test_categories = self.included_categories.all()
  341. if test_categories:
  342. for category in product.categories.all():
  343. for test_category in test_categories:
  344. if category == test_category or category.is_descendant_of(test_category):
  345. return True
  346. return False
  347. # Shorter alias
  348. contains = contains_product
  349. def _included_product_ids(self):
  350. if None == self.__included_product_ids:
  351. self.__included_product_ids = [row['id'] for row in self.included_products.values('id')]
  352. return self.__included_product_ids
  353. def _excluded_product_ids(self):
  354. if None == self.__excluded_product_ids:
  355. self.__excluded_product_ids = [row['id'] for row in self.excluded_products.values('id')]
  356. return self.__excluded_product_ids
  357. def _class_ids(self):
  358. if None == self.__class_ids:
  359. self.__class_ids = [row['id'] for row in self.classes.values('id')]
  360. return self.__class_ids
  361. def num_products(self):
  362. if self.includes_all_products:
  363. return None
  364. return self.included_products.all().count()
  365. # ==========
  366. # Conditions
  367. # ==========
  368. class CountCondition(Condition):
  369. """
  370. An offer condition dependent on the NUMBER of matching items from the basket.
  371. """
  372. class Meta:
  373. proxy = True
  374. verbose_name = _("Count Condition")
  375. verbose_name_plural = _("Count Conditions")
  376. def is_satisfied(self, basket):
  377. """
  378. Determines whether a given basket meets this condition
  379. """
  380. num_matches = 0
  381. for line in basket.all_lines():
  382. if (self.can_apply_condition(line.product)
  383. and line.quantity_without_discount > 0):
  384. num_matches += line.quantity_without_discount
  385. if num_matches >= self.value:
  386. return True
  387. return False
  388. def _get_num_matches(self, basket):
  389. if hasattr(self, '_num_matches'):
  390. return getattr(self, '_num_matches')
  391. num_matches = 0
  392. for line in basket.all_lines():
  393. if (self.can_apply_condition(line.product)
  394. and line.quantity_without_discount > 0):
  395. num_matches += line.quantity_without_discount
  396. self._num_matches = num_matches
  397. return num_matches
  398. def is_partially_satisfied(self, basket):
  399. num_matches = self._get_num_matches(basket)
  400. return 0 < num_matches < self.value
  401. def get_upsell_message(self, basket):
  402. num_matches = self._get_num_matches(basket)
  403. delta = self.value - num_matches
  404. return ungettext('Buy %(delta)d more product from %(range)s',
  405. 'Buy %(delta)d more products from %(range)s', delta) % {
  406. 'delta': delta, 'range': self.range}
  407. def consume_items(self, basket, affected_lines):
  408. """
  409. Marks items within the basket lines as consumed so they
  410. can't be reused in other offers.
  411. :basket: The basket
  412. :affected_lines: The lines that have been affected by the discount.
  413. This should be list of tuples (line, discount, qty)
  414. """
  415. # We need to count how many items have already been consumed as part of
  416. # applying the benefit, so we don't consume too many items.
  417. num_consumed = 0
  418. for line, __, quantity in affected_lines:
  419. num_consumed += quantity
  420. to_consume = max(0, self.value - num_consumed)
  421. if to_consume == 0:
  422. return
  423. for __, line in self.get_applicable_lines(basket,
  424. most_expensive_first=True):
  425. quantity_to_consume = min(line.quantity_without_discount,
  426. to_consume)
  427. line.consume(quantity_to_consume)
  428. to_consume -= quantity_to_consume
  429. if to_consume == 0:
  430. break
  431. class CoverageCondition(Condition):
  432. """
  433. An offer condition dependent on the number of DISTINCT matching items from the basket.
  434. """
  435. class Meta:
  436. proxy = True
  437. verbose_name = _("Coverage Condition")
  438. verbose_name_plural = _("Coverage Conditions")
  439. def is_satisfied(self, basket):
  440. """
  441. Determines whether a given basket meets this condition
  442. """
  443. covered_ids = []
  444. for line in basket.all_lines():
  445. if not line.is_available_for_discount:
  446. continue
  447. product = line.product
  448. if (self.can_apply_condition(product) and product.id not in covered_ids):
  449. covered_ids.append(product.id)
  450. if len(covered_ids) >= self.value:
  451. return True
  452. return False
  453. def _get_num_covered_products(self, basket):
  454. covered_ids = []
  455. for line in basket.all_lines():
  456. if not line.is_available_for_discount:
  457. continue
  458. product = line.product
  459. if (self.can_apply_condition(product) and product.id not in covered_ids):
  460. covered_ids.append(product.id)
  461. return len(covered_ids)
  462. def get_upsell_message(self, basket):
  463. delta = self.value - self._get_num_covered_products(basket)
  464. return ungettext('Buy %(delta)d more product from %(range)s',
  465. 'Buy %(delta)d more products from %(range)s', delta) % {
  466. 'delta': delta, 'range': self.range}
  467. def is_partially_satisfied(self, basket):
  468. return 0 < self._get_num_covered_products(basket) < self.value
  469. def consume_items(self, basket, affected_lines):
  470. """
  471. Marks items within the basket lines as consumed so they
  472. can't be reused in other offers.
  473. """
  474. # Determine products that have already been consumed by applying the
  475. # benefit
  476. consumed_products = []
  477. for line, __, quantity in affected_lines:
  478. consumed_products.append(line.product)
  479. to_consume = max(0, self.value - len(consumed_products))
  480. if to_consume == 0:
  481. return
  482. for line in basket.all_lines():
  483. product = line.product
  484. if not self.can_apply_condition(product):
  485. continue
  486. if product in consumed_products:
  487. continue
  488. if not line.is_available_for_discount:
  489. continue
  490. # Only consume a quantity of 1 from each line
  491. line.consume(1)
  492. consumed_products.append(product)
  493. to_consume -= 1
  494. if to_consume == 0:
  495. break
  496. def get_value_of_satisfying_items(self, basket):
  497. covered_ids = []
  498. value = D('0.00')
  499. for line in basket.all_lines():
  500. if (self.can_apply_condition(line.product) and line.product.id not in covered_ids):
  501. covered_ids.append(line.product.id)
  502. value += line.unit_price_incl_tax
  503. if len(covered_ids) >= self.value:
  504. return value
  505. return value
  506. class ValueCondition(Condition):
  507. """
  508. An offer condition dependent on the VALUE of matching items from the
  509. basket.
  510. """
  511. class Meta:
  512. proxy = True
  513. verbose_name = _("Value Condition")
  514. verbose_name_plural = _("Value Conditions")
  515. def is_satisfied(self, basket):
  516. """
  517. Determine whether a given basket meets this condition
  518. """
  519. value_of_matches = D('0.00')
  520. for line in basket.all_lines():
  521. product = line.product
  522. if (self.can_apply_condition(product) and product.has_stockrecord
  523. and line.quantity_without_discount > 0):
  524. price = line.unit_price_incl_tax
  525. value_of_matches += price * int(line.quantity_without_discount)
  526. if value_of_matches >= self.value:
  527. return True
  528. return False
  529. def _get_value_of_matches(self, basket):
  530. if hasattr(self, '_value_of_matches'):
  531. return getattr(self, '_value_of_matches')
  532. value_of_matches = D('0.00')
  533. for line in basket.all_lines():
  534. product = line.product
  535. if (self.can_apply_condition(product) and product.has_stockrecord
  536. and line.quantity_without_discount > 0):
  537. price = line.unit_price_incl_tax
  538. value_of_matches += price * int(line.quantity_without_discount)
  539. self._value_of_matches = value_of_matches
  540. return value_of_matches
  541. def is_partially_satisfied(self, basket):
  542. value_of_matches = self._get_value_of_matches(basket)
  543. return D('0.00') < value_of_matches < self.value
  544. def get_upsell_message(self, basket):
  545. value_of_matches = self._get_value_of_matches(basket)
  546. return _('Spend %(value)s more from %(range)s') % {'value': value_of_matches, 'range': self.range}
  547. def consume_items(self, basket, affected_lines):
  548. """
  549. Marks items within the basket lines as consumed so they
  550. can't be reused in other offers.
  551. We allow lines to be passed in as sometimes we want them sorted
  552. in a specific order.
  553. """
  554. # Determine value of items already consumed as part of discount
  555. value_consumed = D('0.00')
  556. for line, __, qty in affected_lines:
  557. price = line.unit_price_incl_tax
  558. value_consumed += price * qty
  559. to_consume = max(0, self.value - value_consumed)
  560. if to_consume == 0:
  561. return
  562. for price, line in self.get_applicable_lines(basket,
  563. most_expensive_first=True):
  564. quantity_to_consume = min(
  565. line.quantity_without_discount,
  566. (to_consume / price).quantize(D(1), ROUND_UP))
  567. line.consume(quantity_to_consume)
  568. to_consume -= price * quantity_to_consume
  569. if to_consume == 0:
  570. break
  571. # ========
  572. # Benefits
  573. # ========
  574. class PercentageDiscountBenefit(Benefit):
  575. """
  576. An offer benefit that gives a percentage discount
  577. """
  578. class Meta:
  579. proxy = True
  580. verbose_name = _("Percentage discount benefit")
  581. verbose_name_plural = _("Percentage discount benefits")
  582. def apply(self, basket, condition):
  583. line_tuples = self.get_applicable_lines(basket)
  584. discount = D('0.00')
  585. affected_items = 0
  586. max_affected_items = self._effective_max_affected_items()
  587. affected_lines = []
  588. for price, line in line_tuples:
  589. if affected_items >= max_affected_items:
  590. break
  591. quantity_affected = min(line.quantity_without_discount,
  592. max_affected_items - affected_items)
  593. line_discount = self.round(self.value / D('100.0') * price
  594. * int(quantity_affected))
  595. line.discount(line_discount, quantity_affected)
  596. affected_lines.append((line, line_discount, quantity_affected))
  597. affected_items += quantity_affected
  598. discount += line_discount
  599. if discount > 0:
  600. condition.consume_items(basket, affected_lines)
  601. return discount
  602. class AbsoluteDiscountBenefit(Benefit):
  603. """
  604. An offer benefit that gives an absolute discount
  605. """
  606. class Meta:
  607. proxy = True
  608. verbose_name = _("Absolute discount benefit")
  609. verbose_name_plural = _("Absolute discount benefits")
  610. def apply(self, basket, condition):
  611. line_tuples = self.get_applicable_lines(basket)
  612. if not line_tuples:
  613. return self.round(D('0.00'))
  614. discount = D('0.00')
  615. affected_items = 0
  616. max_affected_items = self._effective_max_affected_items()
  617. affected_lines = []
  618. for price, line in line_tuples:
  619. if affected_items >= max_affected_items:
  620. break
  621. remaining_discount = self.value - discount
  622. quantity_affected = min(
  623. line.quantity_without_discount,
  624. max_affected_items - affected_items,
  625. int(math.ceil(remaining_discount / price)))
  626. line_discount = self.round(min(remaining_discount,
  627. quantity_affected * price))
  628. line.discount(line_discount, quantity_affected)
  629. affected_lines.append((line, line_discount, quantity_affected))
  630. affected_items += quantity_affected
  631. discount += line_discount
  632. if discount > 0:
  633. condition.consume_items(basket, affected_lines)
  634. return discount
  635. class FixedPriceBenefit(Benefit):
  636. """
  637. An offer benefit that gives the items in the condition for a
  638. fixed price. This is useful for "bundle" offers.
  639. Note that we ignore the benefit range here and only give a fixed price
  640. for the products in the condition range. The condition cannot be a value
  641. condition.
  642. We also ignore the max_affected_items setting.
  643. """
  644. class Meta:
  645. proxy = True
  646. verbose_name = _("Fixed price benefit")
  647. verbose_name_plural = _("Fixed price benefits")
  648. def apply(self, basket, condition):
  649. if isinstance(condition, ValueCondition):
  650. return self.round(D('0.00'))
  651. line_tuples = self.get_applicable_lines(basket, range=condition.range)
  652. if not line_tuples:
  653. return self.round(D('0.00'))
  654. # Determine the lines to consume
  655. num_permitted = int(condition.value)
  656. num_affected = 0
  657. value_affected = D('0.00')
  658. covered_lines = []
  659. for price, line in line_tuples:
  660. if isinstance(condition, CoverageCondition):
  661. quantity_affected = 1
  662. else:
  663. quantity_affected = min(
  664. line.quantity_without_discount,
  665. num_permitted - num_affected)
  666. num_affected += quantity_affected
  667. value_affected += quantity_affected * price
  668. covered_lines.append((price, line, quantity_affected))
  669. if num_affected >= num_permitted:
  670. break
  671. discount = max(value_affected - self.value, D('0.00'))
  672. if not discount:
  673. return self.round(discount)
  674. # Apply discount to the affected lines
  675. discount_applied = D('0.00')
  676. last_line = covered_lines[-1][0]
  677. for price, line, quantity in covered_lines:
  678. if line == last_line:
  679. # If last line, we just take the difference to ensure that
  680. # rounding doesn't lead to an off-by-one error
  681. line_discount = discount - discount_applied
  682. else:
  683. line_discount = self.round(
  684. discount * (price * quantity) / value_affected)
  685. line.discount(line_discount, quantity)
  686. discount_applied += line_discount
  687. return discount
  688. class MultibuyDiscountBenefit(Benefit):
  689. class Meta:
  690. proxy = True
  691. verbose_name = _("Multibuy discount benefit")
  692. verbose_name_plural = _("Multibuy discount benefits")
  693. def apply(self, basket, condition):
  694. line_tuples = self.get_applicable_lines(basket)
  695. if not line_tuples:
  696. return self.round(D('0.00'))
  697. # Cheapest line gives free product
  698. discount, line = line_tuples[0]
  699. line.discount(discount, 1)
  700. affected_lines = [(line, discount, 1)]
  701. condition.consume_items(basket, affected_lines)
  702. return discount