Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. from decimal import Decimal as D
  2. import warnings
  3. from django.db import models
  4. from django.conf import settings
  5. from django.db.models import get_model
  6. from django.utils.translation import ugettext_lazy as _
  7. from django.utils.importlib import import_module as django_import_module
  8. from oscar.core.compat import AUTH_USER_MODEL
  9. from oscar.core.utils import slugify
  10. from oscar.core.loading import get_class
  11. from oscar.apps.partner.exceptions import InvalidStockAdjustment
  12. DefaultWrapper = get_class('partner.wrappers', 'DefaultWrapper')
  13. # Cache dict of partner_id => availability wrapper instance
  14. partner_wrappers = None
  15. default_wrapper = DefaultWrapper()
  16. def get_partner_wrapper(partner_id):
  17. """
  18. Returns the appropriate partner wrapper given the partner's PK
  19. """
  20. if partner_wrappers is None:
  21. _load_partner_wrappers()
  22. return partner_wrappers.get(partner_id, default_wrapper)
  23. def _load_partner_wrappers():
  24. # Prime cache of partner wrapper dict
  25. global partner_wrappers
  26. partner_wrappers = {}
  27. Partner = get_model('partner', 'Partner')
  28. for code, class_str in settings.OSCAR_PARTNER_WRAPPERS.items():
  29. try:
  30. partner = Partner.objects.get(code=code)
  31. except Partner.DoesNotExist:
  32. continue
  33. else:
  34. module_path, klass = class_str.rsplit('.', 1)
  35. module = django_import_module(module_path)
  36. partner_wrappers[partner.id] = getattr(module, klass)()
  37. class AbstractPartner(models.Model):
  38. """
  39. A fulfillment partner. An individual or company who can fulfil products.
  40. E.g. for physical goods, somebody with a warehouse and means of delivery.
  41. Creating one or more instances of the Partner model is a required step in
  42. setting up an Oscar deployment. Many Oscar deployments will only have one
  43. fulfillment partner.
  44. """
  45. code = models.SlugField(_("Code"), max_length=128, unique=True)
  46. name = models.CharField(_("Name"), max_length=128, null=True, blank=True)
  47. #: A partner can have users assigned to it. These can be used
  48. #: to provide authentication for webservices etc.
  49. users = models.ManyToManyField(
  50. AUTH_USER_MODEL, related_name="partners",
  51. blank=True, null=True, verbose_name=_("Users"))
  52. @property
  53. def display_name(self):
  54. if not self.name:
  55. return self.code
  56. return self.name
  57. def save(self, *args, **kwargs):
  58. if not self.code:
  59. self.code = slugify(self.name)
  60. super(AbstractPartner, self).save(*args, **kwargs)
  61. class Meta:
  62. verbose_name = _('Fulfillment partner')
  63. verbose_name_plural = _('Fulfillment partners')
  64. abstract = True
  65. permissions = (
  66. ("can_edit_stock_records", _("Can edit stock records")),
  67. ("can_view_stock_records", _("Can view stock records")),
  68. ("can_edit_product_range", _("Can edit product range")),
  69. ("can_view_product_range", _("Can view product range")),
  70. ("can_edit_order_lines", _("Can edit order lines")),
  71. ("can_view_order_lines", _("Can view order lines"))
  72. )
  73. def __unicode__(self):
  74. return self.name
  75. class AbstractStockRecord(models.Model):
  76. """
  77. A stock record.
  78. This records information about a product from a fulfilment partner, such as
  79. their SKU, the number they have in stock and price information.
  80. Stockrecords are used by 'strategies' to determine availability and pricing
  81. information for the customer.
  82. """
  83. product = models.ForeignKey(
  84. 'catalogue.Product', related_name="stockrecords",
  85. verbose_name=_("Product"))
  86. partner = models.ForeignKey(
  87. 'partner.Partner', verbose_name=_("Partner"),
  88. related_name='stockrecords')
  89. #: The fulfilment partner will often have their own SKU for a product,
  90. #: which we store here. This will sometimes be the same the product's UPC
  91. #: but not always. It should be unique per partner.
  92. #: See also http://en.wikipedia.org/wiki/Stock-keeping_unit
  93. partner_sku = models.CharField(_("Partner SKU"), max_length=128)
  94. # Price info:
  95. price_currency = models.CharField(
  96. _("Currency"), max_length=12, default=settings.OSCAR_DEFAULT_CURRENCY)
  97. # This is the base price for calculations - tax should be applied by the
  98. # appropriate method. We don't store tax here as its calculation is highly
  99. # domain-specific. It is NULLable because some items don't have a fixed
  100. # price but require a runtime calculation (possible from an external
  101. # service).
  102. price_excl_tax = models.DecimalField(
  103. _("Price (excl. tax)"), decimal_places=2, max_digits=12,
  104. blank=True, null=True)
  105. #: Retail price for this item. This is simply the recommended price from
  106. #: the manufacturer. If this is used, it is for display purposes only.
  107. #: This prices is the NOT the price charged to the customer.
  108. price_retail = models.DecimalField(
  109. _("Price (retail)"), decimal_places=2, max_digits=12,
  110. blank=True, null=True)
  111. #: Cost price is the price charged by the fulfilment partner. It is not
  112. #: used (by default) in any price calculations but is often used in
  113. #: reporting so merchants can report on their profit margin.
  114. cost_price = models.DecimalField(
  115. _("Cost Price"), decimal_places=2, max_digits=12,
  116. blank=True, null=True)
  117. #: Number of items in stock
  118. num_in_stock = models.PositiveIntegerField(
  119. _("Number in stock"), blank=True, null=True)
  120. #: The amount of stock allocated to orders but not fed back to the master
  121. #: stock system. A typical stock update process will set the num_in_stock
  122. #: variable to a new value and reset num_allocated to zero
  123. num_allocated = models.IntegerField(
  124. _("Number allocated"), blank=True, null=True)
  125. #: Threshold for low-stock alerts. When stock goes beneath this threshold,
  126. #: an alert is triggered so warehouse managers can order more.
  127. low_stock_threshold = models.PositiveIntegerField(
  128. _("Low Stock Threshold"), blank=True, null=True)
  129. # Date information
  130. date_created = models.DateTimeField(_("Date created"), auto_now_add=True)
  131. date_updated = models.DateTimeField(_("Date updated"), auto_now=True,
  132. db_index=True)
  133. def __unicode__(self):
  134. msg = u"Partner: %s, product: %s" % (
  135. self.partner.display_name, self.product,)
  136. if self.partner_sku:
  137. msg = u"%s (%s)" % (msg, self.partner_sku)
  138. return msg
  139. class Meta:
  140. abstract = True
  141. unique_together = ('partner', 'partner_sku')
  142. verbose_name = _("Stock record")
  143. verbose_name_plural = _("Stock records")
  144. @property
  145. def net_stock_level(self):
  146. """
  147. The effective number in stock (eg available to buy).
  148. This is correct property to show the customer, not the num_in_stock
  149. field as that doesn't account for allocations. This can be negative in
  150. some unusual circumstances
  151. """
  152. if self.num_in_stock is None:
  153. return 0
  154. if self.num_allocated is None:
  155. return self.num_in_stock
  156. return self.num_in_stock - self.num_allocated
  157. # 2-stage stock management model
  158. def allocate(self, quantity):
  159. """
  160. Record a stock allocation.
  161. This normally happens when a product is bought at checkout. When the
  162. product is actually shipped, then we 'consume' the allocation.
  163. """
  164. if self.num_allocated is None:
  165. self.num_allocated = 0
  166. self.num_allocated += quantity
  167. self.save()
  168. allocate.alters_data = True
  169. def is_allocation_consumption_possible(self, quantity):
  170. """
  171. Test if a proposed stock consumption is permitted
  172. """
  173. return quantity <= min(self.num_allocated, self.num_in_stock)
  174. def consume_allocation(self, quantity):
  175. """
  176. Consume a previous allocation
  177. This is used when an item is shipped. We remove the original
  178. allocation and adjust the number in stock accordingly
  179. """
  180. if not self.is_allocation_consumption_possible(quantity):
  181. raise InvalidStockAdjustment(
  182. _('Invalid stock consumption request'))
  183. self.num_allocated -= quantity
  184. self.num_in_stock -= quantity
  185. self.save()
  186. consume_allocation.alters_data = True
  187. def cancel_allocation(self, quantity):
  188. # We ignore requests that request a cancellation of more than the
  189. # amount already allocated.
  190. self.num_allocated -= min(self.num_allocated, quantity)
  191. self.save()
  192. cancel_allocation.alters_data = True
  193. @property
  194. def is_below_threshold(self):
  195. if self.low_stock_threshold is None:
  196. return False
  197. return self.net_stock_level < self.low_stock_threshold
  198. # Stock wrapper methods - deprecated since 0.6
  199. @property
  200. def is_available_to_buy(self):
  201. """
  202. Return whether this stockrecord allows the product to be purchased
  203. """
  204. warnings.warn((
  205. "StockRecord.is_available_to_buy is deprecated and will be "
  206. "removed in 0.7. Use a strategy class to determine availability "
  207. "instead"), DeprecationWarning)
  208. return get_partner_wrapper(self.partner_id).is_available_to_buy(self)
  209. def is_purchase_permitted(self, user=None, quantity=1, product=None):
  210. """
  211. Return whether this stockrecord allows the product to be purchased by a
  212. specific user and quantity
  213. """
  214. warnings.warn((
  215. "StockRecord.is_purchase_permitted is deprecated and will be "
  216. "removed in 0.7. Use a strategy class to determine availability "
  217. "instead"), DeprecationWarning)
  218. return get_partner_wrapper(
  219. self.partner_id).is_purchase_permitted(
  220. self, user, quantity, product)
  221. @property
  222. def availability_code(self):
  223. """
  224. Return an product's availability as a code for use in CSS to add icons
  225. to the overall availability mark-up. For example, "instock",
  226. "unavailable".
  227. """
  228. warnings.warn((
  229. "StockRecord.availability_code is deprecated and will be "
  230. "removed in 0.7. Use a strategy class to determine availability "
  231. "instead"), DeprecationWarning)
  232. return get_partner_wrapper(self.partner_id).availability_code(self)
  233. @property
  234. def availability(self):
  235. """
  236. Return a product's availability as a string that can be displayed to
  237. the user. For example, "In stock", "Unavailable".
  238. """
  239. warnings.warn((
  240. "StockRecord.availability is deprecated and will be "
  241. "removed in 0.7. Use a strategy class to determine availability "
  242. "instead"), DeprecationWarning)
  243. return get_partner_wrapper(self.partner_id).availability(self)
  244. def max_purchase_quantity(self, user=None):
  245. """
  246. Return an item's availability as a string
  247. :param user: (optional) The user who wants to purchase
  248. """
  249. warnings.warn((
  250. "StockRecord.max_purchase_quantity is deprecated and will be "
  251. "removed in 0.7. Use a strategy class to determine availability "
  252. "instead"), DeprecationWarning)
  253. return get_partner_wrapper(
  254. self.partner_id).max_purchase_quantity(self, user)
  255. @property
  256. def dispatch_date(self):
  257. """
  258. Return the estimated dispatch date for a line
  259. """
  260. warnings.warn((
  261. "StockRecord.dispatch_date is deprecated and will be "
  262. "removed in 0.7. Use a strategy class to determine availability "
  263. "instead"), DeprecationWarning)
  264. return get_partner_wrapper(self.partner_id).dispatch_date(self)
  265. @property
  266. def lead_time(self):
  267. warnings.warn((
  268. "StockRecord.lead_time is deprecated and will be "
  269. "removed in 0.7. Use a strategy class to determine availability "
  270. "instead"), DeprecationWarning)
  271. return get_partner_wrapper(self.partner_id).lead_time(self)
  272. # Price methods - deprecated in 0.6
  273. @property
  274. def price_incl_tax(self):
  275. """
  276. Return a product's price including tax.
  277. This defaults to the price_excl_tax as tax calculations are
  278. domain specific. This class needs to be subclassed and tax logic
  279. added to this method.
  280. """
  281. warnings.warn((
  282. "StockRecord.price_incl_tax is deprecated and will be "
  283. "removed in 0.7. Use a strategy class to determine price "
  284. "information instead"), DeprecationWarning, stacklevel=2)
  285. if self.price_excl_tax is None:
  286. return D('0.00')
  287. return self.price_excl_tax + self.price_tax
  288. @property
  289. def price_tax(self):
  290. """
  291. Return a product's tax value
  292. """
  293. warnings.warn((
  294. "StockRecord.price_incl_tax is deprecated and will be "
  295. "removed in 0.7. Use a strategy class to determine price "
  296. "information instead"), DeprecationWarning)
  297. return get_partner_wrapper(self.partner_id).calculate_tax(self)
  298. class AbstractStockAlert(models.Model):
  299. """
  300. A stock alert. E.g. used to notify users when a product is 'back in stock'.
  301. """
  302. stockrecord = models.ForeignKey(
  303. 'partner.StockRecord', related_name='alerts',
  304. verbose_name=_("Stock Record"))
  305. threshold = models.PositiveIntegerField(_("Threshold"))
  306. OPEN, CLOSED = "Open", "Closed"
  307. status_choices = (
  308. (OPEN, _("Open")),
  309. (CLOSED, _("Closed")),
  310. )
  311. status = models.CharField(_("Status"), max_length=128, default=OPEN,
  312. choices=status_choices)
  313. date_created = models.DateTimeField(_("Date Created"), auto_now_add=True)
  314. date_closed = models.DateTimeField(_("Date Closed"), blank=True, null=True)
  315. def close(self):
  316. self.status = self.CLOSED
  317. self.save()
  318. close.alters_data = True
  319. def __unicode__(self):
  320. return _('<stockalert for "%(stock)s" status %(status)s>') % {'stock': self.stockrecord, 'status': self.status}
  321. class Meta:
  322. abstract = True
  323. ordering = ('-date_created',)
  324. verbose_name = _('Stock Alert')
  325. verbose_name_plural = _('Stock Alerts')