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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. from decimal import Decimal
  2. import zlib
  3. import datetime
  4. from django.contrib.auth.models import User
  5. from django.db import models
  6. from django.utils.translation import ugettext as _
  7. from django.core.exceptions import ObjectDoesNotExist
  8. from oscar.basket.managers import OpenBasketManager, SavedBasketManager
  9. # Basket statuses
  10. OPEN, MERGED, SAVED, SUBMITTED = ("Open", "Merged", "Saved", "Submitted")
  11. class AbstractBasket(models.Model):
  12. u"""Basket object"""
  13. # Baskets can be anonymously owned (which are merged if the user signs in)
  14. owner = models.ForeignKey('auth.User', related_name='baskets', null=True)
  15. STATUS_CHOICES = (
  16. (OPEN, _("Open - currently active")),
  17. (MERGED, _("Merged - superceded by another basket")),
  18. (SAVED, _("Saved - for items to be purchased later")),
  19. (SUBMITTED, _("Submitted - has been ordered at the checkout")),
  20. )
  21. status = models.CharField(_("Status"), max_length=128, default=OPEN, choices=STATUS_CHOICES)
  22. date_created = models.DateTimeField(auto_now_add=True)
  23. date_merged = models.DateTimeField(null=True, blank=True)
  24. date_submitted = models.DateTimeField(null=True, blank=True)
  25. # Cached queryset of lines
  26. _lines = None
  27. discounts = []
  28. class Meta:
  29. abstract = True
  30. objects = models.Manager()
  31. open = OpenBasketManager()
  32. saved = SavedBasketManager()
  33. def __unicode__(self):
  34. return u"%s basket (owner: %s, lines: %d)" % (self.status, self.owner, self.num_lines)
  35. def all_lines(self):
  36. if not self._lines:
  37. self._lines = self.lines.all()
  38. return self._lines
  39. # ============
  40. # Manipulation
  41. # ============
  42. def flush(self):
  43. u"""Remove all lines from basket."""
  44. self.lines_all().delete()
  45. def add_product(self, item, quantity=1, options=[]):
  46. u"""
  47. Convenience method for adding products to a basket
  48. The 'options' list should contains dicts with keys 'option' and 'value'
  49. which link the relevant product.Option model and string value respectively.
  50. """
  51. line_ref = self._create_line_reference(item, options)
  52. try:
  53. line = self.lines.get(line_reference=line_ref)
  54. line.quantity += quantity
  55. line.save()
  56. except ObjectDoesNotExist:
  57. line = self.lines.create(basket=self, line_reference=line_ref, product=item, quantity=quantity)
  58. for option_dict in options:
  59. line.attributes.create(line=line, option=option_dict['option'], value=option_dict['value'])
  60. def set_discounts(self, discounts):
  61. u"""
  62. Sets the discounts that apply to this basket.
  63. This should be a list of dictionaries
  64. """
  65. self.discounts = discounts
  66. def merge_line(self, line):
  67. u"""
  68. For transferring a line from another basket to this one.
  69. This is used with the "Saved" basket functionality.
  70. """
  71. try:
  72. existing_line = self.lines.get(line_reference=line.line_reference)
  73. # Line already exists - bump its quantity and delete the old
  74. existing_line.quantity += line.quantity
  75. existing_line.save()
  76. line.delete()
  77. except ObjectDoesNotExist:
  78. # Line does not already exist - reassign its basket
  79. line.basket = self
  80. line.save()
  81. def merge(self, basket):
  82. u"""Merges another basket with this one"""
  83. for line_to_merge in basket.all_lines():
  84. self.merge_line(line_to_merge)
  85. basket.status = MERGED
  86. basket.date_merged = datetime.datetime.now()
  87. basket.save()
  88. def set_as_submitted(self):
  89. u"""Mark this basket as submitted."""
  90. self.status = SUBMITTED
  91. self.date_submitted = datetime.datetime.now()
  92. self.save()
  93. # =======
  94. # Helpers
  95. # =======
  96. def _create_line_reference(self, item, options):
  97. u"""
  98. Returns a reference string for a line based on the item
  99. and its options.
  100. """
  101. if not options:
  102. return item.id
  103. return "%d_%s" % (item.id, zlib.crc32(str(options)))
  104. def _get_total(self, property):
  105. u"""
  106. For executing a named method on each line of the basket
  107. and returning the total.
  108. """
  109. total = Decimal('0.00')
  110. for line in self.all_lines():
  111. total += getattr(line, property)
  112. return total
  113. # ==========
  114. # Properties
  115. # ==========
  116. @property
  117. def is_empty(self):
  118. u"""Return bool based on basket having 0 lines"""
  119. return self.num_lines == 0
  120. @property
  121. def total_excl_tax(self):
  122. u"""Return total line price excluding tax"""
  123. return self._get_total('line_price_excl_tax_and_discounts')
  124. @property
  125. def total_tax(self):
  126. u"""Return total tax for a line"""
  127. return self._get_total('line_tax')
  128. @property
  129. def total_incl_tax(self):
  130. u"""Return total price for a line including tax"""
  131. return self._get_total('line_price_incl_tax_and_discounts')
  132. @property
  133. def num_lines(self):
  134. u"""Return number of lines"""
  135. return self.all_lines().count()
  136. @property
  137. def num_items(self):
  138. u"""Return number of items"""
  139. return reduce(lambda num,line: num+line.quantity, self.all_lines(), 0)
  140. class AbstractLine(models.Model):
  141. u"""A line of a basket (product and a quantity)"""
  142. basket = models.ForeignKey('basket.Basket', related_name='lines')
  143. # This is to determine which products belong to the same line
  144. # We can't just use product.id as you can have customised products
  145. # which should be treated as separate lines. Set as a
  146. # SlugField as it is included in the path for certain views.
  147. line_reference = models.SlugField(max_length=128, db_index=True)
  148. product = models.ForeignKey('product.Item', related_name='basket_lines')
  149. quantity = models.PositiveIntegerField(default=1)
  150. # Instance variables used to persist discount information
  151. _discount_field = 'price_excl_tax'
  152. _discount = Decimal('0.00')
  153. _affected_quantity = 0
  154. class Meta:
  155. abstract = True
  156. unique_together = ("basket", "line_reference")
  157. def __unicode__(self):
  158. return u"%s, Product '%s', quantity %d" % (self.basket, self.product, self.quantity)
  159. def save(self, *args, **kwargs):
  160. u"""Saves a line or deletes if it's quanity is 0"""
  161. if self.quantity == 0:
  162. return self.delete(*args, **kwargs)
  163. super(AbstractLine, self).save(*args, **kwargs)
  164. # =============
  165. # Offer methods
  166. # =============
  167. def discount(self, discount_value, affected_quantity):
  168. self._discount += discount_value
  169. self._affected_quantity += affected_quantity
  170. def consume(self, quantity):
  171. self._affected_quantity += quantity
  172. def get_price_breakdown(self):
  173. u"""
  174. Returns a breakdown of line prices after discounts have
  175. been applied.
  176. """
  177. prices = []
  178. if not self.has_discount:
  179. prices.append((self.unit_price_incl_tax, self.unit_price_excl_tax, self.quantity))
  180. else:
  181. # Need to split the discount among the affected quantity
  182. # of products.
  183. item_incl_tax_discount = self._discount / self._affected_quantity
  184. item_excl_tax_discount = item_incl_tax_discount * self._tax_ratio
  185. prices.append((self.unit_price_incl_tax - item_incl_tax_discount,
  186. self.unit_price_excl_tax - item_excl_tax_discount,
  187. self._affected_quantity))
  188. if self.quantity_without_discount:
  189. prices.append((self.unit_price_incl_tax, self.unit_price_excl_tax, self.quantity_without_discount))
  190. return prices
  191. # =======
  192. # Helpers
  193. # =======
  194. def _get_stockrecord_property(self, property):
  195. if not self.product.stockrecord:
  196. return None
  197. else:
  198. return getattr(self.product.stockrecord, property)
  199. @property
  200. def _tax_ratio(self):
  201. return self.unit_price_excl_tax / self.unit_price_incl_tax
  202. # ==========
  203. # Properties
  204. # ==========
  205. @property
  206. def has_discount(self):
  207. return self.quantity > self.quantity_without_discount
  208. @property
  209. def quantity_without_discount(self):
  210. return self.quantity - self._affected_quantity
  211. @property
  212. def discount_value(self):
  213. return self._discount
  214. @property
  215. def unit_price_excl_tax(self):
  216. u"""Return unit price excluding tax"""
  217. return self._get_stockrecord_property('price_excl_tax')
  218. @property
  219. def unit_tax(self):
  220. u"""Return tax of a unit"""
  221. return self._get_stockrecord_property('price_tax')
  222. @property
  223. def unit_price_incl_tax(self):
  224. u"""Return unit price including tax"""
  225. return self._get_stockrecord_property('price_incl_tax')
  226. @property
  227. def line_price_excl_tax(self):
  228. u"""Return line price excluding tax"""
  229. return self.quantity * self.unit_price_excl_tax
  230. @property
  231. def line_price_excl_tax_and_discounts(self):
  232. return self.line_price_excl_tax - self._discount * self._tax_ratio
  233. @property
  234. def line_tax(self):
  235. u"""Return line tax"""
  236. return self.quantity * self.unit_tax
  237. @property
  238. def line_price_incl_tax(self):
  239. u"""Return line price including tax"""
  240. return self.quantity * self.unit_price_incl_tax
  241. @property
  242. def line_price_incl_tax_and_discounts(self):
  243. return self.line_price_incl_tax - self._discount
  244. @property
  245. def description(self):
  246. u"""Return product description"""
  247. d = str(self.product)
  248. ops = []
  249. for attribute in self.attributes.all():
  250. ops.append("%s = '%s'" % (attribute.option.name, attribute.value))
  251. if ops:
  252. d = "%s (%s)" % (d, ", ".join(ops))
  253. return d
  254. class AbstractLineAttribute(models.Model):
  255. u"""An attribute of a basket line"""
  256. line = models.ForeignKey('basket.Line', related_name='attributes')
  257. option = models.ForeignKey('product.option')
  258. value = models.CharField(_("Value"), max_length=255)
  259. class Meta:
  260. abstract = True