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

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