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

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