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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  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. @property
  61. def primary_address(self):
  62. """
  63. Returns a partners primary address. Usually that will be the
  64. headquarters or similar.
  65. This is a rudimentary implementation that raises an error if there's
  66. more than one address. If you actually want to support multiple
  67. addresses, you will likely need to extend PartnerAddress to have some
  68. field or flag to base your decision on.
  69. """
  70. addresses = self.addresses.all()
  71. if len(addresses) == 0: # intentionally using len() to save queries
  72. return None
  73. elif len(addresses) == 1:
  74. return addresses[0]
  75. else:
  76. raise NotImplementedError(
  77. "Oscar's default implementation of primary_address only "
  78. "supports one PartnerAddress. You need to override the "
  79. "primary_address to look up the right address")
  80. def get_address_for_stockrecord(self, stockrecord):
  81. """
  82. Stock might be coming from different warehouses. Overriding this
  83. function allows selecting the correct PartnerAddress for the record.
  84. That can be useful when determining tax.
  85. """
  86. return self.primary_address
  87. class Meta:
  88. verbose_name = _('Fulfillment partner')
  89. verbose_name_plural = _('Fulfillment partners')
  90. abstract = True
  91. permissions = (
  92. ("can_edit_stock_records", _("Can edit stock records")),
  93. ("can_view_stock_records", _("Can view stock records")),
  94. ("can_edit_product_range", _("Can edit product range")),
  95. ("can_view_product_range", _("Can view product range")),
  96. ("can_edit_order_lines", _("Can edit order lines")),
  97. ("can_view_order_lines", _("Can view order lines"))
  98. )
  99. def __unicode__(self):
  100. return self.name
  101. class AbstractStockRecord(models.Model):
  102. """
  103. A basic stock record.
  104. This links a product to a partner, together with price and availability
  105. information. Most projects will need to subclass this object to add custom
  106. fields such as lead_time, report_code, min_quantity.
  107. We deliberately don't store tax information to allow each project
  108. to subclass this model and put its own fields for convey tax.
  109. """
  110. product = models.OneToOneField(
  111. 'catalogue.Product', related_name="stockrecord",
  112. verbose_name=_("Product"))
  113. partner = models.ForeignKey('partner.Partner', verbose_name=_("Partner"))
  114. #: The fulfilment partner will often have their own SKU for a product, which
  115. #: we store here. This will sometimes be the same the product's UPC but not
  116. #: always. It should be unique per partner.
  117. #: See also http://en.wikipedia.org/wiki/Stock-keeping_unit
  118. partner_sku = models.CharField(_("Partner SKU"), max_length=128)
  119. # Price info:
  120. price_currency = models.CharField(
  121. _("Currency"), max_length=12, default=settings.OSCAR_DEFAULT_CURRENCY)
  122. # This is the base price for calculations - tax should be applied by the
  123. # appropriate method. We don't store tax here as its calculation is highly
  124. # domain-specific. It is NULLable because some items don't have a fixed
  125. # price but require a runtime calculation (possible from an external
  126. # service).
  127. price_excl_tax = models.DecimalField(
  128. _("Price (excl. tax)"), decimal_places=2, max_digits=12,
  129. blank=True, null=True)
  130. #: Retail price for this item. This is simply the recommended price from
  131. #: the manufacturer. If this is used, it is for display purposes only.
  132. #: This prices is the NOT the price charged to the customer.
  133. price_retail = models.DecimalField(
  134. _("Price (retail)"), decimal_places=2, max_digits=12,
  135. blank=True, null=True)
  136. #: Cost price is the price charged by the fulfilment partner. It is not
  137. #: used (by default) in any price calculations but is often used in
  138. #: reporting so merchants can report on their profit margin.
  139. cost_price = models.DecimalField(
  140. _("Cost Price"), decimal_places=2, max_digits=12,
  141. blank=True, null=True)
  142. #: Number of items in stock
  143. num_in_stock = models.PositiveIntegerField(
  144. _("Number in stock"), blank=True, null=True)
  145. #: Threshold for low-stock alerts. When stock goes beneath this threshold,
  146. #: an alert is triggered so warehouse managers can order more.
  147. low_stock_threshold = models.PositiveIntegerField(
  148. _("Low Stock Threshold"), blank=True, null=True)
  149. #: The amount of stock allocated to orders but not fed back to the master
  150. #: stock system. A typical stock update process will set the num_in_stock
  151. #: variable to a new value and reset num_allocated to zero
  152. num_allocated = models.IntegerField(
  153. _("Number allocated"), blank=True, null=True)
  154. # Date information
  155. date_created = models.DateTimeField(_("Date created"), auto_now_add=True)
  156. date_updated = models.DateTimeField(_("Date updated"), auto_now=True,
  157. db_index=True)
  158. class Meta:
  159. abstract = True
  160. unique_together = ('partner', 'partner_sku')
  161. verbose_name = _("Stock Record")
  162. verbose_name_plural = _("Stock Records")
  163. # 2-stage stock management model
  164. def allocate(self, quantity):
  165. """
  166. Record a stock allocation.
  167. This normally happens when a product is bought at checkout. When the
  168. product is actually shipped, then we 'consume' the allocation.
  169. """
  170. if self.num_allocated is None:
  171. self.num_allocated = 0
  172. self.num_allocated += quantity
  173. self.save()
  174. allocate.alters_data = True
  175. def is_allocation_consumption_possible(self, quantity):
  176. return quantity <= min(self.num_allocated, self.num_in_stock)
  177. def consume_allocation(self, quantity):
  178. """
  179. Consume a previous allocation
  180. This is used when an item is shipped. We remove the original allocation
  181. and adjust the number in stock accordingly
  182. """
  183. if not self.is_allocation_consumption_possible(quantity):
  184. raise InvalidStockAdjustment(_('Invalid stock consumption request'))
  185. self.num_allocated -= quantity
  186. self.num_in_stock -= quantity
  187. self.save()
  188. consume_allocation.alters_data = True
  189. def cancel_allocation(self, quantity):
  190. # We ignore requests that request a cancellation of more than the amount already
  191. # allocated.
  192. self.num_allocated -= min(self.num_allocated, quantity)
  193. self.save()
  194. cancel_allocation.alters_data = True
  195. @property
  196. def net_stock_level(self):
  197. """
  198. Return the effective number in stock. This is correct property to show
  199. the customer, not the num_in_stock field as that doesn't account for
  200. allocations. This can be negative in some unusual circumstances
  201. """
  202. if self.num_in_stock is None:
  203. return 0
  204. if self.num_allocated is None:
  205. return self.num_in_stock
  206. return self.num_in_stock - self.num_allocated
  207. def set_discount_price(self, price):
  208. """
  209. A setter method for setting a new price.
  210. This is called from within the "discount" app, which is responsible
  211. for applying fixed-discount offers to products. We use a setter method
  212. so that this behaviour can be customised in projects.
  213. """
  214. self.price_excl_tax = price
  215. self.save()
  216. set_discount_price.alters_data = True
  217. # Price retrieval methods - these default to no tax being applicable
  218. # These are intended to be overridden.
  219. @property
  220. def is_available_to_buy(self):
  221. """
  222. Return whether this stockrecord allows the product to be purchased
  223. """
  224. return get_partner_wrapper(self.partner_id).is_available_to_buy(self)
  225. def is_purchase_permitted(self, user=None, quantity=1, product=None):
  226. """
  227. Return whether this stockrecord allows the product to be purchased by a
  228. specific user and quantity
  229. """
  230. return get_partner_wrapper(self.partner_id).is_purchase_permitted(self, user, quantity, product)
  231. @property
  232. def is_below_threshold(self):
  233. if self.low_stock_threshold is None:
  234. return False
  235. return self.net_stock_level < self.low_stock_threshold
  236. @property
  237. def availability_code(self):
  238. """
  239. Return an product's availability as a code for use in CSS to add icons
  240. to the overall availability mark-up. For example, "instock",
  241. "unavailable".
  242. """
  243. return get_partner_wrapper(self.partner_id).availability_code(self)
  244. @property
  245. def availability(self):
  246. """
  247. Return a product's availability as a string that can be displayed to the
  248. user. For example, "In stock", "Unavailable".
  249. """
  250. return get_partner_wrapper(self.partner_id).availability(self)
  251. def max_purchase_quantity(self, user=None):
  252. """
  253. Return an item's availability as a string
  254. :param user: (optional) The user who wants to purchase
  255. """
  256. return get_partner_wrapper(self.partner_id).max_purchase_quantity(self, user)
  257. @property
  258. def dispatch_date(self):
  259. """
  260. Return the estimated dispatch date for a line
  261. """
  262. return get_partner_wrapper(self.partner_id).dispatch_date(self)
  263. @property
  264. def lead_time(self):
  265. return get_partner_wrapper(self.partner_id).lead_time(self)
  266. @property
  267. def price_incl_tax(self):
  268. """
  269. Return a product's price including tax.
  270. This defaults to the price_excl_tax as tax calculations are
  271. domain specific. This class needs to be subclassed and tax logic
  272. added to this method.
  273. """
  274. if self.price_excl_tax is None:
  275. return D('0.00')
  276. return self.price_excl_tax + self.price_tax
  277. @property
  278. def price_tax(self):
  279. """
  280. Return a product's tax value
  281. """
  282. return get_partner_wrapper(self.partner_id).calculate_tax(self)
  283. def __unicode__(self):
  284. if self.partner_sku:
  285. return "%s (%s): %s" % (self.partner.display_name,
  286. self.partner_sku, self.product.title)
  287. else:
  288. return "%s: %s" % (self.partner.display_name, self.product.title)
  289. class AbstractStockAlert(models.Model):
  290. """
  291. A stock alert. E.g. used to notify users when a product is 'back in stock'.
  292. """
  293. stockrecord = models.ForeignKey(
  294. 'partner.StockRecord', related_name='alerts',
  295. verbose_name=_("Stock Record"))
  296. threshold = models.PositiveIntegerField(_("Threshold"))
  297. OPEN, CLOSED = "Open", "Closed"
  298. status_choices = (
  299. (OPEN, _("Open")),
  300. (CLOSED, _("Closed")),
  301. )
  302. status = models.CharField(_("Status"), max_length=128, default=OPEN,
  303. choices=status_choices)
  304. date_created = models.DateTimeField(_("Date Created"), auto_now_add=True)
  305. date_closed = models.DateTimeField(_("Date Closed"), blank=True, null=True)
  306. def close(self):
  307. self.status = self.CLOSED
  308. self.save()
  309. close.alters_data = True
  310. def __unicode__(self):
  311. return _('<stockalert for "%(stock)s" status %(status)s>') % {'stock': self.stockrecord, 'status': self.status}
  312. class Meta:
  313. abstract = True
  314. ordering = ('-date_created',)
  315. verbose_name = _('Stock Alert')
  316. verbose_name_plural = _('Stock Alerts')