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

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