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.

abstract_models.py 28KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850
  1. from decimal import Decimal as D
  2. import zlib
  3. from django.db import models
  4. from django.db.models import Sum
  5. from django.conf import settings
  6. from django.utils.encoding import python_2_unicode_compatible
  7. from django.utils.timezone import now
  8. from django.utils.translation import ugettext_lazy as _
  9. from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
  10. from oscar.apps.basket.managers import OpenBasketManager, SavedBasketManager
  11. from oscar.apps.offer import results
  12. from oscar.core.utils import get_default_currency
  13. from oscar.core.compat import AUTH_USER_MODEL
  14. from oscar.templatetags.currency_filters import currency
  15. @python_2_unicode_compatible
  16. class AbstractBasket(models.Model):
  17. """
  18. Basket object
  19. """
  20. # Baskets can be anonymously owned - hence this field is nullable. When a
  21. # anon user signs in, their two baskets are merged.
  22. owner = models.ForeignKey(
  23. AUTH_USER_MODEL, related_name='baskets', null=True,
  24. verbose_name=_("Owner"))
  25. # Basket statuses
  26. # - Frozen is for when a basket is in the process of being submitted
  27. # and we need to prevent any changes to it.
  28. OPEN, MERGED, SAVED, FROZEN, SUBMITTED = (
  29. "Open", "Merged", "Saved", "Frozen", "Submitted")
  30. STATUS_CHOICES = (
  31. (OPEN, _("Open - currently active")),
  32. (MERGED, _("Merged - superceded by another basket")),
  33. (SAVED, _("Saved - for items to be purchased later")),
  34. (FROZEN, _("Frozen - the basket cannot be modified")),
  35. (SUBMITTED, _("Submitted - has been ordered at the checkout")),
  36. )
  37. status = models.CharField(
  38. _("Status"), max_length=128, default=OPEN, choices=STATUS_CHOICES)
  39. # A basket can have many vouchers attached to it. However, it is common
  40. # for sites to only allow one voucher per basket - this will need to be
  41. # enforced in the project's codebase.
  42. vouchers = models.ManyToManyField(
  43. 'voucher.Voucher', null=True, verbose_name=_("Vouchers"), blank=True)
  44. date_created = models.DateTimeField(_("Date created"), auto_now_add=True)
  45. date_merged = models.DateTimeField(_("Date merged"), null=True, blank=True)
  46. date_submitted = models.DateTimeField(_("Date submitted"), null=True,
  47. blank=True)
  48. # Only if a basket is in one of these statuses can it be edited
  49. editable_statuses = (OPEN, SAVED)
  50. class Meta:
  51. abstract = True
  52. app_label = 'basket'
  53. verbose_name = _('Basket')
  54. verbose_name_plural = _('Baskets')
  55. objects = models.Manager()
  56. open = OpenBasketManager()
  57. saved = SavedBasketManager()
  58. def __init__(self, *args, **kwargs):
  59. super(AbstractBasket, self).__init__(*args, **kwargs)
  60. # We keep a cached copy of the basket lines as we refer to them often
  61. # within the same request cycle. Also, applying offers will append
  62. # discount data to the basket lines which isn't persisted to the DB and
  63. # so we want to avoid reloading them as this would drop the discount
  64. # information.
  65. self._lines = None
  66. self.offer_applications = results.OfferApplications()
  67. def __str__(self):
  68. return _(
  69. u"%(status)s basket (owner: %(owner)s, lines: %(num_lines)d)") \
  70. % {'status': self.status,
  71. 'owner': self.owner,
  72. 'num_lines': self.num_lines}
  73. # ========
  74. # Strategy
  75. # ========
  76. @property
  77. def has_strategy(self):
  78. return hasattr(self, '_strategy')
  79. def _get_strategy(self):
  80. if not self.has_strategy:
  81. raise RuntimeError(
  82. "No strategy class has been assigned to this basket. "
  83. "This is normally assigned to the incoming request in "
  84. "oscar.apps.basket.middleware.BasketMiddleware. "
  85. "Since it is missing, you must be doing something different. "
  86. "Ensure that a strategy instance is assigned to the basket!"
  87. )
  88. return self._strategy
  89. def _set_strategy(self, strategy):
  90. self._strategy = strategy
  91. strategy = property(_get_strategy, _set_strategy)
  92. def all_lines(self):
  93. """
  94. Return a cached set of basket lines.
  95. This is important for offers as they alter the line models and you
  96. don't want to reload them from the DB as that information would be
  97. lost.
  98. """
  99. if self.id is None:
  100. return self.lines.none()
  101. if self._lines is None:
  102. self._lines = (
  103. self.lines
  104. .select_related('product', 'stockrecord')
  105. .prefetch_related(
  106. 'attributes', 'product__images'))
  107. return self._lines
  108. def is_quantity_allowed(self, qty):
  109. """
  110. Test whether the passed quantity of items can be added to the basket
  111. """
  112. # We enfore a max threshold to prevent a DOS attack via the offers
  113. # system.
  114. basket_threshold = settings.OSCAR_MAX_BASKET_QUANTITY_THRESHOLD
  115. if basket_threshold:
  116. total_basket_quantity = self.num_items
  117. max_allowed = basket_threshold - total_basket_quantity
  118. if qty > max_allowed:
  119. return False, _(
  120. "Due to technical limitations we are not able "
  121. "to ship more than %(threshold)d items in one order.") \
  122. % {'threshold': basket_threshold}
  123. return True, None
  124. # ============
  125. # Manipulation
  126. # ============
  127. def flush(self):
  128. """
  129. Remove all lines from basket.
  130. """
  131. if self.status == self.FROZEN:
  132. raise PermissionDenied("A frozen basket cannot be flushed")
  133. self.lines.all().delete()
  134. self._lines = None
  135. def add_product(self, product, quantity=1, options=None):
  136. """
  137. Add a product to the basket
  138. 'stock_info' is the price and availability data returned from
  139. a partner strategy class.
  140. The 'options' list should contains dicts with keys 'option' and 'value'
  141. which link the relevant product.Option model and string value
  142. respectively.
  143. Returns (line, created).
  144. line: the matching basket line
  145. created: whether the line was created or updated
  146. """
  147. if options is None:
  148. options = []
  149. if not self.id:
  150. self.save()
  151. # Ensure that all lines are the same currency
  152. price_currency = self.currency
  153. stock_info = self.strategy.fetch_for_product(product)
  154. if price_currency and stock_info.price.currency != price_currency:
  155. raise ValueError((
  156. "Basket lines must all have the same currency. Proposed "
  157. "line has currency %s, while basket has currency %s")
  158. % (stock_info.price.currency, price_currency))
  159. if stock_info.stockrecord is None:
  160. raise ValueError((
  161. "Basket lines must all have stock records. Strategy hasn't "
  162. "found any stock record for product %s") % product)
  163. # Line reference is used to distinguish between variations of the same
  164. # product (eg T-shirts with different personalisations)
  165. line_ref = self._create_line_reference(
  166. product, stock_info.stockrecord, options)
  167. # Determine price to store (if one exists). It is only stored for
  168. # audit and sometimes caching.
  169. defaults = {
  170. 'quantity': quantity,
  171. 'price_excl_tax': stock_info.price.excl_tax,
  172. 'price_currency': stock_info.price.currency,
  173. }
  174. if stock_info.price.is_tax_known:
  175. defaults['price_incl_tax'] = stock_info.price.incl_tax
  176. line, created = self.lines.get_or_create(
  177. line_reference=line_ref,
  178. product=product,
  179. stockrecord=stock_info.stockrecord,
  180. defaults=defaults)
  181. if created:
  182. for option_dict in options:
  183. line.attributes.create(option=option_dict['option'],
  184. value=option_dict['value'])
  185. else:
  186. line.quantity += quantity
  187. line.save()
  188. self.reset_offer_applications()
  189. # Returning the line is useful when overriding this method.
  190. return line, created
  191. add_product.alters_data = True
  192. add = add_product
  193. def applied_offers(self):
  194. """
  195. Return a dict of offers successfully applied to the basket.
  196. This is used to compare offers before and after a basket change to see
  197. if there is a difference.
  198. """
  199. return self.offer_applications.offers
  200. def reset_offer_applications(self):
  201. """
  202. Remove any discounts so they get recalculated
  203. """
  204. self.offer_applications = results.OfferApplications()
  205. self._lines = None
  206. def merge_line(self, line, add_quantities=True):
  207. """
  208. For transferring a line from another basket to this one.
  209. This is used with the "Saved" basket functionality.
  210. """
  211. try:
  212. existing_line = self.lines.get(line_reference=line.line_reference)
  213. except ObjectDoesNotExist:
  214. # Line does not already exist - reassign its basket
  215. line.basket = self
  216. line.save()
  217. else:
  218. # Line already exists - assume the max quantity is correct and
  219. # delete the old
  220. if add_quantities:
  221. existing_line.quantity += line.quantity
  222. else:
  223. existing_line.quantity = max(existing_line.quantity,
  224. line.quantity)
  225. existing_line.save()
  226. line.delete()
  227. finally:
  228. self._lines = None
  229. merge_line.alters_data = True
  230. def merge(self, basket, add_quantities=True):
  231. """
  232. Merges another basket with this one.
  233. :basket: The basket to merge into this one.
  234. :add_quantities: Whether to add line quantities when they are merged.
  235. """
  236. # Use basket.lines.all instead of all_lines as this function is called
  237. # before a strategy has been assigned.
  238. for line_to_merge in basket.lines.all():
  239. self.merge_line(line_to_merge, add_quantities)
  240. basket.status = self.MERGED
  241. basket.date_merged = now()
  242. basket._lines = None
  243. basket.save()
  244. # Ensure all vouchers are moved to the new basket
  245. for voucher in basket.vouchers.all():
  246. basket.vouchers.remove(voucher)
  247. self.vouchers.add(voucher)
  248. merge.alters_data = True
  249. def freeze(self):
  250. """
  251. Freezes the basket so it cannot be modified.
  252. """
  253. self.status = self.FROZEN
  254. self.save()
  255. freeze.alters_data = True
  256. def thaw(self):
  257. """
  258. Unfreezes a basket so it can be modified again
  259. """
  260. self.status = self.OPEN
  261. self.save()
  262. thaw.alters_data = True
  263. def submit(self):
  264. """
  265. Mark this basket as submitted
  266. """
  267. self.status = self.SUBMITTED
  268. self.date_submitted = now()
  269. self.save()
  270. submit.alters_data = True
  271. # Kept for backwards compatibility
  272. set_as_submitted = submit
  273. def is_shipping_required(self):
  274. """
  275. Test whether the basket contains physical products that require
  276. shipping.
  277. """
  278. for line in self.all_lines():
  279. if line.product.is_shipping_required:
  280. return True
  281. return False
  282. # =======
  283. # Helpers
  284. # =======
  285. def _create_line_reference(self, product, stockrecord, options):
  286. """
  287. Returns a reference string for a line based on the item
  288. and its options.
  289. """
  290. base = '%s_%s' % (product.id, stockrecord.id)
  291. if not options:
  292. return base
  293. return "%s_%s" % (base, zlib.crc32(repr(options).encode('utf8')))
  294. def _get_total(self, property):
  295. """
  296. For executing a named method on each line of the basket
  297. and returning the total.
  298. """
  299. total = D('0.00')
  300. for line in self.all_lines():
  301. try:
  302. total += getattr(line, property)
  303. except ObjectDoesNotExist:
  304. # Handle situation where the product may have been deleted
  305. pass
  306. return total
  307. # ==========
  308. # Properties
  309. # ==========
  310. @property
  311. def is_empty(self):
  312. """
  313. Test if this basket is empty
  314. """
  315. return self.id is None or self.num_lines == 0
  316. @property
  317. def is_tax_known(self):
  318. """
  319. Test if tax values are known for this basket
  320. """
  321. return all([line.is_tax_known for line in self.all_lines()])
  322. @property
  323. def total_excl_tax(self):
  324. """
  325. Return total line price excluding tax
  326. """
  327. return self._get_total('line_price_excl_tax_incl_discounts')
  328. @property
  329. def total_tax(self):
  330. """Return total tax for a line"""
  331. return self._get_total('line_tax')
  332. @property
  333. def total_incl_tax(self):
  334. """
  335. Return total price inclusive of tax and discounts
  336. """
  337. return self._get_total('line_price_incl_tax_incl_discounts')
  338. @property
  339. def total_incl_tax_excl_discounts(self):
  340. """
  341. Return total price inclusive of tax but exclusive discounts
  342. """
  343. return self._get_total('line_price_incl_tax')
  344. @property
  345. def total_discount(self):
  346. return self._get_total('discount_value')
  347. @property
  348. def offer_discounts(self):
  349. """
  350. Return basket discounts from non-voucher sources. Does not include
  351. shipping discounts.
  352. """
  353. return self.offer_applications.offer_discounts
  354. @property
  355. def voucher_discounts(self):
  356. """
  357. Return discounts from vouchers
  358. """
  359. return self.offer_applications.voucher_discounts
  360. @property
  361. def has_shipping_discounts(self):
  362. return len(self.shipping_discounts) > 0
  363. @property
  364. def shipping_discounts(self):
  365. """
  366. Return discounts from vouchers
  367. """
  368. return self.offer_applications.shipping_discounts
  369. @property
  370. def post_order_actions(self):
  371. """
  372. Return discounts from vouchers
  373. """
  374. return self.offer_applications.post_order_actions
  375. @property
  376. def grouped_voucher_discounts(self):
  377. """
  378. Return discounts from vouchers but grouped so that a voucher which
  379. links to multiple offers is aggregated into one object.
  380. """
  381. return self.offer_applications.grouped_voucher_discounts
  382. @property
  383. def total_excl_tax_excl_discounts(self):
  384. """
  385. Return total price excluding tax and discounts
  386. """
  387. return self._get_total('line_price_excl_tax')
  388. @property
  389. def num_lines(self):
  390. """Return number of lines"""
  391. return self.all_lines().count()
  392. @property
  393. def num_items(self):
  394. """Return number of items"""
  395. return sum(line.quantity for line in self.lines.all())
  396. @property
  397. def num_items_without_discount(self):
  398. num = 0
  399. for line in self.all_lines():
  400. num += line.quantity_without_discount
  401. return num
  402. @property
  403. def num_items_with_discount(self):
  404. num = 0
  405. for line in self.all_lines():
  406. num += line.quantity_with_discount
  407. return num
  408. @property
  409. def time_before_submit(self):
  410. if not self.date_submitted:
  411. return None
  412. return self.date_submitted - self.date_created
  413. @property
  414. def time_since_creation(self, test_datetime=None):
  415. if not test_datetime:
  416. test_datetime = now()
  417. return test_datetime - self.date_created
  418. @property
  419. def contains_a_voucher(self):
  420. if not self.id:
  421. return False
  422. return self.vouchers.exists()
  423. @property
  424. def is_submitted(self):
  425. return self.status == self.SUBMITTED
  426. @property
  427. def can_be_edited(self):
  428. """
  429. Test if a basket can be edited
  430. """
  431. return self.status in self.editable_statuses
  432. @property
  433. def currency(self):
  434. # Since all lines should have the same currency, return the currency of
  435. # the first one found.
  436. for line in self.all_lines():
  437. return line.price_currency
  438. # =============
  439. # Query methods
  440. # =============
  441. def contains_voucher(self, code):
  442. """
  443. Test whether the basket contains a voucher with a given code
  444. """
  445. if self.id is None:
  446. return False
  447. try:
  448. self.vouchers.get(code=code)
  449. except ObjectDoesNotExist:
  450. return False
  451. else:
  452. return True
  453. def product_quantity(self, product):
  454. """
  455. Return the quantity of a product in the basket
  456. The basket can contain multiple lines with the same product, but
  457. different options and stockrecords. Those quantities are summed up.
  458. """
  459. matching_lines = self.lines.filter(product=product)
  460. quantity = matching_lines.aggregate(Sum('quantity'))['quantity__sum']
  461. return quantity or 0
  462. def line_quantity(self, product, stockrecord, options=None):
  463. """
  464. Return the current quantity of a specific product and options
  465. """
  466. ref = self._create_line_reference(product, stockrecord, options)
  467. try:
  468. return self.lines.get(line_reference=ref).quantity
  469. except ObjectDoesNotExist:
  470. return 0
  471. @python_2_unicode_compatible
  472. class AbstractLine(models.Model):
  473. """
  474. A line of a basket (product and a quantity)
  475. """
  476. basket = models.ForeignKey('basket.Basket', related_name='lines',
  477. verbose_name=_("Basket"))
  478. # This is to determine which products belong to the same line
  479. # We can't just use product.id as you can have customised products
  480. # which should be treated as separate lines. Set as a
  481. # SlugField as it is included in the path for certain views.
  482. line_reference = models.SlugField(
  483. _("Line Reference"), max_length=128, db_index=True)
  484. product = models.ForeignKey(
  485. 'catalogue.Product', related_name='basket_lines',
  486. verbose_name=_("Product"))
  487. # We store the stockrecord that should be used to fulfil this line.
  488. stockrecord = models.ForeignKey(
  489. 'partner.StockRecord', related_name='basket_lines')
  490. quantity = models.PositiveIntegerField(_('Quantity'), default=1)
  491. # We store the unit price incl tax of the product when it is first added to
  492. # the basket. This allows us to tell if a product has changed price since
  493. # a person first added it to their basket.
  494. price_currency = models.CharField(
  495. _("Currency"), max_length=12, default=get_default_currency)
  496. price_excl_tax = models.DecimalField(
  497. _('Price excl. Tax'), decimal_places=2, max_digits=12,
  498. null=True)
  499. price_incl_tax = models.DecimalField(
  500. _('Price incl. Tax'), decimal_places=2, max_digits=12, null=True)
  501. # Track date of first addition
  502. date_created = models.DateTimeField(_("Date Created"), auto_now_add=True)
  503. def __init__(self, *args, **kwargs):
  504. super(AbstractLine, self).__init__(*args, **kwargs)
  505. # Instance variables used to persist discount information
  506. self._discount_excl_tax = D('0.00')
  507. self._discount_incl_tax = D('0.00')
  508. self._affected_quantity = 0
  509. class Meta:
  510. abstract = True
  511. app_label = 'basket'
  512. unique_together = ("basket", "line_reference")
  513. verbose_name = _('Basket line')
  514. verbose_name_plural = _('Basket lines')
  515. def __str__(self):
  516. return _(
  517. u"Basket #%(basket_id)d, Product #%(product_id)d, quantity"
  518. u" %(quantity)d") % {'basket_id': self.basket.pk,
  519. 'product_id': self.product.pk,
  520. 'quantity': self.quantity}
  521. def save(self, *args, **kwargs):
  522. if not self.basket.can_be_edited:
  523. raise PermissionDenied(
  524. _("You cannot modify a %s basket") % (
  525. self.basket.status.lower(),))
  526. return super(AbstractLine, self).save(*args, **kwargs)
  527. # =============
  528. # Offer methods
  529. # =============
  530. def clear_discount(self):
  531. """
  532. Remove any discounts from this line.
  533. """
  534. self._discount_excl_tax = D('0.00')
  535. self._discount_incl_tax = D('0.00')
  536. self._affected_quantity = 0
  537. def discount(self, discount_value, affected_quantity, incl_tax=True):
  538. """
  539. Apply a discount to this line
  540. """
  541. if incl_tax:
  542. if self._discount_excl_tax > 0:
  543. raise RuntimeError(
  544. "Attempting to discount the tax-inclusive price of a line "
  545. "when tax-exclusive discounts are already applied")
  546. self._discount_incl_tax += discount_value
  547. else:
  548. if self._discount_incl_tax > 0:
  549. raise RuntimeError(
  550. "Attempting to discount the tax-exclusive price of a line "
  551. "when tax-inclusive discounts are already applied")
  552. self._discount_excl_tax += discount_value
  553. self._affected_quantity += int(affected_quantity)
  554. def consume(self, quantity):
  555. """
  556. Mark all or part of the line as 'consumed'
  557. Consumed items are no longer available to be used in offers.
  558. """
  559. if quantity > self.quantity - self._affected_quantity:
  560. inc = self.quantity - self._affected_quantity
  561. else:
  562. inc = quantity
  563. self._affected_quantity += int(inc)
  564. def get_price_breakdown(self):
  565. """
  566. Return a breakdown of line prices after discounts have been applied.
  567. Returns a list of (unit_price_incl_tax, unit_price_excl_tax, quantity)
  568. tuples.
  569. """
  570. if not self.is_tax_known:
  571. raise RuntimeError("A price breakdown can only be determined "
  572. "when taxes are known")
  573. prices = []
  574. if not self.discount_value:
  575. prices.append((self.unit_price_incl_tax, self.unit_price_excl_tax,
  576. self.quantity))
  577. else:
  578. # Need to split the discount among the affected quantity
  579. # of products.
  580. item_incl_tax_discount = (
  581. self.discount_value / int(self._affected_quantity))
  582. item_excl_tax_discount = item_incl_tax_discount * self._tax_ratio
  583. item_excl_tax_discount = item_excl_tax_discount.quantize(D('0.01'))
  584. prices.append((self.unit_price_incl_tax - item_incl_tax_discount,
  585. self.unit_price_excl_tax - item_excl_tax_discount,
  586. self._affected_quantity))
  587. if self.quantity_without_discount:
  588. prices.append((self.unit_price_incl_tax,
  589. self.unit_price_excl_tax,
  590. self.quantity_without_discount))
  591. return prices
  592. # =======
  593. # Helpers
  594. # =======
  595. @property
  596. def _tax_ratio(self):
  597. if not self.unit_price_incl_tax:
  598. return 0
  599. return self.unit_price_excl_tax / self.unit_price_incl_tax
  600. # ==========
  601. # Properties
  602. # ==========
  603. @property
  604. def has_discount(self):
  605. return self.quantity > self.quantity_without_discount
  606. @property
  607. def quantity_with_discount(self):
  608. return self._affected_quantity
  609. @property
  610. def quantity_without_discount(self):
  611. return int(self.quantity - self._affected_quantity)
  612. @property
  613. def is_available_for_discount(self):
  614. return self.quantity_without_discount > 0
  615. @property
  616. def discount_value(self):
  617. # Only one of the incl- and excl- discounts should be non-zero
  618. return max(self._discount_incl_tax, self._discount_excl_tax)
  619. @property
  620. def purchase_info(self):
  621. """
  622. Return the stock/price info
  623. """
  624. if not hasattr(self, '_info'):
  625. # Cache the PurchaseInfo instance.
  626. self._info = self.basket.strategy.fetch_for_product(
  627. self.product, self.stockrecord)
  628. return self._info
  629. @property
  630. def is_tax_known(self):
  631. return self.purchase_info.price.is_tax_known
  632. @property
  633. def unit_effective_price(self):
  634. """
  635. The price to use for offer calculations
  636. """
  637. return self.purchase_info.price.effective_price
  638. @property
  639. def unit_price_excl_tax(self):
  640. return self.purchase_info.price.excl_tax
  641. @property
  642. def unit_price_incl_tax(self):
  643. return self.purchase_info.price.incl_tax
  644. @property
  645. def unit_tax(self):
  646. return self.purchase_info.price.tax
  647. @property
  648. def line_price_excl_tax(self):
  649. return self.quantity * self.unit_price_excl_tax
  650. @property
  651. def line_price_excl_tax_incl_discounts(self):
  652. if self._discount_excl_tax:
  653. return self.line_price_excl_tax - self._discount_excl_tax
  654. if self._discount_incl_tax:
  655. # This is a tricky situation. We know the discount as calculated
  656. # against tax inclusive prices but we need to guess how much of the
  657. # discount applies to tax-exclusive prices. We do this by
  658. # assuming a linear tax and scaling down the original discount.
  659. return self.line_price_excl_tax \
  660. - self._tax_ratio * self._discount_incl_tax
  661. return self.line_price_excl_tax
  662. @property
  663. def line_price_incl_tax_incl_discounts(self):
  664. # We use whichever discount value is set. If the discount value was
  665. # calculated against the tax-exclusive prices, then the line price
  666. # including tax
  667. return self.line_price_incl_tax - self.discount_value
  668. @property
  669. def line_tax(self):
  670. return self.quantity * self.unit_tax
  671. @property
  672. def line_price_incl_tax(self):
  673. return self.quantity * self.unit_price_incl_tax
  674. @property
  675. def description(self):
  676. d = str(self.product)
  677. ops = []
  678. for attribute in self.attributes.all():
  679. ops.append("%s = '%s'" % (attribute.option.name, attribute.value))
  680. if ops:
  681. d = "%s (%s)" % (d.decode('utf-8'), ", ".join(ops))
  682. return d
  683. def get_warning(self):
  684. """
  685. Return a warning message about this basket line if one is applicable
  686. This could be things like the price has changed
  687. """
  688. if not self.stockrecord:
  689. msg = u"'%(product)s' is no longer available"
  690. return _(msg) % {'product': self.product.get_title()}
  691. if not self.price_incl_tax:
  692. return
  693. if not self.purchase_info.price.is_tax_known:
  694. return
  695. # Compare current price to price when added to basket
  696. current_price_incl_tax = self.purchase_info.price.incl_tax
  697. if current_price_incl_tax != self.price_incl_tax:
  698. product_prices = {
  699. 'product': self.product.get_title(),
  700. 'old_price': currency(self.price_incl_tax),
  701. 'new_price': currency(current_price_incl_tax)
  702. }
  703. if current_price_incl_tax > self.price_incl_tax:
  704. warning = _("The price of '%(product)s' has increased from"
  705. " %(old_price)s to %(new_price)s since you added"
  706. " it to your basket")
  707. return warning % product_prices
  708. else:
  709. warning = _("The price of '%(product)s' has decreased from"
  710. " %(old_price)s to %(new_price)s since you added"
  711. " it to your basket")
  712. return warning % product_prices
  713. class AbstractLineAttribute(models.Model):
  714. """
  715. An attribute of a basket line
  716. """
  717. line = models.ForeignKey('basket.Line', related_name='attributes',
  718. verbose_name=_("Line"))
  719. option = models.ForeignKey('catalogue.Option', verbose_name=_("Option"))
  720. value = models.CharField(_("Value"), max_length=255)
  721. class Meta:
  722. abstract = True
  723. app_label = 'basket'
  724. verbose_name = _('Line attribute')
  725. verbose_name_plural = _('Line attributes')