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.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. from collections import defaultdict, namedtuple
  2. from django.contrib import messages
  3. from django.template.loader import render_to_string
  4. from oscar.core.loading import get_class, get_model
  5. from oscar.core.decorators import deprecated
  6. Applicator = get_class("offer.applicator", "Applicator")
  7. ConditionalOffer = get_model("offer", "ConditionalOffer")
  8. class BasketMessageGenerator(object):
  9. new_total_template_name = "oscar/basket/messages/new_total.html"
  10. offer_lost_template_name = "oscar/basket/messages/offer_lost.html"
  11. offer_gained_template_name = "oscar/basket/messages/offer_gained.html"
  12. def get_new_total_messages(self, basket, include_buttons=True):
  13. new_total_messages = []
  14. # We use the 'include_buttons' parameter to determine whether to show the
  15. # 'Checkout now' buttons. We don't want to show these on the basket page.
  16. msg = render_to_string(
  17. self.new_total_template_name,
  18. {"basket": basket, "include_buttons": include_buttons},
  19. )
  20. new_total_messages.append((messages.INFO, msg))
  21. return new_total_messages
  22. def get_offer_lost_messages(self, offers_before, offers_after):
  23. offer_messages = []
  24. for offer_id in set(offers_before).difference(offers_after):
  25. offer = offers_before[offer_id]
  26. msg = render_to_string(self.offer_lost_template_name, {"offer": offer})
  27. offer_messages.append((messages.WARNING, msg))
  28. return offer_messages
  29. def get_offer_gained_messages(self, offers_before, offers_after):
  30. offer_messages = []
  31. for offer_id in set(offers_after).difference(offers_before):
  32. offer = offers_after[offer_id]
  33. msg = render_to_string(self.offer_gained_template_name, {"offer": offer})
  34. offer_messages.append((messages.SUCCESS, msg))
  35. return offer_messages
  36. def get_offer_messages(self, offers_before, offers_after):
  37. offer_messages = []
  38. offer_messages.extend(self.get_offer_lost_messages(offers_before, offers_after))
  39. offer_messages.extend(
  40. self.get_offer_gained_messages(offers_before, offers_after)
  41. )
  42. return offer_messages
  43. def get_messages(self, basket, offers_before, offers_after, include_buttons=True):
  44. message_list = []
  45. message_list.extend(self.get_offer_messages(offers_before, offers_after))
  46. message_list.extend(self.get_new_total_messages(basket, include_buttons))
  47. return message_list
  48. def apply_messages(self, request, offers_before):
  49. """
  50. Set flash messages triggered by changes to the basket
  51. """
  52. # Re-apply offers to see if any new ones are now available
  53. request.basket.reset_offer_applications()
  54. Applicator().apply(request.basket, request.user, request)
  55. offers_after = request.basket.applied_offers()
  56. for level, msg in self.get_messages(
  57. request.basket, offers_before, offers_after
  58. ):
  59. messages.add_message(request, level, msg, extra_tags="safe noicon")
  60. class LineOfferConsumer(object):
  61. """
  62. facade for marking basket lines as consumed by
  63. any or a specific offering.
  64. historically oscar marks a line as consumed if any
  65. offer is applied to it, but more complicated scenarios
  66. are possible if we mark the line as being consumed by
  67. specific offers.
  68. this allows combining i.e. multiple vouchers, vouchers
  69. with special session discounts, etc.
  70. """
  71. def __init__(self, line):
  72. self._line = line
  73. self._offers = dict()
  74. self._affected_quantity = 0
  75. self._consumptions = defaultdict(int)
  76. def _cache(self, offer):
  77. self._offers[offer.pk] = offer
  78. def _update_affected_quantity(self, quantity):
  79. available = int(self._line.quantity - self._affected_quantity)
  80. num_consumed = min(available, quantity)
  81. self._affected_quantity += num_consumed
  82. return num_consumed
  83. # public
  84. def consume(self, quantity: int, offer=None):
  85. """
  86. mark a basket line as consumed by an offer
  87. :param int quantity: the number of items on the line affected
  88. :param offer: the offer to mark the line
  89. :type offer: ConditionalOffer or None
  90. :return: the number of items actually consumed
  91. :rtype: int
  92. if offer is None, the specified quantity of items on this
  93. basket line is consumed for *any* offer, else only for the
  94. specified offer.
  95. """
  96. # raise Exception("viggo is henk")
  97. if offer:
  98. self._cache(offer)
  99. available = self.available(offer)
  100. num_consumed = self._update_affected_quantity(quantity)
  101. if offer:
  102. num_consumed = min(available, quantity)
  103. self._consumptions[offer.pk] += num_consumed
  104. return num_consumed
  105. @deprecated
  106. def consumed(self, offer=None):
  107. return self.num_consumed(offer)
  108. def num_consumed(self, offer=None):
  109. """
  110. check how many items on this line have been
  111. consumed by an offer
  112. :param offer: the offer to check
  113. :type offer: ConditionalOffer or None
  114. :return: the number of items marked as consumed
  115. :rtype: int
  116. if offer is not None, only the number of items marked
  117. with the specified ConditionalOffer are returned
  118. """
  119. if not offer:
  120. return self._affected_quantity
  121. return int(self._consumptions[offer.pk])
  122. @property
  123. def consumers(self):
  124. return [x for x in self._offers.values() if self.num_consumed(x)]
  125. def available(self, offer=None) -> int:
  126. """
  127. check how many items are available for offer
  128. :param offer: the offer to check
  129. :type offer: ConditionalOffer or None
  130. :return: the number of items available for offer
  131. :rtype: int
  132. """
  133. max_affected_items = self._line.quantity
  134. if offer and isinstance(offer, ConditionalOffer):
  135. applied = [x for x in self.consumers if x != offer]
  136. if offer.exclusive:
  137. for a in applied:
  138. if a.exclusive:
  139. if any(
  140. [
  141. a.priority > offer.priority,
  142. a.priority == offer.priority and a.id != offer.id,
  143. ]
  144. ):
  145. # Exclusive offers cannot be applied if any other exclusive
  146. # offer with higher priority is active already.
  147. max_affected_items = max_affected_items - self.num_consumed(
  148. a
  149. )
  150. if max_affected_items == 0:
  151. return 0
  152. else:
  153. # Exclusive offers cannot be applied if any other offers are
  154. # active already.
  155. return 0
  156. # find any *other* exclusive offers
  157. elif any([x.exclusive for x in applied]):
  158. return 0
  159. # check for applied offers allowing restricted combinations
  160. for x in applied:
  161. check = offer.combinations.count() or x.combinations.count()
  162. if check and offer not in x.combined_offers:
  163. return 0
  164. return max_affected_items - self.num_consumed(offer)
  165. DiscountApplication = namedtuple(
  166. "DiscountApplication", ["amount", "quantity", "incl_tax", "offer"]
  167. )
  168. class LineDiscountRegistry(LineOfferConsumer):
  169. def __init__(self, line):
  170. super().__init__(line)
  171. self._discounts = []
  172. self._discount_excl_tax = None
  173. self._discount_incl_tax = None
  174. def discount(self, amount, quantity, incl_tax=True, offer=None):
  175. self._discounts.append(DiscountApplication(amount, quantity, incl_tax, offer))
  176. self.consume(quantity, offer=offer)
  177. if incl_tax:
  178. self._discount_incl_tax = None
  179. else:
  180. self._discount_excl_tax = None
  181. @property
  182. def excl_tax(self):
  183. if self._discount_excl_tax is None:
  184. self._discount_excl_tax = sum(
  185. [d.amount for d in self._discounts if not d.incl_tax], 0
  186. )
  187. return self._discount_excl_tax
  188. @property
  189. def incl_tax(self):
  190. if self._discount_incl_tax is None:
  191. self._discount_incl_tax = sum(
  192. [d.amount for d in self._discounts if d.incl_tax], 0
  193. )
  194. return self._discount_incl_tax
  195. @property
  196. def total(self):
  197. return sum([d.amount for d in self._discounts], 0)
  198. def all(self):
  199. return self._discounts
  200. def __iter__(self):
  201. return iter(self._discounts)