Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

abstract_models.py 40KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081
  1. import itertools
  2. import os
  3. import operator
  4. import re
  5. from decimal import Decimal as D, ROUND_DOWN
  6. from django.db import models
  7. from django.db.models.query import Q
  8. from django.core import exceptions
  9. from django.core.urlresolvers import reverse
  10. from django.template.defaultfilters import date as date_filter
  11. from django.utils.encoding import python_2_unicode_compatible
  12. from django.utils.functional import cached_property
  13. from django.utils.timezone import now, get_current_timezone
  14. from django.utils.translation import ugettext_lazy as _
  15. from django.conf import settings
  16. from oscar.apps.offer import results, utils
  17. from oscar.apps.offer.managers import ActiveOfferManager
  18. from oscar.core.compat import AUTH_USER_MODEL
  19. from oscar.core.loading import get_model, get_class
  20. from oscar.models import fields
  21. from oscar.templatetags.currency_filters import currency
  22. BrowsableRangeManager = get_class('offer.managers', 'BrowsableRangeManager')
  23. @python_2_unicode_compatible
  24. class AbstractConditionalOffer(models.Model):
  25. """
  26. A conditional offer (eg buy 1, get 10% off)
  27. """
  28. name = models.CharField(
  29. _("Name"), max_length=128, unique=True,
  30. help_text=_("This is displayed within the customer's basket"))
  31. slug = fields.AutoSlugField(
  32. _("Slug"), max_length=128, unique=True, populate_from='name')
  33. description = models.TextField(_("Description"), blank=True,
  34. help_text=_("This is displayed on the offer"
  35. " browsing page"))
  36. # Offers come in a few different types:
  37. # (a) Offers that are available to all customers on the site. Eg a
  38. # 3-for-2 offer.
  39. # (b) Offers that are linked to a voucher, and only become available once
  40. # that voucher has been applied to the basket
  41. # (c) Offers that are linked to a user. Eg, all students get 10% off. The
  42. # code to apply this offer needs to be coded
  43. # (d) Session offers - these are temporarily available to a user after some
  44. # trigger event. Eg, users coming from some affiliate site get 10%
  45. # off.
  46. SITE, VOUCHER, USER, SESSION = ("Site", "Voucher", "User", "Session")
  47. TYPE_CHOICES = (
  48. (SITE, _("Site offer - available to all users")),
  49. (VOUCHER, _("Voucher offer - only available after entering "
  50. "the appropriate voucher code")),
  51. (USER, _("User offer - available to certain types of user")),
  52. (SESSION, _("Session offer - temporary offer, available for "
  53. "a user for the duration of their session")),
  54. )
  55. offer_type = models.CharField(
  56. _("Type"), choices=TYPE_CHOICES, default=SITE, max_length=128)
  57. # We track a status variable so it's easier to load offers that are
  58. # 'available' in some sense.
  59. OPEN, SUSPENDED, CONSUMED = "Open", "Suspended", "Consumed"
  60. status = models.CharField(_("Status"), max_length=64, default=OPEN)
  61. condition = models.ForeignKey(
  62. 'offer.Condition', verbose_name=_("Condition"))
  63. benefit = models.ForeignKey('offer.Benefit', verbose_name=_("Benefit"))
  64. # Some complicated situations require offers to be applied in a set order.
  65. priority = models.IntegerField(
  66. _("Priority"), default=0,
  67. help_text=_("The highest priority offers are applied first"))
  68. # AVAILABILITY
  69. # Range of availability. Note that if this is a voucher offer, then these
  70. # dates are ignored and only the dates from the voucher are used to
  71. # determine availability.
  72. start_datetime = models.DateTimeField(
  73. _("Start date"), blank=True, null=True)
  74. end_datetime = models.DateTimeField(
  75. _("End date"), blank=True, null=True,
  76. help_text=_("Offers are active until the end of the 'end date'"))
  77. # Use this field to limit the number of times this offer can be applied in
  78. # total. Note that a single order can apply an offer multiple times so
  79. # this is not the same as the number of orders that can use it.
  80. max_global_applications = models.PositiveIntegerField(
  81. _("Max global applications"),
  82. help_text=_("The number of times this offer can be used before it "
  83. "is unavailable"), blank=True, null=True)
  84. # Use this field to limit the number of times this offer can be used by a
  85. # single user. This only works for signed-in users - it doesn't really
  86. # make sense for sites that allow anonymous checkout.
  87. max_user_applications = models.PositiveIntegerField(
  88. _("Max user applications"),
  89. help_text=_("The number of times a single user can use this offer"),
  90. blank=True, null=True)
  91. # Use this field to limit the number of times this offer can be applied to
  92. # a basket (and hence a single order).
  93. max_basket_applications = models.PositiveIntegerField(
  94. _("Max basket applications"),
  95. blank=True, null=True,
  96. help_text=_("The number of times this offer can be applied to a "
  97. "basket (and order)"))
  98. # Use this field to limit the amount of discount an offer can lead to.
  99. # This can be helpful with budgeting.
  100. max_discount = models.DecimalField(
  101. _("Max discount"), decimal_places=2, max_digits=12, null=True,
  102. blank=True,
  103. help_text=_("When an offer has given more discount to orders "
  104. "than this threshold, then the offer becomes "
  105. "unavailable"))
  106. # TRACKING
  107. total_discount = models.DecimalField(
  108. _("Total Discount"), decimal_places=2, max_digits=12,
  109. default=D('0.00'))
  110. num_applications = models.PositiveIntegerField(
  111. _("Number of applications"), default=0)
  112. num_orders = models.PositiveIntegerField(
  113. _("Number of Orders"), default=0)
  114. redirect_url = fields.ExtendedURLField(
  115. _("URL redirect (optional)"), blank=True)
  116. date_created = models.DateTimeField(_("Date Created"), auto_now_add=True)
  117. objects = models.Manager()
  118. active = ActiveOfferManager()
  119. # We need to track the voucher that this offer came from (if it is a
  120. # voucher offer)
  121. _voucher = None
  122. class Meta:
  123. abstract = True
  124. app_label = 'offer'
  125. ordering = ['-priority']
  126. verbose_name = _("Conditional offer")
  127. verbose_name_plural = _("Conditional offers")
  128. def save(self, *args, **kwargs):
  129. # Check to see if consumption thresholds have been broken
  130. if not self.is_suspended:
  131. if self.get_max_applications() == 0:
  132. self.status = self.CONSUMED
  133. else:
  134. self.status = self.OPEN
  135. return super(AbstractConditionalOffer, self).save(*args, **kwargs)
  136. def get_absolute_url(self):
  137. return reverse('offer:detail', kwargs={'slug': self.slug})
  138. def __str__(self):
  139. return self.name
  140. def clean(self):
  141. if (self.start_datetime and self.end_datetime and
  142. self.start_datetime > self.end_datetime):
  143. raise exceptions.ValidationError(
  144. _('End date should be later than start date'))
  145. @property
  146. def is_open(self):
  147. return self.status == self.OPEN
  148. @property
  149. def is_suspended(self):
  150. return self.status == self.SUSPENDED
  151. def suspend(self):
  152. self.status = self.SUSPENDED
  153. self.save()
  154. suspend.alters_data = True
  155. def unsuspend(self):
  156. self.status = self.OPEN
  157. self.save()
  158. unsuspend.alters_data = True
  159. def is_available(self, user=None, test_date=None):
  160. """
  161. Test whether this offer is available to be used
  162. """
  163. if self.is_suspended:
  164. return False
  165. if test_date is None:
  166. test_date = now()
  167. predicates = []
  168. if self.start_datetime:
  169. predicates.append(self.start_datetime > test_date)
  170. if self.end_datetime:
  171. predicates.append(test_date > self.end_datetime)
  172. if any(predicates):
  173. return False
  174. return self.get_max_applications(user) > 0
  175. def is_condition_satisfied(self, basket):
  176. return self.condition.proxy().is_satisfied(self, basket)
  177. def is_condition_partially_satisfied(self, basket):
  178. return self.condition.proxy().is_partially_satisfied(self, basket)
  179. def get_upsell_message(self, basket):
  180. return self.condition.proxy().get_upsell_message(self, basket)
  181. def apply_benefit(self, basket):
  182. """
  183. Applies the benefit to the given basket and returns the discount.
  184. """
  185. if not self.is_condition_satisfied(basket):
  186. return results.ZERO_DISCOUNT
  187. return self.benefit.proxy().apply(
  188. basket, self.condition.proxy(), self)
  189. def apply_deferred_benefit(self, basket, order, application):
  190. """
  191. Applies any deferred benefits. These are things like adding loyalty
  192. points to somone's account.
  193. """
  194. return self.benefit.proxy().apply_deferred(basket, order, application)
  195. def set_voucher(self, voucher):
  196. self._voucher = voucher
  197. def get_voucher(self):
  198. return self._voucher
  199. def get_max_applications(self, user=None):
  200. """
  201. Return the number of times this offer can be applied to a basket for a
  202. given user.
  203. """
  204. if self.max_discount and self.total_discount >= self.max_discount:
  205. return 0
  206. # Hard-code a maximum value as we need some sensible upper limit for
  207. # when there are not other caps.
  208. limits = [10000]
  209. if self.max_user_applications and user:
  210. limits.append(max(0, self.max_user_applications -
  211. self.get_num_user_applications(user)))
  212. if self.max_basket_applications:
  213. limits.append(self.max_basket_applications)
  214. if self.max_global_applications:
  215. limits.append(
  216. max(0, self.max_global_applications - self.num_applications))
  217. return min(limits)
  218. def get_num_user_applications(self, user):
  219. OrderDiscount = get_model('order', 'OrderDiscount')
  220. aggregates = OrderDiscount.objects.filter(offer_id=self.id,
  221. order__user=user)\
  222. .aggregate(total=models.Sum('frequency'))
  223. return aggregates['total'] if aggregates['total'] is not None else 0
  224. def shipping_discount(self, charge):
  225. return self.benefit.proxy().shipping_discount(charge)
  226. def record_usage(self, discount):
  227. self.num_applications += discount['freq']
  228. self.total_discount += discount['discount']
  229. self.num_orders += 1
  230. self.save()
  231. record_usage.alters_data = True
  232. def availability_description(self):
  233. """
  234. Return a description of when this offer is available
  235. """
  236. restrictions = self.availability_restrictions()
  237. descriptions = [r['description'] for r in restrictions]
  238. return "<br/>".join(descriptions)
  239. def availability_restrictions(self): # noqa (too complex (15))
  240. restrictions = []
  241. if self.is_suspended:
  242. restrictions.append({
  243. 'description': _("Offer is suspended"),
  244. 'is_satisfied': False})
  245. if self.max_global_applications:
  246. remaining = self.max_global_applications - self.num_applications
  247. desc = _("Limited to %(total)d uses (%(remainder)d remaining)") \
  248. % {'total': self.max_global_applications,
  249. 'remainder': remaining}
  250. restrictions.append({'description': desc,
  251. 'is_satisfied': remaining > 0})
  252. if self.max_user_applications:
  253. if self.max_user_applications == 1:
  254. desc = _("Limited to 1 use per user")
  255. else:
  256. desc = _("Limited to %(total)d uses per user") \
  257. % {'total': self.max_user_applications}
  258. restrictions.append({'description': desc,
  259. 'is_satisfied': True})
  260. if self.max_basket_applications:
  261. if self.max_user_applications == 1:
  262. desc = _("Limited to 1 use per basket")
  263. else:
  264. desc = _("Limited to %(total)d uses per basket") \
  265. % {'total': self.max_basket_applications}
  266. restrictions.append({
  267. 'description': desc,
  268. 'is_satisfied': True})
  269. def hide_time_if_zero(dt):
  270. # Only show hours/minutes if they have been specified
  271. if dt.tzinfo:
  272. localtime = dt.astimezone(get_current_timezone())
  273. else:
  274. localtime = dt
  275. if localtime.hour == 0 and localtime.minute == 0:
  276. return date_filter(localtime, settings.DATE_FORMAT)
  277. return date_filter(localtime, settings.DATETIME_FORMAT)
  278. if self.start_datetime or self.end_datetime:
  279. today = now()
  280. if self.start_datetime and self.end_datetime:
  281. desc = _("Available between %(start)s and %(end)s") \
  282. % {'start': hide_time_if_zero(self.start_datetime),
  283. 'end': hide_time_if_zero(self.end_datetime)}
  284. is_satisfied \
  285. = self.start_datetime <= today <= self.end_datetime
  286. elif self.start_datetime:
  287. desc = _("Available from %(start)s") % {
  288. 'start': hide_time_if_zero(self.start_datetime)}
  289. is_satisfied = today >= self.start_datetime
  290. elif self.end_datetime:
  291. desc = _("Available until %(end)s") % {
  292. 'end': hide_time_if_zero(self.end_datetime)}
  293. is_satisfied = today <= self.end_datetime
  294. restrictions.append({
  295. 'description': desc,
  296. 'is_satisfied': is_satisfied})
  297. if self.max_discount:
  298. desc = _("Limited to a cost of %(max)s") % {
  299. 'max': currency(self.max_discount)}
  300. restrictions.append({
  301. 'description': desc,
  302. 'is_satisfied': self.total_discount < self.max_discount})
  303. return restrictions
  304. @property
  305. def has_products(self):
  306. return self.condition.range is not None
  307. def products(self):
  308. """
  309. Return a queryset of products in this offer
  310. """
  311. Product = get_model('catalogue', 'Product')
  312. if not self.has_products:
  313. return Product.objects.none()
  314. cond_range = self.condition.range
  315. if cond_range.includes_all_products:
  316. # Return ALL the products
  317. queryset = Product.browsable
  318. else:
  319. queryset = cond_range.included_products
  320. return queryset.filter(is_discountable=True).exclude(
  321. structure=Product.CHILD)
  322. @python_2_unicode_compatible
  323. class AbstractBenefit(models.Model):
  324. range = models.ForeignKey(
  325. 'offer.Range', null=True, blank=True, verbose_name=_("Range"))
  326. # Benefit types
  327. PERCENTAGE, FIXED, MULTIBUY, FIXED_PRICE = (
  328. "Percentage", "Absolute", "Multibuy", "Fixed price")
  329. SHIPPING_PERCENTAGE, SHIPPING_ABSOLUTE, SHIPPING_FIXED_PRICE = (
  330. 'Shipping percentage', 'Shipping absolute', 'Shipping fixed price')
  331. TYPE_CHOICES = (
  332. (PERCENTAGE, _("Discount is a percentage off of the product's value")),
  333. (FIXED, _("Discount is a fixed amount off of the product's value")),
  334. (MULTIBUY, _("Discount is to give the cheapest product for free")),
  335. (FIXED_PRICE,
  336. _("Get the products that meet the condition for a fixed price")),
  337. (SHIPPING_ABSOLUTE,
  338. _("Discount is a fixed amount of the shipping cost")),
  339. (SHIPPING_FIXED_PRICE, _("Get shipping for a fixed price")),
  340. (SHIPPING_PERCENTAGE, _("Discount is a percentage off of the shipping"
  341. " cost")),
  342. )
  343. type = models.CharField(
  344. _("Type"), max_length=128, choices=TYPE_CHOICES, blank=True)
  345. # The value to use with the designated type. This can be either an integer
  346. # (eg for multibuy) or a decimal (eg an amount) which is slightly
  347. # confusing.
  348. value = fields.PositiveDecimalField(
  349. _("Value"), decimal_places=2, max_digits=12, null=True, blank=True)
  350. # If this is not set, then there is no upper limit on how many products
  351. # can be discounted by this benefit.
  352. max_affected_items = models.PositiveIntegerField(
  353. _("Max Affected Items"), blank=True, null=True,
  354. help_text=_("Set this to prevent the discount consuming all items "
  355. "within the range that are in the basket."))
  356. # A custom benefit class can be used instead. This means the
  357. # type/value/max_affected_items fields should all be None.
  358. proxy_class = fields.NullCharField(
  359. _("Custom class"), max_length=255, unique=True, default=None)
  360. class Meta:
  361. abstract = True
  362. app_label = 'offer'
  363. verbose_name = _("Benefit")
  364. verbose_name_plural = _("Benefits")
  365. def proxy(self):
  366. from oscar.apps.offer import benefits
  367. klassmap = {
  368. self.PERCENTAGE: benefits.PercentageDiscountBenefit,
  369. self.FIXED: benefits.AbsoluteDiscountBenefit,
  370. self.MULTIBUY: benefits.MultibuyDiscountBenefit,
  371. self.FIXED_PRICE: benefits.FixedPriceBenefit,
  372. self.SHIPPING_ABSOLUTE: benefits.ShippingAbsoluteDiscountBenefit,
  373. self.SHIPPING_FIXED_PRICE: benefits.ShippingFixedPriceBenefit,
  374. self.SHIPPING_PERCENTAGE: benefits.ShippingPercentageDiscountBenefit
  375. }
  376. # Short-circuit logic if current class is already a proxy class.
  377. if self.__class__ in klassmap.values():
  378. return self
  379. field_dict = dict(self.__dict__)
  380. for field in list(field_dict.keys()):
  381. if field.startswith('_'):
  382. del field_dict[field]
  383. if self.proxy_class:
  384. klass = utils.load_proxy(self.proxy_class)
  385. # Short-circuit again.
  386. if self.__class__ == klass:
  387. return self
  388. return klass(**field_dict)
  389. if self.type in klassmap:
  390. return klassmap[self.type](**field_dict)
  391. raise RuntimeError("Unrecognised benefit type (%s)" % self.type)
  392. def __str__(self):
  393. return self.name
  394. @property
  395. def name(self):
  396. """
  397. A plaintext description of the benefit. Every proxy class has to
  398. implement it.
  399. This is used in the dropdowns within the offer dashboard.
  400. """
  401. return self.proxy().name
  402. @property
  403. def description(self):
  404. """
  405. A description of the benefit.
  406. Defaults to the name. May contain HTML.
  407. """
  408. return self.name
  409. def apply(self, basket, condition, offer):
  410. return results.ZERO_DISCOUNT
  411. def apply_deferred(self, basket, order, application):
  412. return None
  413. def clean(self):
  414. if not self.type:
  415. return
  416. method_name = 'clean_%s' % self.type.lower().replace(' ', '_')
  417. if hasattr(self, method_name):
  418. getattr(self, method_name)()
  419. def clean_multibuy(self):
  420. if not self.range:
  421. raise exceptions.ValidationError(
  422. _("Multibuy benefits require a product range"))
  423. if self.value:
  424. raise exceptions.ValidationError(
  425. _("Multibuy benefits don't require a value"))
  426. if self.max_affected_items:
  427. raise exceptions.ValidationError(
  428. _("Multibuy benefits don't require a 'max affected items' "
  429. "attribute"))
  430. def clean_percentage(self):
  431. if not self.range:
  432. raise exceptions.ValidationError(
  433. _("Percentage benefits require a product range"))
  434. if self.value > 100:
  435. raise exceptions.ValidationError(
  436. _("Percentage discount cannot be greater than 100"))
  437. def clean_shipping_absolute(self):
  438. if not self.value:
  439. raise exceptions.ValidationError(
  440. _("A discount value is required"))
  441. if self.range:
  442. raise exceptions.ValidationError(
  443. _("No range should be selected as this benefit does not "
  444. "apply to products"))
  445. if self.max_affected_items:
  446. raise exceptions.ValidationError(
  447. _("Shipping discounts don't require a 'max affected items' "
  448. "attribute"))
  449. def clean_shipping_percentage(self):
  450. if self.value > 100:
  451. raise exceptions.ValidationError(
  452. _("Percentage discount cannot be greater than 100"))
  453. if self.range:
  454. raise exceptions.ValidationError(
  455. _("No range should be selected as this benefit does not "
  456. "apply to products"))
  457. if self.max_affected_items:
  458. raise exceptions.ValidationError(
  459. _("Shipping discounts don't require a 'max affected items' "
  460. "attribute"))
  461. def clean_shipping_fixed_price(self):
  462. if self.range:
  463. raise exceptions.ValidationError(
  464. _("No range should be selected as this benefit does not "
  465. "apply to products"))
  466. if self.max_affected_items:
  467. raise exceptions.ValidationError(
  468. _("Shipping discounts don't require a 'max affected items' "
  469. "attribute"))
  470. def clean_fixed_price(self):
  471. if self.range:
  472. raise exceptions.ValidationError(
  473. _("No range should be selected as the condition range will "
  474. "be used instead."))
  475. def clean_absolute(self):
  476. if not self.range:
  477. raise exceptions.ValidationError(
  478. _("Fixed discount benefits require a product range"))
  479. if not self.value:
  480. raise exceptions.ValidationError(
  481. _("Fixed discount benefits require a value"))
  482. def round(self, amount):
  483. """
  484. Apply rounding to discount amount
  485. """
  486. if hasattr(settings, 'OSCAR_OFFER_ROUNDING_FUNCTION'):
  487. return settings.OSCAR_OFFER_ROUNDING_FUNCTION(amount)
  488. return amount.quantize(D('.01'), ROUND_DOWN)
  489. def _effective_max_affected_items(self):
  490. """
  491. Return the maximum number of items that can have a discount applied
  492. during the application of this benefit
  493. """
  494. return self.max_affected_items if self.max_affected_items else 10000
  495. def can_apply_benefit(self, line):
  496. """
  497. Determines whether the benefit can be applied to a given basket line
  498. """
  499. return line.stockrecord and line.product.is_discountable
  500. def get_applicable_lines(self, offer, basket, range=None):
  501. """
  502. Return the basket lines that are available to be discounted
  503. :basket: The basket
  504. :range: The range of products to use for filtering. The fixed-price
  505. benefit ignores its range and uses the condition range
  506. """
  507. if range is None:
  508. range = self.range
  509. line_tuples = []
  510. for line in basket.all_lines():
  511. product = line.product
  512. if (not range.contains(product) or
  513. not self.can_apply_benefit(line)):
  514. continue
  515. price = utils.unit_price(offer, line)
  516. if not price:
  517. # Avoid zero price products
  518. continue
  519. if line.quantity_without_discount == 0:
  520. continue
  521. line_tuples.append((price, line))
  522. # We sort lines to be cheapest first to ensure consistent applications
  523. return sorted(line_tuples, key=operator.itemgetter(0))
  524. def shipping_discount(self, charge):
  525. return D('0.00')
  526. @python_2_unicode_compatible
  527. class AbstractCondition(models.Model):
  528. """
  529. A condition for an offer to be applied. You can either specify a custom
  530. proxy class, or need to specify a type, range and value.
  531. """
  532. COUNT, VALUE, COVERAGE = ("Count", "Value", "Coverage")
  533. TYPE_CHOICES = (
  534. (COUNT, _("Depends on number of items in basket that are in "
  535. "condition range")),
  536. (VALUE, _("Depends on value of items in basket that are in "
  537. "condition range")),
  538. (COVERAGE, _("Needs to contain a set number of DISTINCT items "
  539. "from the condition range")))
  540. range = models.ForeignKey(
  541. 'offer.Range', verbose_name=_("Range"), null=True, blank=True)
  542. type = models.CharField(_('Type'), max_length=128, choices=TYPE_CHOICES,
  543. blank=True)
  544. value = fields.PositiveDecimalField(
  545. _('Value'), decimal_places=2, max_digits=12, null=True, blank=True)
  546. proxy_class = fields.NullCharField(
  547. _("Custom class"), max_length=255, unique=True, default=None)
  548. class Meta:
  549. abstract = True
  550. app_label = 'offer'
  551. verbose_name = _("Condition")
  552. verbose_name_plural = _("Conditions")
  553. def proxy(self):
  554. """
  555. Return the proxy model
  556. """
  557. from oscar.apps.offer import conditions
  558. klassmap = {
  559. self.COUNT: conditions.CountCondition,
  560. self.VALUE: conditions.ValueCondition,
  561. self.COVERAGE: conditions.CoverageCondition
  562. }
  563. # Short-circuit logic if current class is already a proxy class.
  564. if self.__class__ in klassmap.values():
  565. return self
  566. field_dict = dict(self.__dict__)
  567. for field in list(field_dict.keys()):
  568. if field.startswith('_'):
  569. del field_dict[field]
  570. if self.proxy_class:
  571. klass = utils.load_proxy(self.proxy_class)
  572. # Short-circuit again.
  573. if self.__class__ == klass:
  574. return self
  575. return klass(**field_dict)
  576. if self.type in klassmap:
  577. return klassmap[self.type](**field_dict)
  578. raise RuntimeError("Unrecognised condition type (%s)" % self.type)
  579. def __str__(self):
  580. return self.name
  581. @property
  582. def name(self):
  583. """
  584. A plaintext description of the condition. Every proxy class has to
  585. implement it.
  586. This is used in the dropdowns within the offer dashboard.
  587. """
  588. return self.proxy().name
  589. @property
  590. def description(self):
  591. """
  592. A description of the condition.
  593. Defaults to the name. May contain HTML.
  594. """
  595. return self.name
  596. def consume_items(self, offer, basket, affected_lines):
  597. pass
  598. def is_satisfied(self, offer, basket):
  599. """
  600. Determines whether a given basket meets this condition. This is
  601. stubbed in this top-class object. The subclassing proxies are
  602. responsible for implementing it correctly.
  603. """
  604. return False
  605. def is_partially_satisfied(self, offer, basket):
  606. """
  607. Determine if the basket partially meets the condition. This is useful
  608. for up-selling messages to entice customers to buy something more in
  609. order to qualify for an offer.
  610. """
  611. return False
  612. def get_upsell_message(self, offer, basket):
  613. return None
  614. def can_apply_condition(self, line):
  615. """
  616. Determines whether the condition can be applied to a given basket line
  617. """
  618. if not line.stockrecord_id:
  619. return False
  620. product = line.product
  621. return (self.range.contains_product(product)
  622. and product.get_is_discountable())
  623. def get_applicable_lines(self, offer, basket, most_expensive_first=True):
  624. """
  625. Return line data for the lines that can be consumed by this condition
  626. """
  627. line_tuples = []
  628. for line in basket.all_lines():
  629. if not self.can_apply_condition(line):
  630. continue
  631. price = utils.unit_price(offer, line)
  632. if not price:
  633. continue
  634. line_tuples.append((price, line))
  635. key = operator.itemgetter(0)
  636. if most_expensive_first:
  637. return sorted(line_tuples, reverse=True, key=key)
  638. return sorted(line_tuples, key=key)
  639. @python_2_unicode_compatible
  640. class AbstractRange(models.Model):
  641. """
  642. Represents a range of products that can be used within an offer.
  643. Ranges only support adding parent or stand-alone products. Offers will
  644. consider child products automatically.
  645. """
  646. name = models.CharField(_("Name"), max_length=128, unique=True)
  647. slug = fields.AutoSlugField(
  648. _("Slug"), max_length=128, unique=True, populate_from="name")
  649. description = models.TextField(blank=True)
  650. # Whether this range is public
  651. is_public = models.BooleanField(
  652. _('Is public?'), default=False,
  653. help_text=_("Public ranges have a customer-facing page"))
  654. includes_all_products = models.BooleanField(
  655. _('Includes all products?'), default=False)
  656. included_products = models.ManyToManyField(
  657. 'catalogue.Product', related_name='includes', blank=True,
  658. verbose_name=_("Included Products"), through='offer.RangeProduct')
  659. excluded_products = models.ManyToManyField(
  660. 'catalogue.Product', related_name='excludes', blank=True,
  661. verbose_name=_("Excluded Products"))
  662. classes = models.ManyToManyField(
  663. 'catalogue.ProductClass', related_name='classes', blank=True,
  664. verbose_name=_("Product Types"))
  665. included_categories = models.ManyToManyField(
  666. 'catalogue.Category', related_name='includes', blank=True,
  667. verbose_name=_("Included Categories"))
  668. # Allow a custom range instance to be specified
  669. proxy_class = fields.NullCharField(
  670. _("Custom class"), max_length=255, default=None, unique=True)
  671. date_created = models.DateTimeField(_("Date Created"), auto_now_add=True)
  672. __included_product_ids = None
  673. __excluded_product_ids = None
  674. __class_ids = None
  675. __category_ids = None
  676. objects = models.Manager()
  677. browsable = BrowsableRangeManager()
  678. class Meta:
  679. abstract = True
  680. app_label = 'offer'
  681. verbose_name = _("Range")
  682. verbose_name_plural = _("Ranges")
  683. def __str__(self):
  684. return self.name
  685. def get_absolute_url(self):
  686. return reverse(
  687. 'catalogue:range', kwargs={'slug': self.slug})
  688. @cached_property
  689. def proxy(self):
  690. if self.proxy_class:
  691. return utils.load_proxy(self.proxy_class)()
  692. def add_product(self, product, display_order=None):
  693. """ Add product to the range
  694. When adding product that is already in the range, prevent re-adding it.
  695. If display_order is specified, update it.
  696. Default display_order for a new product in the range is 0; this puts
  697. the product at the top of the list.
  698. """
  699. initial_order = display_order or 0
  700. RangeProduct = get_model('offer', 'RangeProduct')
  701. relation, __ = RangeProduct.objects.get_or_create(
  702. range=self, product=product,
  703. defaults={'display_order': initial_order})
  704. if (display_order is not None and
  705. relation.display_order != display_order):
  706. relation.display_order = display_order
  707. relation.save()
  708. def remove_product(self, product):
  709. """
  710. Remove product from range. To save on queries, this function does not
  711. check if the product is in fact in the range.
  712. """
  713. RangeProduct = get_model('offer', 'RangeProduct')
  714. RangeProduct.objects.filter(range=self, product=product).delete()
  715. def contains_product(self, product): # noqa (too complex (12))
  716. """
  717. Check whether the passed product is part of this range.
  718. """
  719. # Delegate to a proxy class if one is provided
  720. if self.proxy:
  721. return self.proxy.contains_product(product)
  722. excluded_product_ids = self._excluded_product_ids()
  723. if product.id in excluded_product_ids:
  724. return False
  725. if self.includes_all_products:
  726. return True
  727. if product.product_class_id in self._class_ids():
  728. return True
  729. included_product_ids = self._included_product_ids()
  730. # If the product's parent is in the range, the child is automatically included as well
  731. if product.is_child and product.parent.id in included_product_ids:
  732. return True
  733. if product.id in included_product_ids:
  734. return True
  735. test_categories = self.included_categories.all()
  736. if test_categories:
  737. for category in product.get_categories().all():
  738. for test_category in test_categories:
  739. if category == test_category \
  740. or category.is_descendant_of(test_category):
  741. return True
  742. return False
  743. # Shorter alias
  744. contains = contains_product
  745. def __get_pks_and_child_pks(self, queryset):
  746. """
  747. Expects a product queryset; gets the primary keys of the passed
  748. products and their children.
  749. Verbose, but database and memory friendly.
  750. """
  751. # One query to get parent and children; [(4, None), (5, 10), (5, 11)]
  752. pk_tuples_iterable = queryset.values_list('pk', 'children__pk')
  753. # Flatten list without unpacking; [4, None, 5, 10, 5, 11]
  754. flat_iterable = itertools.chain.from_iterable(pk_tuples_iterable)
  755. # Ensure uniqueness and remove None; {4, 5, 10, 11}
  756. return set(flat_iterable) - {None}
  757. def _included_product_ids(self):
  758. if not self.id:
  759. return []
  760. if self.__included_product_ids is None:
  761. self.__included_product_ids = self.__get_pks_and_child_pks(
  762. self.included_products)
  763. return self.__included_product_ids
  764. def _excluded_product_ids(self):
  765. if not self.id:
  766. return []
  767. if self.__excluded_product_ids is None:
  768. self.__excluded_product_ids = self.__get_pks_and_child_pks(
  769. self.excluded_products)
  770. return self.__excluded_product_ids
  771. def _class_ids(self):
  772. if self.__class_ids is None:
  773. self.__class_ids = self.classes.values_list('pk', flat=True)
  774. return self.__class_ids
  775. def _category_ids(self):
  776. if self.__category_ids is None:
  777. category_ids_list = list(
  778. self.included_categories.values_list('pk', flat=True))
  779. for category in self.included_categories.all():
  780. children_ids = category.get_descendants().values_list(
  781. 'pk', flat=True)
  782. category_ids_list.extend(list(children_ids))
  783. self.__category_ids = category_ids_list
  784. return self.__category_ids
  785. def num_products(self):
  786. # Delegate to a proxy class if one is provided
  787. if self.proxy:
  788. return self.proxy.num_products()
  789. if self.includes_all_products:
  790. return None
  791. return self.all_products().count()
  792. def all_products(self):
  793. """
  794. Return a queryset containing all the products in the range
  795. This includes included_products plus the products contained in the
  796. included classes and categories, minus the products in
  797. excluded_products.
  798. """
  799. if self.proxy:
  800. return self.proxy.all_products()
  801. Product = get_model("catalogue", "Product")
  802. if self.includes_all_products:
  803. # Filter out child products
  804. return Product.browsable.all()
  805. return Product.objects.filter(
  806. Q(id__in=self._included_product_ids()) |
  807. Q(product_class_id__in=self._class_ids()) |
  808. Q(productcategory__category_id__in=self._category_ids())
  809. ).exclude(id__in=self._excluded_product_ids())
  810. @property
  811. def is_editable(self):
  812. """
  813. Test whether this product can be edited in the dashboard
  814. """
  815. return not self.proxy_class
  816. class AbstractRangeProduct(models.Model):
  817. """
  818. Allow ordering products inside ranges
  819. Exists to allow customising.
  820. """
  821. range = models.ForeignKey('offer.Range')
  822. product = models.ForeignKey('catalogue.Product')
  823. display_order = models.IntegerField(default=0)
  824. class Meta:
  825. abstract = True
  826. app_label = 'offer'
  827. unique_together = ('range', 'product')
  828. class AbstractRangeProductFileUpload(models.Model):
  829. range = models.ForeignKey('offer.Range', related_name='file_uploads',
  830. verbose_name=_("Range"))
  831. filepath = models.CharField(_("File Path"), max_length=255)
  832. size = models.PositiveIntegerField(_("Size"))
  833. uploaded_by = models.ForeignKey(AUTH_USER_MODEL,
  834. verbose_name=_("Uploaded By"))
  835. date_uploaded = models.DateTimeField(_("Date Uploaded"), auto_now_add=True)
  836. PENDING, FAILED, PROCESSED = 'Pending', 'Failed', 'Processed'
  837. choices = (
  838. (PENDING, PENDING),
  839. (FAILED, FAILED),
  840. (PROCESSED, PROCESSED),
  841. )
  842. status = models.CharField(_("Status"), max_length=32, choices=choices,
  843. default=PENDING)
  844. error_message = models.CharField(_("Error Message"), max_length=255,
  845. blank=True)
  846. # Post-processing audit fields
  847. date_processed = models.DateTimeField(_("Date Processed"), null=True)
  848. num_new_skus = models.PositiveIntegerField(_("Number of New SKUs"),
  849. null=True)
  850. num_unknown_skus = models.PositiveIntegerField(_("Number of Unknown SKUs"),
  851. null=True)
  852. num_duplicate_skus = models.PositiveIntegerField(
  853. _("Number of Duplicate SKUs"), null=True)
  854. class Meta:
  855. abstract = True
  856. app_label = 'offer'
  857. ordering = ('-date_uploaded',)
  858. verbose_name = _("Range Product Uploaded File")
  859. verbose_name_plural = _("Range Product Uploaded Files")
  860. @property
  861. def filename(self):
  862. return os.path.basename(self.filepath)
  863. def mark_as_failed(self, message=None):
  864. self.date_processed = now()
  865. self.error_message = message
  866. self.status = self.FAILED
  867. self.save()
  868. def mark_as_processed(self, num_new, num_unknown, num_duplicate):
  869. self.status = self.PROCESSED
  870. self.date_processed = now()
  871. self.num_new_skus = num_new
  872. self.num_unknown_skus = num_unknown
  873. self.num_duplicate_skus = num_duplicate
  874. self.save()
  875. def was_processing_successful(self):
  876. return self.status == self.PROCESSED
  877. def process(self):
  878. """
  879. Process the file upload and add products to the range
  880. """
  881. all_ids = set(self.extract_ids())
  882. products = self.range.included_products.all()
  883. existing_skus = products.values_list(
  884. 'stockrecords__partner_sku', flat=True)
  885. existing_skus = set(filter(bool, existing_skus))
  886. existing_upcs = products.values_list('upc', flat=True)
  887. existing_upcs = set(filter(bool, existing_upcs))
  888. existing_ids = existing_skus.union(existing_upcs)
  889. new_ids = all_ids - existing_ids
  890. Product = models.get_model('catalogue', 'Product')
  891. products = Product._default_manager.filter(
  892. models.Q(stockrecords__partner_sku__in=new_ids) |
  893. models.Q(upc__in=new_ids))
  894. for product in products:
  895. self.range.add_product(product)
  896. # Processing stats
  897. found_skus = products.values_list(
  898. 'stockrecords__partner_sku', flat=True)
  899. found_skus = set(filter(bool, found_skus))
  900. found_upcs = set(filter(bool, products.values_list('upc', flat=True)))
  901. found_ids = found_skus.union(found_upcs)
  902. missing_ids = new_ids - found_ids
  903. dupes = set(all_ids).intersection(existing_ids)
  904. self.mark_as_processed(products.count(), len(missing_ids), len(dupes))
  905. def extract_ids(self):
  906. """
  907. Extract all SKU- or UPC-like strings from the file
  908. """
  909. for line in open(self.filepath, 'r'):
  910. for id in re.split('[^\w:\.-]', line):
  911. if id:
  912. yield id
  913. def delete_file(self):
  914. os.unlink(self.filepath)