Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

abstract_models.py 13KB

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