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

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505
  1. from decimal import Decimal as D, ROUND_DOWN, ROUND_UP
  2. from django.core import exceptions
  3. from django.db.models import get_model
  4. from django.template.defaultfilters import date as date_filter
  5. from django.db import models
  6. from django.utils.timezone import now, get_current_timezone
  7. from django.utils.translation import ungettext, ugettext as _
  8. from django.utils.importlib import import_module
  9. from django.core.exceptions import ValidationError
  10. from django.core.urlresolvers import reverse
  11. from django.conf import settings
  12. from oscar.core.utils import slugify
  13. from oscar.apps.offer.managers import ActiveOfferManager
  14. from oscar.templatetags.currency_filters import currency
  15. from oscar.models.fields import PositiveDecimalField, ExtendedURLField
  16. from oscar.core.loading import get_class
  17. BrowsableRangeManager = get_class('offer.managers', 'BrowsableRangeManager')
  18. def load_proxy(proxy_class):
  19. module, classname = proxy_class.rsplit('.', 1)
  20. try:
  21. mod = import_module(module)
  22. except ImportError, e:
  23. raise exceptions.ImproperlyConfigured(
  24. "Error importing module %s: %s" % (module, e))
  25. try:
  26. return getattr(mod, classname)
  27. except AttributeError:
  28. raise exceptions.ImproperlyConfigured(
  29. "Module %s does not define a %s" % (module, classname))
  30. def range_anchor(range):
  31. return '<a href="%s">%s</a>' % (
  32. reverse('dashboard:range-update', kwargs={'pk': range.pk}),
  33. range.name)
  34. class ConditionalOffer(models.Model):
  35. """
  36. A conditional offer (eg buy 1, get 10% off)
  37. """
  38. name = models.CharField(
  39. _("Name"), max_length=128, unique=True,
  40. help_text=_("This is displayed within the customer's basket"))
  41. slug = models.SlugField(_("Slug"), max_length=128, unique=True, null=True)
  42. description = models.TextField(_("Description"), blank=True,
  43. help_text=_("This is displayed on the offer browsing page"))
  44. # Offers come in a few different types:
  45. # (a) Offers that are available to all customers on the site. Eg a
  46. # 3-for-2 offer.
  47. # (b) Offers that are linked to a voucher, and only become available once
  48. # that voucher has been applied to the basket
  49. # (c) Offers that are linked to a user. Eg, all students get 10% off. The
  50. # code to apply this offer needs to be coded
  51. # (d) Session offers - these are temporarily available to a user after some
  52. # trigger event. Eg, users coming from some affiliate site get 10%
  53. # off.
  54. SITE, VOUCHER, USER, SESSION = ("Site", "Voucher", "User", "Session")
  55. TYPE_CHOICES = (
  56. (SITE, _("Site offer - available to all users")),
  57. (VOUCHER, _("Voucher offer - only available after entering "
  58. "the appropriate voucher code")),
  59. (USER, _("User offer - available to certain types of user")),
  60. (SESSION, _("Session offer - temporary offer, available for "
  61. "a user for the duration of their session")),
  62. )
  63. offer_type = models.CharField(
  64. _("Type"), choices=TYPE_CHOICES, default=SITE, max_length=128)
  65. # We track a status variable so it's easier to load offers that are
  66. # 'available' in some sense.
  67. OPEN, SUSPENDED, CONSUMED = "Open", "Suspended", "Consumed"
  68. status = models.CharField(_("Status"), max_length=64, default=OPEN)
  69. condition = models.ForeignKey(
  70. 'offer.Condition', verbose_name=_("Condition"))
  71. benefit = models.ForeignKey('offer.Benefit', verbose_name=_("Benefit"))
  72. # Some complicated situations require offers to be applied in a set order.
  73. priority = models.IntegerField(_("Priority"), default=0,
  74. help_text=_("The highest priority offers are applied first"))
  75. # AVAILABILITY
  76. # Range of availability. Note that if this is a voucher offer, then these
  77. # dates are ignored and only the dates from the voucher are used to
  78. # determine availability.
  79. start_datetime = models.DateTimeField(_("Start date"), blank=True, null=True)
  80. end_datetime = models.DateTimeField(
  81. _("End date"), blank=True, null=True,
  82. help_text=_("Offers are active until the end of the 'end date'"))
  83. # Use this field to limit the number of times this offer can be applied in
  84. # total. Note that a single order can apply an offer multiple times so
  85. # this is not the same as the number of orders that can use it.
  86. max_global_applications = models.PositiveIntegerField(
  87. _("Max global applications"),
  88. help_text=_("The number of times this offer can be used before it "
  89. "is unavailable"), blank=True, null=True)
  90. # Use this field to limit the number of times this offer can be used by a
  91. # single user. This only works for signed-in users - it doesn't really
  92. # make sense for sites that allow anonymous checkout.
  93. max_user_applications = models.PositiveIntegerField(
  94. _("Max user applications"),
  95. help_text=_("The number of times a single user can use this offer"),
  96. blank=True, null=True)
  97. # Use this field to limit the number of times this offer can be applied to
  98. # a basket (and hence a single order).
  99. max_basket_applications = models.PositiveIntegerField(
  100. _("Max basket applications"),
  101. blank=True, null=True,
  102. help_text=_("The number of times this offer can be applied to a "
  103. "basket (and order)"))
  104. # Use this field to limit the amount of discount an offer can lead to.
  105. # This can be helpful with budgeting.
  106. max_discount = models.DecimalField(
  107. _("Max discount"), decimal_places=2, max_digits=12, null=True,
  108. blank=True,
  109. help_text=_("When an offer has given more discount to orders "
  110. "than this threshold, then the offer becomes "
  111. "unavailable"))
  112. # TRACKING
  113. total_discount = models.DecimalField(
  114. _("Total Discount"), decimal_places=2, max_digits=12,
  115. default=D('0.00'))
  116. num_applications = models.PositiveIntegerField(
  117. _("Number of applications"), default=0)
  118. num_orders = models.PositiveIntegerField(
  119. _("Number of Orders"), default=0)
  120. redirect_url = ExtendedURLField(_("URL redirect (optional)"), blank=True)
  121. date_created = models.DateTimeField(_("Date Created"), auto_now_add=True)
  122. objects = models.Manager()
  123. active = ActiveOfferManager()
  124. # We need to track the voucher that this offer came from (if it is a
  125. # voucher offer)
  126. _voucher = None
  127. class Meta:
  128. ordering = ['-priority']
  129. verbose_name = _("Conditional offer")
  130. verbose_name_plural = _("Conditional offers")
  131. # The way offers are looked up involves the fields (offer_type, status,
  132. # start_datetime, end_datetime). Ideally, you want a DB index that
  133. # covers these 4 fields (will add support for this in Django 1.5)
  134. def save(self, *args, **kwargs):
  135. if not self.slug:
  136. self.slug = slugify(self.name)
  137. # Check to see if consumption thresholds have been broken
  138. if not self.is_suspended:
  139. if self.get_max_applications() == 0:
  140. self.status = self.CONSUMED
  141. else:
  142. self.status = self.OPEN
  143. return super(ConditionalOffer, self).save(*args, **kwargs)
  144. def get_absolute_url(self):
  145. return reverse('offer:detail', kwargs={'slug': self.slug})
  146. def __unicode__(self):
  147. return self.name
  148. def clean(self):
  149. if (self.start_datetime and self.end_datetime and
  150. self.start_datetime > self.end_datetime):
  151. raise exceptions.ValidationError(
  152. _('End date should be later than start date'))
  153. @property
  154. def is_open(self):
  155. return self.status == self.OPEN
  156. @property
  157. def is_suspended(self):
  158. return self.status == self.SUSPENDED
  159. def suspend(self):
  160. self.status = self.SUSPENDED
  161. self.save()
  162. suspend.alters_data = True
  163. def unsuspend(self):
  164. self.status = self.OPEN
  165. self.save()
  166. suspend.alters_data = True
  167. def is_available(self, user=None, test_date=None):
  168. """
  169. Test whether this offer is available to be used
  170. """
  171. if self.is_suspended:
  172. return False
  173. if test_date is None:
  174. test_date = now()
  175. predicates = []
  176. if self.start_datetime:
  177. predicates.append(self.start_datetime > test_date)
  178. if self.end_datetime:
  179. predicates.append(test_date > self.end_datetime)
  180. if any(predicates):
  181. return 0
  182. return self.get_max_applications(user) > 0
  183. def is_condition_satisfied(self, basket):
  184. return self.condition.proxy().is_satisfied(basket)
  185. def is_condition_partially_satisfied(self, basket):
  186. return self.condition.proxy().is_partially_satisfied(basket)
  187. def get_upsell_message(self, basket):
  188. return self.condition.proxy().get_upsell_message(basket)
  189. def apply_benefit(self, basket):
  190. """
  191. Applies the benefit to the given basket and returns the discount.
  192. """
  193. if not self.is_condition_satisfied(basket):
  194. return ZERO_DISCOUNT
  195. return self.benefit.proxy().apply(
  196. basket, self.condition.proxy(), self)
  197. def apply_deferred_benefit(self, basket):
  198. """
  199. Applies any deferred benefits. These are things like adding loyalty
  200. points to somone's account.
  201. """
  202. return self.benefit.proxy().apply_deferred(basket)
  203. def set_voucher(self, voucher):
  204. self._voucher = voucher
  205. def get_voucher(self):
  206. return self._voucher
  207. def get_max_applications(self, user=None):
  208. """
  209. Return the number of times this offer can be applied to a basket for a
  210. given user.
  211. """
  212. if self.max_discount and self.total_discount >= self.max_discount:
  213. return 0
  214. # Hard-code a maximum value as we need some sensible upper limit for
  215. # when there are not other caps.
  216. limits = [10000]
  217. if self.max_user_applications and user:
  218. limits.append(max(0, self.max_user_applications -
  219. self.get_num_user_applications(user)))
  220. if self.max_basket_applications:
  221. limits.append(self.max_basket_applications)
  222. if self.max_global_applications:
  223. limits.append(
  224. max(0, self.max_global_applications - self.num_applications))
  225. return min(limits)
  226. def get_num_user_applications(self, user):
  227. OrderDiscount = models.get_model('order', 'OrderDiscount')
  228. aggregates = OrderDiscount.objects.filter(
  229. offer_id=self.id, order__user=user).aggregate(
  230. total=models.Sum('frequency'))
  231. return aggregates['total'] if aggregates['total'] is not None else 0
  232. def shipping_discount(self, charge):
  233. return self.benefit.proxy().shipping_discount(charge)
  234. def record_usage(self, discount):
  235. self.num_applications += discount['freq']
  236. self.total_discount += discount['discount']
  237. self.num_orders += 1
  238. self.save()
  239. record_usage.alters_data = True
  240. def availability_description(self):
  241. """
  242. Return a description of when this offer is available
  243. """
  244. restrictions = self.availability_restrictions()
  245. descriptions = [r['description'] for r in restrictions]
  246. return "<br/>".join(descriptions)
  247. def availability_restrictions(self):
  248. restrictions = []
  249. if self.is_suspended:
  250. restrictions.append({
  251. 'description': _("Offer is suspended"),
  252. 'is_satisfied': False})
  253. if self.max_global_applications:
  254. remaining = self.max_global_applications - self.num_applications
  255. desc = _(
  256. "Limited to %(total)d uses "
  257. "(%(remainder)d remaining)") % {
  258. 'total': self.max_global_applications,
  259. 'remainder': remaining}
  260. restrictions.append({
  261. 'description': desc,
  262. 'is_satisfied': remaining > 0})
  263. if self.max_user_applications:
  264. if self.max_user_applications == 1:
  265. desc = _("Limited to 1 use per user")
  266. else:
  267. desc = _(
  268. "Limited to %(total)d uses per user") % {
  269. 'total': self.max_user_applications}
  270. restrictions.append({
  271. 'description': desc,
  272. 'is_satisfied': True})
  273. if self.max_basket_applications:
  274. if self.max_user_applications == 1:
  275. desc = _("Limited to 1 use per basket")
  276. else:
  277. desc = _(
  278. "Limited to %(total)d uses per basket") % {
  279. 'total': self.max_basket_applications}
  280. restrictions.append({
  281. 'description': desc,
  282. 'is_satisfied': True})
  283. def hide_time_if_zero(dt):
  284. # Only show hours/minutes if they have been specified
  285. localtime = dt.astimezone(get_current_timezone())
  286. if localtime.hour == 0 and localtime.minute == 0:
  287. return date_filter(localtime, settings.DATE_FORMAT)
  288. return date_filter(localtime, settings.DATETIME_FORMAT)
  289. if self.start_datetime or self.end_datetime:
  290. today = now()
  291. if self.start_datetime and self.end_datetime:
  292. desc = _("Available between %(start)s and %(end)s") % {
  293. 'start': hide_time_if_zero(self.start_datetime),
  294. 'end': hide_time_if_zero(self.end_datetime)}
  295. is_satisfied = self.start_datetime <= today <= self.end_datetime
  296. elif self.start_datetime:
  297. desc = _("Available from %(start)s") % {
  298. 'start': hide_time_if_zero(self.start_datetime)}
  299. is_satisfied = today >= self.start_datetime
  300. elif self.end_datetime:
  301. desc = _("Available until %(end)s") % {
  302. 'end': hide_time_if_zero(self.end_datetime)}
  303. is_satisfied = today <= self.end_datetime
  304. restrictions.append({
  305. 'description': desc,
  306. 'is_satisfied': is_satisfied})
  307. if self.max_discount:
  308. desc = _("Limited to a cost of %(max)s") % {
  309. 'max': currency(self.max_discount)}
  310. restrictions.append({
  311. 'description': desc,
  312. 'is_satisfied': self.total_discount < self.max_discount})
  313. return restrictions
  314. @property
  315. def has_products(self):
  316. return self.condition.range is not None
  317. def products(self):
  318. """
  319. Return a queryset of products in this offer
  320. """
  321. Product = get_model('catalogue', 'Product')
  322. if not self.has_products:
  323. return Product.objects.none()
  324. cond_range = self.condition.range
  325. if cond_range.includes_all_products:
  326. # Return ALL the products
  327. return Product.browsable.select_related(
  328. 'product_class', 'stockrecord').filter(
  329. is_discountable=True).prefetch_related(
  330. 'variants', 'images', 'product_class__options',
  331. 'product_options')
  332. return cond_range.included_products.filter(is_discountable=True)
  333. class Condition(models.Model):
  334. COUNT, VALUE, COVERAGE = ("Count", "Value", "Coverage")
  335. TYPE_CHOICES = (
  336. (COUNT, _("Depends on number of items in basket that are in "
  337. "condition range")),
  338. (VALUE, _("Depends on value of items in basket that are in "
  339. "condition range")),
  340. (COVERAGE, _("Needs to contain a set number of DISTINCT items "
  341. "from the condition range")))
  342. range = models.ForeignKey(
  343. 'offer.Range', verbose_name=_("Range"), null=True, blank=True)
  344. type = models.CharField(_('Type'), max_length=128, choices=TYPE_CHOICES,
  345. null=True, blank=True)
  346. value = PositiveDecimalField(_('Value'), decimal_places=2, max_digits=12,
  347. null=True, blank=True)
  348. proxy_class = models.CharField(_("Custom class"), null=True, blank=True,
  349. max_length=255, unique=True, default=None)
  350. class Meta:
  351. verbose_name = _("Condition")
  352. verbose_name_plural = _("Conditions")
  353. def proxy(self):
  354. """
  355. Return the proxy model
  356. """
  357. field_dict = dict(self.__dict__)
  358. for field in field_dict.keys():
  359. if field.startswith('_'):
  360. del field_dict[field]
  361. if self.proxy_class:
  362. klass = load_proxy(self.proxy_class)
  363. return klass(**field_dict)
  364. klassmap = {
  365. self.COUNT: CountCondition,
  366. self.VALUE: ValueCondition,
  367. self.COVERAGE: CoverageCondition}
  368. if self.type in klassmap:
  369. return klassmap[self.type](**field_dict)
  370. return self
  371. def __unicode__(self):
  372. return self.proxy().name
  373. @property
  374. def name(self):
  375. return self.description
  376. @property
  377. def description(self):
  378. return self.proxy().description
  379. def consume_items(self, basket, affected_lines):
  380. pass
  381. def is_satisfied(self, basket):
  382. """
  383. Determines whether a given basket meets this condition. This is
  384. stubbed in this top-class object. The subclassing proxies are
  385. responsible for implementing it correctly.
  386. """
  387. return False
  388. def is_partially_satisfied(self, basket):
  389. """
  390. Determine if the basket partially meets the condition. This is useful
  391. for up-selling messages to entice customers to buy something more in
  392. order to qualify for an offer.
  393. """
  394. return False
  395. def get_upsell_message(self, basket):
  396. return None
  397. def can_apply_condition(self, line):
  398. """
  399. Determines whether the condition can be applied to a given basket line
  400. """
  401. if not line.stockrecord:
  402. return False
  403. product = line.product
  404. return self.range.contains_product(product) and product.is_discountable
  405. def get_applicable_lines(self, basket, most_expensive_first=True):
  406. """
  407. Return line data for the lines that can be consumed by this condition
  408. """
  409. line_tuples = []
  410. for line in basket.all_lines():
  411. if not self.can_apply_condition(line):
  412. continue
  413. # We only include products where we know the tax charged
  414. if not line.stockinfo.price.is_tax_known:
  415. continue
  416. price = line.unit_price_incl_tax
  417. if not price:
  418. continue
  419. line_tuples.append((price, line))
  420. if most_expensive_first:
  421. return sorted(line_tuples, reverse=True)
  422. return sorted(line_tuples)
  423. class Benefit(models.Model):
  424. range = models.ForeignKey(
  425. 'offer.Range', null=True, blank=True, verbose_name=_("Range"))
  426. # Benefit types
  427. PERCENTAGE, FIXED, MULTIBUY, FIXED_PRICE = (
  428. "Percentage", "Absolute", "Multibuy", "Fixed price")
  429. SHIPPING_PERCENTAGE, SHIPPING_ABSOLUTE, SHIPPING_FIXED_PRICE = (
  430. 'Shipping percentage', 'Shipping absolute', 'Shipping fixed price')
  431. TYPE_CHOICES = (
  432. (PERCENTAGE, _("Discount is a percentage off of the product's value")),
  433. (FIXED, _("Discount is a fixed amount off of the product's value")),
  434. (MULTIBUY, _("Discount is to give the cheapest product for free")),
  435. (FIXED_PRICE,
  436. _("Get the products that meet the condition for a fixed price")),
  437. (SHIPPING_ABSOLUTE,
  438. _("Discount is a fixed amount of the shipping cost")),
  439. (SHIPPING_FIXED_PRICE, _("Get shipping for a fixed price")),
  440. (SHIPPING_PERCENTAGE, _("Discount is a percentage off of the shipping cost")),
  441. )
  442. type = models.CharField(
  443. _("Type"), max_length=128, choices=TYPE_CHOICES, blank=True)
  444. # The value to use with the designated type. This can be either an integer
  445. # (eg for multibuy) or a decimal (eg an amount) which is slightly
  446. # confusing.
  447. value = PositiveDecimalField(
  448. _("Value"), decimal_places=2, max_digits=12, null=True, blank=True)
  449. # If this is not set, then there is no upper limit on how many products
  450. # can be discounted by this benefit.
  451. max_affected_items = models.PositiveIntegerField(
  452. _("Max Affected Items"), blank=True, null=True,
  453. help_text=_("Set this to prevent the discount consuming all items "
  454. "within the range that are in the basket."))
  455. # A custom benefit class can be used instead. This means the
  456. # type/value/max_affected_items fields should all be None.
  457. proxy_class = models.CharField(_("Custom class"), null=True, blank=True,
  458. max_length=255, unique=True, default=None)
  459. class Meta:
  460. verbose_name = _("Benefit")
  461. verbose_name_plural = _("Benefits")
  462. def proxy(self):
  463. field_dict = dict(self.__dict__)
  464. for field in field_dict.keys():
  465. if field.startswith('_'):
  466. del field_dict[field]
  467. if self.proxy_class:
  468. klass = load_proxy(self.proxy_class)
  469. return klass(**field_dict)
  470. klassmap = {
  471. self.PERCENTAGE: PercentageDiscountBenefit,
  472. self.FIXED: AbsoluteDiscountBenefit,
  473. self.MULTIBUY: MultibuyDiscountBenefit,
  474. self.FIXED_PRICE: FixedPriceBenefit,
  475. self.SHIPPING_ABSOLUTE: ShippingAbsoluteDiscountBenefit,
  476. self.SHIPPING_FIXED_PRICE: ShippingFixedPriceBenefit,
  477. self.SHIPPING_PERCENTAGE: ShippingPercentageDiscountBenefit}
  478. if self.type in klassmap:
  479. return klassmap[self.type](**field_dict)
  480. raise RuntimeError("Unrecognised benefit type (%s)" % self.type)
  481. def __unicode__(self):
  482. name = self.proxy().name
  483. if self.max_affected_items:
  484. name += ungettext(
  485. " (max %d item)",
  486. " (max %d items)",
  487. self.max_affected_items) % self.max_affected_items
  488. return name
  489. @property
  490. def name(self):
  491. return self.description
  492. @property
  493. def description(self):
  494. return self.proxy().description
  495. def apply(self, basket, condition, offer=None):
  496. return ZERO_DISCOUNT
  497. def apply_deferred(self, basket):
  498. return None
  499. def clean(self):
  500. if not self.type:
  501. return
  502. method_name = 'clean_%s' % self.type.lower().replace(' ', '_')
  503. if hasattr(self, method_name):
  504. getattr(self, method_name)()
  505. def clean_multibuy(self):
  506. if not self.range:
  507. raise ValidationError(
  508. _("Multibuy benefits require a product range"))
  509. if self.value:
  510. raise ValidationError(
  511. _("Multibuy benefits don't require a value"))
  512. if self.max_affected_items:
  513. raise ValidationError(
  514. _("Multibuy benefits don't require a 'max affected items' "
  515. "attribute"))
  516. def clean_percentage(self):
  517. if not self.range:
  518. raise ValidationError(
  519. _("Percentage benefits require a product range"))
  520. if self.value > 100:
  521. raise ValidationError(
  522. _("Percentage discount cannot be greater than 100"))
  523. def clean_shipping_absolute(self):
  524. if not self.value:
  525. raise ValidationError(
  526. _("A discount value is required"))
  527. if self.range:
  528. raise ValidationError(
  529. _("No range should be selected as this benefit does not "
  530. "apply to products"))
  531. if self.max_affected_items:
  532. raise ValidationError(
  533. _("Shipping discounts don't require a 'max affected items' "
  534. "attribute"))
  535. def clean_shipping_percentage(self):
  536. if self.value > 100:
  537. raise ValidationError(
  538. _("Percentage discount cannot be greater than 100"))
  539. if self.range:
  540. raise ValidationError(
  541. _("No range should be selected as this benefit does not "
  542. "apply to products"))
  543. if self.max_affected_items:
  544. raise ValidationError(
  545. _("Shipping discounts don't require a 'max affected items' "
  546. "attribute"))
  547. def clean_shipping_fixed_price(self):
  548. if self.range:
  549. raise ValidationError(
  550. _("No range should be selected as this benefit does not "
  551. "apply to products"))
  552. if self.max_affected_items:
  553. raise ValidationError(
  554. _("Shipping discounts don't require a 'max affected items' "
  555. "attribute"))
  556. def clean_fixed_price(self):
  557. if self.range:
  558. raise ValidationError(
  559. _("No range should be selected as the condition range will "
  560. "be used instead."))
  561. def clean_absolute(self):
  562. if not self.range:
  563. raise ValidationError(
  564. _("Fixed discount benefits require a product range"))
  565. if not self.value:
  566. raise ValidationError(
  567. _("Fixed discount benefits require a value"))
  568. def round(self, amount):
  569. """
  570. Apply rounding to discount amount
  571. """
  572. if hasattr(settings, 'OSCAR_OFFER_ROUNDING_FUNCTION'):
  573. return settings.OSCAR_OFFER_ROUNDING_FUNCTION(amount)
  574. return amount.quantize(D('.01'), ROUND_DOWN)
  575. def _effective_max_affected_items(self):
  576. """
  577. Return the maximum number of items that can have a discount applied
  578. during the application of this benefit
  579. """
  580. return self.max_affected_items if self.max_affected_items else 10000
  581. def can_apply_benefit(self, line):
  582. """
  583. Determines whether the benefit can be applied to a given basket line
  584. """
  585. return line.stockrecord and line.product.is_discountable
  586. def get_applicable_lines(self, basket, range=None):
  587. """
  588. Return the basket lines that are available to be discounted
  589. :basket: The basket
  590. :range: The range of products to use for filtering. The fixed-price
  591. benefit ignores its range and uses the condition range
  592. """
  593. if range is None:
  594. range = self.range
  595. line_tuples = []
  596. for line in basket.all_lines():
  597. product = line.product
  598. # We only include products where we know the tax charged
  599. if not line.stockinfo.price.is_tax_known:
  600. continue
  601. if (not range.contains(product) or
  602. not self.can_apply_benefit(line)):
  603. continue
  604. price = line.unit_price_incl_tax
  605. if not price:
  606. # Avoid zero price products
  607. continue
  608. if line.quantity_without_discount == 0:
  609. continue
  610. line_tuples.append((price, line))
  611. # We sort lines to be cheapest first to ensure consistent applications
  612. return sorted(line_tuples)
  613. def shipping_discount(self, charge):
  614. return D('0.00')
  615. class Range(models.Model):
  616. """
  617. Represents a range of products that can be used within an offer
  618. """
  619. name = models.CharField(_("Name"), max_length=128, unique=True)
  620. slug = models.SlugField(_('Slug'), max_length=128, unique=True, null=True)
  621. description = models.TextField(blank=True)
  622. # Whether this range is public
  623. is_public = models.BooleanField(
  624. _('Is public?'), default=False,
  625. help_text=_("Public ranges have a customer-facing page"))
  626. includes_all_products = models.BooleanField(
  627. _('Includes all products?'), default=False)
  628. included_products = models.ManyToManyField(
  629. 'catalogue.Product', related_name='includes', blank=True,
  630. verbose_name=_("Included Products"))
  631. excluded_products = models.ManyToManyField(
  632. 'catalogue.Product', related_name='excludes', blank=True,
  633. verbose_name=_("Excluded Products"))
  634. classes = models.ManyToManyField(
  635. 'catalogue.ProductClass', related_name='classes', blank=True,
  636. verbose_name=_("Product Classes"))
  637. included_categories = models.ManyToManyField(
  638. 'catalogue.Category', related_name='includes', blank=True,
  639. verbose_name=_("Included Categories"))
  640. # Allow a custom range instance to be specified
  641. proxy_class = models.CharField(
  642. _("Custom class"), null=True, blank=True, max_length=255,
  643. default=None, unique=True)
  644. date_created = models.DateTimeField(_("Date Created"), auto_now_add=True)
  645. __included_product_ids = None
  646. __excluded_product_ids = None
  647. __class_ids = None
  648. objects = models.Manager()
  649. browsable = BrowsableRangeManager()
  650. class Meta:
  651. verbose_name = _("Range")
  652. verbose_name_plural = _("Ranges")
  653. def __unicode__(self):
  654. return self.name
  655. def get_absolute_url(self):
  656. return reverse('catalogue:range', kwargs={
  657. 'slug': self.slug})
  658. def save(self, *args, **kwargs):
  659. if not self.slug:
  660. self.slug = slugify(self.name)
  661. # Save Range
  662. super(Range, self).save(*args, **kwargs)
  663. def contains_product(self, product):
  664. """
  665. Check whether the passed product is part of this range
  666. """
  667. # We look for shortcircuit checks first before
  668. # the tests that require more database queries.
  669. if settings.OSCAR_OFFER_BLACKLIST_PRODUCT and \
  670. settings.OSCAR_OFFER_BLACKLIST_PRODUCT(product):
  671. return False
  672. # Delegate to a proxy class if one is provided
  673. if self.proxy_class:
  674. return load_proxy(self.proxy_class)().contains_product(product)
  675. excluded_product_ids = self._excluded_product_ids()
  676. if product.id in excluded_product_ids:
  677. return False
  678. if self.includes_all_products:
  679. return True
  680. if product.product_class_id in self._class_ids():
  681. return True
  682. included_product_ids = self._included_product_ids()
  683. if product.id in included_product_ids:
  684. return True
  685. test_categories = self.included_categories.all()
  686. if test_categories:
  687. for category in product.categories.all():
  688. for test_category in test_categories:
  689. if category == test_category or category.is_descendant_of(test_category):
  690. return True
  691. return False
  692. # Shorter alias
  693. contains = contains_product
  694. def _included_product_ids(self):
  695. if self.__included_product_ids is None:
  696. self.__included_product_ids = [row['id'] for row in self.included_products.values('id')]
  697. return self.__included_product_ids
  698. def _excluded_product_ids(self):
  699. if not self.id:
  700. return []
  701. if self.__excluded_product_ids is None:
  702. self.__excluded_product_ids = [row['id'] for row in self.excluded_products.values('id')]
  703. return self.__excluded_product_ids
  704. def _class_ids(self):
  705. if None == self.__class_ids:
  706. self.__class_ids = [row['id'] for row in self.classes.values('id')]
  707. return self.__class_ids
  708. def num_products(self):
  709. if self.includes_all_products:
  710. return None
  711. return self.included_products.all().count()
  712. @property
  713. def is_editable(self):
  714. """
  715. Test whether this product can be edited in the dashboard
  716. """
  717. return self.proxy_class is None
  718. # ==========
  719. # Conditions
  720. # ==========
  721. class CountCondition(Condition):
  722. """
  723. An offer condition dependent on the NUMBER of matching items from the
  724. basket.
  725. """
  726. _description = _("Basket includes %(count)d item(s) from %(range)s")
  727. @property
  728. def name(self):
  729. return self._description % {
  730. 'count': self.value,
  731. 'range': unicode(self.range).lower()}
  732. @property
  733. def description(self):
  734. return self._description % {
  735. 'count': self.value,
  736. 'range': range_anchor(self.range)}
  737. class Meta:
  738. proxy = True
  739. verbose_name = _("Count Condition")
  740. verbose_name_plural = _("Count Conditions")
  741. def is_satisfied(self, basket):
  742. """
  743. Determines whether a given basket meets this condition
  744. """
  745. num_matches = 0
  746. for line in basket.all_lines():
  747. if (self.can_apply_condition(line)
  748. and line.quantity_without_discount > 0):
  749. num_matches += line.quantity_without_discount
  750. if num_matches >= self.value:
  751. return True
  752. return False
  753. def _get_num_matches(self, basket):
  754. if hasattr(self, '_num_matches'):
  755. return getattr(self, '_num_matches')
  756. num_matches = 0
  757. for line in basket.all_lines():
  758. if (self.can_apply_condition(line)
  759. and line.quantity_without_discount > 0):
  760. num_matches += line.quantity_without_discount
  761. self._num_matches = num_matches
  762. return num_matches
  763. def is_partially_satisfied(self, basket):
  764. num_matches = self._get_num_matches(basket)
  765. return 0 < num_matches < self.value
  766. def get_upsell_message(self, basket):
  767. num_matches = self._get_num_matches(basket)
  768. delta = self.value - num_matches
  769. return ungettext('Buy %(delta)d more product from %(range)s',
  770. 'Buy %(delta)d more products from %(range)s', delta) % {
  771. 'delta': delta, 'range': self.range}
  772. def consume_items(self, basket, affected_lines):
  773. """
  774. Marks items within the basket lines as consumed so they
  775. can't be reused in other offers.
  776. :basket: The basket
  777. :affected_lines: The lines that have been affected by the discount.
  778. This should be list of tuples (line, discount, qty)
  779. """
  780. # We need to count how many items have already been consumed as part of
  781. # applying the benefit, so we don't consume too many items.
  782. num_consumed = 0
  783. for line, __, quantity in affected_lines:
  784. num_consumed += quantity
  785. to_consume = max(0, self.value - num_consumed)
  786. if to_consume == 0:
  787. return
  788. for __, line in self.get_applicable_lines(basket,
  789. most_expensive_first=True):
  790. quantity_to_consume = min(line.quantity_without_discount,
  791. to_consume)
  792. line.consume(quantity_to_consume)
  793. to_consume -= quantity_to_consume
  794. if to_consume == 0:
  795. break
  796. class CoverageCondition(Condition):
  797. """
  798. An offer condition dependent on the number of DISTINCT matching items from the basket.
  799. """
  800. _description = _("Basket includes %(count)d distinct item(s) from %(range)s")
  801. @property
  802. def name(self):
  803. return self._description % {
  804. 'count': self.value,
  805. 'range': unicode(self.range).lower()}
  806. @property
  807. def description(self):
  808. return self._description % {
  809. 'count': self.value,
  810. 'range': range_anchor(self.range)}
  811. class Meta:
  812. proxy = True
  813. verbose_name = _("Coverage Condition")
  814. verbose_name_plural = _("Coverage Conditions")
  815. def is_satisfied(self, basket):
  816. """
  817. Determines whether a given basket meets this condition
  818. """
  819. covered_ids = []
  820. for line in basket.all_lines():
  821. if not line.is_available_for_discount:
  822. continue
  823. product = line.product
  824. if (self.can_apply_condition(line) and product.id not in covered_ids):
  825. covered_ids.append(product.id)
  826. if len(covered_ids) >= self.value:
  827. return True
  828. return False
  829. def _get_num_covered_products(self, basket):
  830. covered_ids = []
  831. for line in basket.all_lines():
  832. if not line.is_available_for_discount:
  833. continue
  834. product = line.product
  835. if (self.can_apply_condition(line) and product.id not in covered_ids):
  836. covered_ids.append(product.id)
  837. return len(covered_ids)
  838. def get_upsell_message(self, basket):
  839. delta = self.value - self._get_num_covered_products(basket)
  840. return ungettext('Buy %(delta)d more product from %(range)s',
  841. 'Buy %(delta)d more products from %(range)s', delta) % {
  842. 'delta': delta, 'range': self.range}
  843. def is_partially_satisfied(self, basket):
  844. return 0 < self._get_num_covered_products(basket) < self.value
  845. def consume_items(self, basket, affected_lines):
  846. """
  847. Marks items within the basket lines as consumed so they
  848. can't be reused in other offers.
  849. """
  850. # Determine products that have already been consumed by applying the
  851. # benefit
  852. consumed_products = []
  853. for line, __, quantity in affected_lines:
  854. consumed_products.append(line.product)
  855. to_consume = max(0, self.value - len(consumed_products))
  856. if to_consume == 0:
  857. return
  858. for line in basket.all_lines():
  859. product = line.product
  860. if not self.can_apply_condition(line):
  861. continue
  862. if product in consumed_products:
  863. continue
  864. if not line.is_available_for_discount:
  865. continue
  866. # Only consume a quantity of 1 from each line
  867. line.consume(1)
  868. consumed_products.append(product)
  869. to_consume -= 1
  870. if to_consume == 0:
  871. break
  872. def get_value_of_satisfying_items(self, basket):
  873. covered_ids = []
  874. value = D('0.00')
  875. for line in basket.all_lines():
  876. if (self.can_apply_condition(line) and line.product.id not in covered_ids):
  877. covered_ids.append(line.product.id)
  878. value += line.unit_price_incl_tax
  879. if len(covered_ids) >= self.value:
  880. return value
  881. return value
  882. class ValueCondition(Condition):
  883. """
  884. An offer condition dependent on the VALUE of matching items from the
  885. basket.
  886. """
  887. _description = _("Basket includes %(amount)s from %(range)s")
  888. @property
  889. def name(self):
  890. return self._description % {
  891. 'amount': currency(self.value),
  892. 'range': unicode(self.range).lower()}
  893. @property
  894. def description(self):
  895. return self._description % {
  896. 'amount': currency(self.value),
  897. 'range': range_anchor(self.range)}
  898. class Meta:
  899. proxy = True
  900. verbose_name = _("Value Condition")
  901. verbose_name_plural = _("Value Conditions")
  902. def is_satisfied(self, basket):
  903. """
  904. Determine whether a given basket meets this condition
  905. """
  906. value_of_matches = D('0.00')
  907. for line in basket.all_lines():
  908. if (self.can_apply_condition(line) and line.quantity_without_discount > 0):
  909. price = line.unit_price_incl_tax
  910. value_of_matches += price * int(line.quantity_without_discount)
  911. if value_of_matches >= self.value:
  912. return True
  913. return False
  914. def _get_value_of_matches(self, basket):
  915. if hasattr(self, '_value_of_matches'):
  916. return getattr(self, '_value_of_matches')
  917. value_of_matches = D('0.00')
  918. for line in basket.all_lines():
  919. if (self.can_apply_condition(line) and line.quantity_without_discount > 0):
  920. price = line.unit_price_incl_tax
  921. value_of_matches += price * int(line.quantity_without_discount)
  922. self._value_of_matches = value_of_matches
  923. return value_of_matches
  924. def is_partially_satisfied(self, basket):
  925. value_of_matches = self._get_value_of_matches(basket)
  926. return D('0.00') < value_of_matches < self.value
  927. def get_upsell_message(self, basket):
  928. value_of_matches = self._get_value_of_matches(basket)
  929. return _('Spend %(value)s more from %(range)s') % {
  930. 'value': currency(self.value - value_of_matches),
  931. 'range': self.range}
  932. def consume_items(self, basket, affected_lines):
  933. """
  934. Marks items within the basket lines as consumed so they
  935. can't be reused in other offers.
  936. We allow lines to be passed in as sometimes we want them sorted
  937. in a specific order.
  938. """
  939. # Determine value of items already consumed as part of discount
  940. value_consumed = D('0.00')
  941. for line, __, qty in affected_lines:
  942. price = line.unit_price_incl_tax
  943. value_consumed += price * qty
  944. to_consume = max(0, self.value - value_consumed)
  945. if to_consume == 0:
  946. return
  947. for price, line in self.get_applicable_lines(
  948. basket, most_expensive_first=True):
  949. quantity_to_consume = min(
  950. line.quantity_without_discount,
  951. (to_consume / price).quantize(D(1), ROUND_UP))
  952. line.consume(quantity_to_consume)
  953. to_consume -= price * quantity_to_consume
  954. if to_consume <= 0:
  955. break
  956. # ============
  957. # Result types
  958. # ============
  959. class ApplicationResult(object):
  960. is_final = is_successful = False
  961. # Basket discount
  962. discount = D('0.00')
  963. description = None
  964. # Offer applications can affect 3 distinct things
  965. # (a) Give a discount off the BASKET total
  966. # (b) Give a discount off the SHIPPING total
  967. # (a) Trigger a post-order action
  968. BASKET, SHIPPING, POST_ORDER = range(0, 3)
  969. affects = None
  970. @property
  971. def affects_basket(self):
  972. return self.affects == self.BASKET
  973. @property
  974. def affects_shipping(self):
  975. return self.affects == self.SHIPPING
  976. @property
  977. def affects_post_order(self):
  978. return self.affects == self.POST_ORDER
  979. class BasketDiscount(ApplicationResult):
  980. """
  981. For when an offer application leads to a simple discount off the basket's
  982. total
  983. """
  984. affects = ApplicationResult.BASKET
  985. def __init__(self, amount):
  986. self.discount = amount
  987. @property
  988. def is_successful(self):
  989. return self.discount > 0
  990. # Helper global as returning zero discount is quite common
  991. ZERO_DISCOUNT = BasketDiscount(D('0.00'))
  992. class ShippingDiscount(ApplicationResult):
  993. """
  994. For when an offer application leads to a discount from the shipping cost
  995. """
  996. is_successful = is_final = True
  997. affects = ApplicationResult.SHIPPING
  998. SHIPPING_DISCOUNT = ShippingDiscount()
  999. class PostOrderAction(ApplicationResult):
  1000. """
  1001. For when an offer condition is met but the benefit is deferred until after
  1002. the order has been placed. Eg buy 2 books and get 100 loyalty points.
  1003. """
  1004. is_final = is_successful = True
  1005. affects = ApplicationResult.POST_ORDER
  1006. def __init__(self, description):
  1007. self.description = description
  1008. # ========
  1009. # Benefits
  1010. # ========
  1011. class PercentageDiscountBenefit(Benefit):
  1012. """
  1013. An offer benefit that gives a percentage discount
  1014. """
  1015. _description = _("%(value)s%% discount on %(range)s")
  1016. @property
  1017. def name(self):
  1018. return self._description % {
  1019. 'value': self.value,
  1020. 'range': self.range.name.lower()}
  1021. @property
  1022. def description(self):
  1023. return self._description % {
  1024. 'value': self.value,
  1025. 'range': range_anchor(self.range)}
  1026. class Meta:
  1027. proxy = True
  1028. verbose_name = _("Percentage discount benefit")
  1029. verbose_name_plural = _("Percentage discount benefits")
  1030. def apply(self, basket, condition, offer=None):
  1031. line_tuples = self.get_applicable_lines(basket)
  1032. discount = D('0.00')
  1033. affected_items = 0
  1034. max_affected_items = self._effective_max_affected_items()
  1035. affected_lines = []
  1036. for price, line in line_tuples:
  1037. if affected_items >= max_affected_items:
  1038. break
  1039. quantity_affected = min(line.quantity_without_discount,
  1040. max_affected_items - affected_items)
  1041. line_discount = self.round(self.value / D('100.0') * price
  1042. * int(quantity_affected))
  1043. line.discount(line_discount, quantity_affected)
  1044. affected_lines.append((line, line_discount, quantity_affected))
  1045. affected_items += quantity_affected
  1046. discount += line_discount
  1047. if discount > 0:
  1048. condition.consume_items(basket, affected_lines)
  1049. return BasketDiscount(discount)
  1050. class AbsoluteDiscountBenefit(Benefit):
  1051. """
  1052. An offer benefit that gives an absolute discount
  1053. """
  1054. _description = _("%(value)s discount on %(range)s")
  1055. @property
  1056. def name(self):
  1057. return self._description % {
  1058. 'value': currency(self.value),
  1059. 'range': self.range.name.lower()}
  1060. @property
  1061. def description(self):
  1062. return self._description % {
  1063. 'value': currency(self.value),
  1064. 'range': range_anchor(self.range)}
  1065. class Meta:
  1066. proxy = True
  1067. verbose_name = _("Absolute discount benefit")
  1068. verbose_name_plural = _("Absolute discount benefits")
  1069. def apply(self, basket, condition, offer=None):
  1070. # Fetch basket lines that are in the range and available to be used in
  1071. # an offer.
  1072. line_tuples = self.get_applicable_lines(basket)
  1073. if not line_tuples:
  1074. return ZERO_DISCOUNT
  1075. # Determine which lines can have the discount applied to them
  1076. max_affected_items = self._effective_max_affected_items()
  1077. num_affected_items = 0
  1078. affected_items_total = D('0.00')
  1079. lines_to_discount = []
  1080. for price, line in line_tuples:
  1081. if num_affected_items >= max_affected_items:
  1082. break
  1083. qty = min(line.quantity_without_discount,
  1084. max_affected_items - num_affected_items)
  1085. lines_to_discount.append((line, price, qty))
  1086. num_affected_items += qty
  1087. affected_items_total += qty * price
  1088. # Guard against zero price products causing problems
  1089. if not affected_items_total:
  1090. return ZERO_DISCOUNT
  1091. # Ensure we don't try to apply a discount larger than the total of the
  1092. # matching items.
  1093. discount = min(self.value, affected_items_total)
  1094. # Apply discount equally amongst them
  1095. affected_lines = []
  1096. applied_discount = D('0.00')
  1097. for i, (line, price, qty) in enumerate(lines_to_discount):
  1098. if i == len(lines_to_discount) - 1:
  1099. # If last line, then take the delta as the discount to ensure
  1100. # the total discount is correct and doesn't mismatch due to
  1101. # rounding.
  1102. line_discount = discount - applied_discount
  1103. else:
  1104. # Calculate a weighted discount for the line
  1105. line_discount = self.round(
  1106. ((price * qty) / affected_items_total) * discount)
  1107. line.discount(line_discount, qty)
  1108. affected_lines.append((line, line_discount, qty))
  1109. applied_discount += line_discount
  1110. condition.consume_items(basket, affected_lines)
  1111. return BasketDiscount(discount)
  1112. class FixedPriceBenefit(Benefit):
  1113. """
  1114. An offer benefit that gives the items in the condition for a
  1115. fixed price. This is useful for "bundle" offers.
  1116. Note that we ignore the benefit range here and only give a fixed price
  1117. for the products in the condition range. The condition cannot be a value
  1118. condition.
  1119. We also ignore the max_affected_items setting.
  1120. """
  1121. _description = _("The products that meet the condition are sold "
  1122. "for %(amount)s")
  1123. def __unicode__(self):
  1124. return self._description % {
  1125. 'amount': currency(self.value)}
  1126. @property
  1127. def description(self):
  1128. return self.__unicode__()
  1129. class Meta:
  1130. proxy = True
  1131. verbose_name = _("Fixed price benefit")
  1132. verbose_name_plural = _("Fixed price benefits")
  1133. def apply(self, basket, condition, offer=None):
  1134. if isinstance(condition, ValueCondition):
  1135. return ZERO_DISCOUNT
  1136. # Fetch basket lines that are in the range and available to be used in
  1137. # an offer.
  1138. line_tuples = self.get_applicable_lines(basket, range=condition.range)
  1139. if not line_tuples:
  1140. return ZERO_DISCOUNT
  1141. # Determine the lines to consume
  1142. num_permitted = int(condition.value)
  1143. num_affected = 0
  1144. value_affected = D('0.00')
  1145. covered_lines = []
  1146. for price, line in line_tuples:
  1147. if isinstance(condition, CoverageCondition):
  1148. quantity_affected = 1
  1149. else:
  1150. quantity_affected = min(
  1151. line.quantity_without_discount,
  1152. num_permitted - num_affected)
  1153. num_affected += quantity_affected
  1154. value_affected += quantity_affected * price
  1155. covered_lines.append((price, line, quantity_affected))
  1156. if num_affected >= num_permitted:
  1157. break
  1158. discount = max(value_affected - self.value, D('0.00'))
  1159. if not discount:
  1160. return ZERO_DISCOUNT
  1161. # Apply discount to the affected lines
  1162. discount_applied = D('0.00')
  1163. last_line = covered_lines[-1][1]
  1164. for price, line, quantity in covered_lines:
  1165. if line == last_line:
  1166. # If last line, we just take the difference to ensure that
  1167. # rounding doesn't lead to an off-by-one error
  1168. line_discount = discount - discount_applied
  1169. else:
  1170. line_discount = self.round(
  1171. discount * (price * quantity) / value_affected)
  1172. line.discount(line_discount, quantity)
  1173. discount_applied += line_discount
  1174. return BasketDiscount(discount)
  1175. class MultibuyDiscountBenefit(Benefit):
  1176. _description = _("Cheapest product from %(range)s is free")
  1177. @property
  1178. def name(self):
  1179. return self._description % {
  1180. 'range': self.range.name.lower()}
  1181. @property
  1182. def description(self):
  1183. return self._description % {
  1184. 'range': range_anchor(self.range)}
  1185. class Meta:
  1186. proxy = True
  1187. verbose_name = _("Multibuy discount benefit")
  1188. verbose_name_plural = _("Multibuy discount benefits")
  1189. def apply(self, basket, condition, offer=None):
  1190. line_tuples = self.get_applicable_lines(basket)
  1191. if not line_tuples:
  1192. return ZERO_DISCOUNT
  1193. # Cheapest line gives free product
  1194. discount, line = line_tuples[0]
  1195. line.discount(discount, 1)
  1196. affected_lines = [(line, discount, 1)]
  1197. condition.consume_items(basket, affected_lines)
  1198. return BasketDiscount(discount)
  1199. # =================
  1200. # Shipping benefits
  1201. # =================
  1202. class ShippingBenefit(Benefit):
  1203. def apply(self, basket, condition, offer=None):
  1204. condition.consume_items(basket, affected_lines=())
  1205. return SHIPPING_DISCOUNT
  1206. class Meta:
  1207. proxy = True
  1208. class ShippingAbsoluteDiscountBenefit(ShippingBenefit):
  1209. _description = _("%(amount)s off shipping cost")
  1210. @property
  1211. def description(self):
  1212. return self._description % {
  1213. 'amount': currency(self.value)}
  1214. class Meta:
  1215. proxy = True
  1216. verbose_name = _("Shipping absolute discount benefit")
  1217. verbose_name_plural = _("Shipping absolute discount benefits")
  1218. def shipping_discount(self, charge):
  1219. return min(charge, self.value)
  1220. class ShippingFixedPriceBenefit(ShippingBenefit):
  1221. _description = _("Get shipping for %(amount)s")
  1222. @property
  1223. def description(self):
  1224. return self._description % {
  1225. 'amount': currency(self.value)}
  1226. class Meta:
  1227. proxy = True
  1228. verbose_name = _("Fixed price shipping benefit")
  1229. verbose_name_plural = _("Fixed price shipping benefits")
  1230. def shipping_discount(self, charge):
  1231. if charge < self.value:
  1232. return D('0.00')
  1233. return charge - self.value
  1234. class ShippingPercentageDiscountBenefit(ShippingBenefit):
  1235. _description = _("%(value)s%% off of shipping cost")
  1236. @property
  1237. def description(self):
  1238. return self._description % {
  1239. 'value': self.value}
  1240. class Meta:
  1241. proxy = True
  1242. verbose_name = _("Shipping percentage discount benefit")
  1243. verbose_name_plural = _("Shipping percentage discount benefits")
  1244. def shipping_discount(self, charge):
  1245. return charge * self.value / D('100.0')