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

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