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

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