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

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