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

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