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

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