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

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