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

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