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

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