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 24KB

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