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

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