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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  1. from decimal import Decimal as D
  2. from django.conf import settings
  3. from django.db import models
  4. from django.utils.translation import ugettext_lazy as _
  5. from django.utils.importlib import import_module as django_import_module
  6. from oscar.core.loading import get_class
  7. DefaultWrapper = get_class('partner.wrappers', 'DefaultWrapper')
  8. # Cache the partners for quicklookups
  9. default_wrapper = DefaultWrapper()
  10. partner_wrappers = {}
  11. for partner, class_str in settings.OSCAR_PARTNER_WRAPPERS.items():
  12. bits = class_str.split('.')
  13. class_name = bits.pop()
  14. module_str = '.'.join(bits)
  15. module = django_import_module(module_str)
  16. partner_wrappers[partner] = getattr(module, class_name)()
  17. def get_partner_wrapper(partner_name):
  18. """
  19. Returns the appropriate partner wrapper given the partner name
  20. """
  21. return partner_wrappers.get(partner_name, default_wrapper)
  22. class AbstractPartner(models.Model):
  23. """
  24. Fulfillment partner
  25. """
  26. name = models.CharField(max_length=128, unique=True)
  27. # A partner can have users assigned to it. These can be used
  28. # to provide authentication for webservices etc.
  29. users = models.ManyToManyField('auth.User', related_name="partners", blank=True, null=True)
  30. class Meta:
  31. verbose_name_plural = 'Fulfillment partners'
  32. abstract = True
  33. permissions = (
  34. ("can_edit_stock_records", "Can edit stock records"),
  35. ("can_view_stock_records", "Can view stock records"),
  36. ("can_edit_product_range", "Can edit product range"),
  37. ("can_view_product_range", "Can view product range"),
  38. ("can_edit_order_lines", "Can edit order lines"),
  39. ("can_view_order_lines", "Can view order lines"),
  40. )
  41. def __unicode__(self):
  42. return self.name
  43. class AbstractStockRecord(models.Model):
  44. """
  45. A basic stock record.
  46. This links a product to a partner, together with price and availability
  47. information. Most projects will need to subclass this object to add custom
  48. fields such as lead_time, report_code, min_quantity.
  49. We deliberately don't store tax information to allow each project
  50. to subclass this model and put its own fields for convey tax.
  51. """
  52. product = models.OneToOneField('catalogue.Product', related_name="stockrecord")
  53. partner = models.ForeignKey('partner.Partner')
  54. # The fulfilment partner will often have their own SKU for a product, which
  55. # we store here.
  56. partner_sku = models.CharField(_("Partner SKU"), max_length=128)
  57. # Price info:
  58. price_currency = models.CharField(max_length=12, default=settings.OSCAR_DEFAULT_CURRENCY)
  59. # This is the base price for calculations - tax should be applied
  60. # by the appropriate method. We don't store it here as its calculation is
  61. # highly domain-specific. It is NULLable because some items don't have a fixed
  62. # price.
  63. price_excl_tax = models.DecimalField(decimal_places=2, max_digits=12, blank=True, null=True)
  64. # Retail price for this item
  65. price_retail = models.DecimalField(decimal_places=2, max_digits=12, blank=True, null=True)
  66. # Cost price is optional as not all partners supply it
  67. cost_price = models.DecimalField(decimal_places=2, max_digits=12, blank=True, null=True)
  68. # Stock level information
  69. num_in_stock = models.PositiveIntegerField(default=0, blank=True, null=True)
  70. # Threshold for low-stock alerts
  71. low_stock_threshold = models.PositiveIntegerField(blank=True, null=True)
  72. # The amount of stock allocated to orders but not fed back to the master
  73. # stock system. A typical stock update process will set the num_in_stock
  74. # variable to a new value and reset num_allocated to zero
  75. num_allocated = models.IntegerField(default=0, blank=True, null=True)
  76. # Date information
  77. date_created = models.DateTimeField(auto_now_add=True)
  78. date_updated = models.DateTimeField(auto_now=True, db_index=True)
  79. class Meta:
  80. abstract = True
  81. unique_together = ('partner', 'partner_sku')
  82. # 2-stage stock management model
  83. def allocate(self, quantity):
  84. """
  85. Record a stock allocation.
  86. This normally happens when a product is bought at checkout. When the
  87. product is actually shipped, then we 'consume' the allocation.
  88. """
  89. self.num_allocated = int(self.num_allocated)
  90. self.num_allocated += quantity
  91. self.save()
  92. def consume_allocation(self, quantity):
  93. """
  94. Consume a previous allocation
  95. This is used when an item is shipped. We remove the original allocation
  96. and adjust the number in stock accordingly
  97. """
  98. if quantity > self.num_allocated:
  99. raise ValueError('No more than %d units can be consumed' % self.num_allocated)
  100. self.num_allocated -= quantity
  101. self.num_in_stock -= quantity
  102. self.save()
  103. def cancel_allocation(self, quantity):
  104. if quantity > self.num_allocated:
  105. raise ValueError('No more than %d units can be cancelled' % self.num_allocated)
  106. self.num_allocated -= quantity
  107. self.save()
  108. @property
  109. def net_stock_level(self):
  110. """
  111. Return the effective number in stock. This is correct property to show
  112. the customer, not the num_in_stock field as that doesn't account for
  113. allocations.
  114. """
  115. if self.num_in_stock is None:
  116. return 0
  117. if self.num_allocated is None:
  118. return self.num_in_stock
  119. return self.num_in_stock - self.num_allocated
  120. def set_discount_price(self, price):
  121. """
  122. A setter method for setting a new price.
  123. This is called from within the "discount" app, which is responsible
  124. for applying fixed-discount offers to products. We use a setter method
  125. so that this behaviour can be customised in projects.
  126. """
  127. self.price_excl_tax = price
  128. self.save()
  129. # Price retrieval methods - these default to no tax being applicable
  130. # These are intended to be overridden.
  131. @property
  132. def is_available_to_buy(self):
  133. """
  134. Return whether this stockrecord allows the product to be purchased
  135. """
  136. return get_partner_wrapper(self.partner.name).is_available_to_buy(self)
  137. def is_purchase_permitted(self, user=None, quantity=1):
  138. """
  139. Return whether this stockrecord allows the product to be purchased by a
  140. specific user and quantity
  141. """
  142. return get_partner_wrapper(self.partner.name).is_purchase_permitted(self, user, quantity)
  143. @property
  144. def is_below_threshold(self):
  145. if self.low_stock_threshold is None:
  146. return False
  147. return self.net_stock_level < self.low_stock_threshold
  148. @property
  149. def availability_code(self):
  150. """
  151. Return an item's availability as a code for use in CSS
  152. """
  153. return get_partner_wrapper(self.partner.name).availability_code(self)
  154. @property
  155. def availability(self):
  156. """
  157. Return an item's availability as a string
  158. """
  159. return get_partner_wrapper(self.partner.name).availability(self)
  160. @property
  161. def dispatch_date(self):
  162. """
  163. Return the estimated dispatch date for a line
  164. """
  165. return get_partner_wrapper(self.partner.name).dispatch_date(self)
  166. @property
  167. def lead_time(self):
  168. return get_partner_wrapper(self.partner.name).lead_time(self)
  169. @property
  170. def price_incl_tax(self):
  171. """
  172. Return a product's price including tax.
  173. This defaults to the price_excl_tax as tax calculations are
  174. domain specific. This class needs to be subclassed and tax logic
  175. added to this method.
  176. """
  177. if self.price_excl_tax is None:
  178. return D('0.00')
  179. return self.price_excl_tax + self.price_tax
  180. @property
  181. def price_tax(self):
  182. """
  183. Return a product's tax value
  184. """
  185. return get_partner_wrapper(self.partner.name).calculate_tax(self)
  186. def __unicode__(self):
  187. if self.partner_sku:
  188. return "%s (%s): %s" % (self.partner.name, self.partner_sku, self.product.title)
  189. else:
  190. return "%s: %s" % (self.partner.name, self.product.title)
  191. class AbstractStockAlert(models.Model):
  192. stockrecord = models.ForeignKey('partner.StockRecord', related_name='alerts')
  193. threshold = models.PositiveIntegerField()
  194. OPEN, CLOSED = 'Open', 'Closed'
  195. status_choices = (
  196. (OPEN, _('Open')),
  197. (CLOSED, _('Closed')),
  198. )
  199. status = models.CharField(max_length=128, default=OPEN, choices=status_choices)
  200. date_created = models.DateTimeField(auto_now_add=True)
  201. date_closed = models.DateTimeField(blank=True, null=True)
  202. def close(self):
  203. self.status = self.CLOSED
  204. self.save()
  205. def __unicode__(self):
  206. return u'<stockalert for "%s" status %s>' % (self.stockrecord,
  207. self.status)
  208. class Meta:
  209. ordering = ('-date_created',)