| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415 |
- from decimal import Decimal as D
- import warnings
-
- from django.db import models
- from django.conf import settings
- from django.db.models import get_model
- from django.utils.translation import ugettext_lazy as _
- from django.utils.importlib import import_module as django_import_module
- from oscar.core.compat import AUTH_USER_MODEL
-
- from oscar.core.utils import slugify
- from oscar.core.loading import get_class
- from oscar.apps.partner.exceptions import InvalidStockAdjustment
- DefaultWrapper = get_class('partner.wrappers', 'DefaultWrapper')
-
-
- # Cache dict of partner_id => availability wrapper instance
- partner_wrappers = None
-
- default_wrapper = DefaultWrapper()
-
-
- def get_partner_wrapper(partner_id):
- """
- Returns the appropriate partner wrapper given the partner's PK
- """
- if partner_wrappers is None:
- _load_partner_wrappers()
- return partner_wrappers.get(partner_id, default_wrapper)
-
-
- def _load_partner_wrappers():
- # Prime cache of partner wrapper dict
- global partner_wrappers
- partner_wrappers = {}
- Partner = get_model('partner', 'Partner')
- for code, class_str in settings.OSCAR_PARTNER_WRAPPERS.items():
- try:
- partner = Partner.objects.get(code=code)
- except Partner.DoesNotExist:
- continue
- else:
- module_path, klass = class_str.rsplit('.', 1)
- module = django_import_module(module_path)
- partner_wrappers[partner.id] = getattr(module, klass)()
-
-
- class AbstractPartner(models.Model):
- """
- A fulfillment partner. An individual or company who can fulfil products.
- E.g. for physical goods, somebody with a warehouse and means of delivery.
-
- Creating one or more instances of the Partner model is a required step in
- setting up an Oscar deployment. Many Oscar deployments will only have one
- fulfillment partner.
- """
- code = models.SlugField(_("Code"), max_length=128, unique=True)
- name = models.CharField(_("Name"), max_length=128, null=True, blank=True)
-
- #: A partner can have users assigned to it. These can be used
- #: to provide authentication for webservices etc.
- users = models.ManyToManyField(
- AUTH_USER_MODEL, related_name="partners",
- blank=True, null=True, verbose_name=_("Users"))
-
- @property
- def display_name(self):
- if not self.name:
- return self.code
- return self.name
-
- def save(self, *args, **kwargs):
- if not self.code:
- self.code = slugify(self.name)
- super(AbstractPartner, self).save(*args, **kwargs)
-
- @property
- def primary_address(self):
- """
- Returns a partners primary address. Usually that will be the
- headquarters or similar.
-
- This is a rudimentary implementation that raises an error if there's
- more than one address. If you actually want to support multiple
- addresses, you will likely need to extend PartnerAddress to have some
- field or flag to base your decision on.
- """
- addresses = self.addresses.all()
- if len(addresses) == 0: # intentionally using len() to save queries
- return None
- elif len(addresses) == 1:
- return addresses[0]
- else:
- raise NotImplementedError(
- "Oscar's default implementation of primary_address only "
- "supports one PartnerAddress. You need to override the "
- "primary_address to look up the right address")
-
- def get_address_for_stockrecord(self, stockrecord):
- """
- Stock might be coming from different warehouses. Overriding this
- function allows selecting the correct PartnerAddress for the record.
- That can be useful when determining tax.
- """
- return self.primary_address
-
- class Meta:
- verbose_name = _('Fulfillment partner')
- verbose_name_plural = _('Fulfillment partners')
- abstract = True
- permissions = (
- ("can_edit_stock_records", _("Can edit stock records")),
- ("can_view_stock_records", _("Can view stock records")),
- ("can_edit_product_range", _("Can edit product range")),
- ("can_view_product_range", _("Can view product range")),
- ("can_edit_order_lines", _("Can edit order lines")),
- ("can_view_order_lines", _("Can view order lines"))
- )
-
- def __unicode__(self):
- return self.name
-
-
- class AbstractStockRecord(models.Model):
- """
- A stock record.
-
- This records information about a product from a fulfilment partner, such as
- their SKU, the number they have in stock and price information.
-
- Stockrecords are used by 'strategies' to determine availability and pricing
- information for the customer.
- """
- product = models.ForeignKey(
- 'catalogue.Product', related_name="stockrecords",
- verbose_name=_("Product"))
- partner = models.ForeignKey(
- 'partner.Partner', verbose_name=_("Partner"),
- related_name='stockrecords')
-
- #: The fulfilment partner will often have their own SKU for a product,
- #: which we store here. This will sometimes be the same the product's UPC
- #: but not always. It should be unique per partner.
- #: See also http://en.wikipedia.org/wiki/Stock-keeping_unit
- partner_sku = models.CharField(_("Partner SKU"), max_length=128)
-
- # Price info:
- price_currency = models.CharField(
- _("Currency"), max_length=12, default=settings.OSCAR_DEFAULT_CURRENCY)
-
- # This is the base price for calculations - tax should be applied by the
- # appropriate method. We don't store tax here as its calculation is highly
- # domain-specific. It is NULLable because some items don't have a fixed
- # price but require a runtime calculation (possible from an external
- # service).
- price_excl_tax = models.DecimalField(
- _("Price (excl. tax)"), decimal_places=2, max_digits=12,
- blank=True, null=True)
-
- #: Retail price for this item. This is simply the recommended price from
- #: the manufacturer. If this is used, it is for display purposes only.
- #: This prices is the NOT the price charged to the customer.
- price_retail = models.DecimalField(
- _("Price (retail)"), decimal_places=2, max_digits=12,
- blank=True, null=True)
-
- #: Cost price is the price charged by the fulfilment partner. It is not
- #: used (by default) in any price calculations but is often used in
- #: reporting so merchants can report on their profit margin.
- cost_price = models.DecimalField(
- _("Cost Price"), decimal_places=2, max_digits=12,
- blank=True, null=True)
-
- #: Number of items in stock
- num_in_stock = models.PositiveIntegerField(
- _("Number in stock"), blank=True, null=True)
-
- #: The amount of stock allocated to orders but not fed back to the master
- #: stock system. A typical stock update process will set the num_in_stock
- #: variable to a new value and reset num_allocated to zero
- num_allocated = models.IntegerField(
- _("Number allocated"), blank=True, null=True)
-
- #: Threshold for low-stock alerts. When stock goes beneath this threshold,
- #: an alert is triggered so warehouse managers can order more.
- low_stock_threshold = models.PositiveIntegerField(
- _("Low Stock Threshold"), blank=True, null=True)
-
- # Date information
- date_created = models.DateTimeField(_("Date created"), auto_now_add=True)
- date_updated = models.DateTimeField(_("Date updated"), auto_now=True,
- db_index=True)
-
- def __unicode__(self):
- msg = u"Partner: %s, product: %s" % (
- self.partner.display_name, self.product,)
- if self.partner_sku:
- msg = u"%s (%s)" % (msg, self.partner_sku)
- return msg
-
- class Meta:
- abstract = True
- unique_together = ('partner', 'partner_sku')
- verbose_name = _("Stock record")
- verbose_name_plural = _("Stock records")
-
- @property
- def net_stock_level(self):
- """
- The effective number in stock (eg available to buy).
-
- This is correct property to show the customer, not the num_in_stock
- field as that doesn't account for allocations. This can be negative in
- some unusual circumstances
- """
- if self.num_in_stock is None:
- return 0
- if self.num_allocated is None:
- return self.num_in_stock
- return self.num_in_stock - self.num_allocated
-
- # 2-stage stock management model
-
- def allocate(self, quantity):
- """
- Record a stock allocation.
-
- This normally happens when a product is bought at checkout. When the
- product is actually shipped, then we 'consume' the allocation.
- """
- if self.num_allocated is None:
- self.num_allocated = 0
- self.num_allocated += quantity
- self.save()
- allocate.alters_data = True
-
- def is_allocation_consumption_possible(self, quantity):
- """
- Test if a proposed stock consumption is permitted
- """
- return quantity <= min(self.num_allocated, self.num_in_stock)
-
- def consume_allocation(self, quantity):
- """
- Consume a previous allocation
-
- This is used when an item is shipped. We remove the original
- allocation and adjust the number in stock accordingly
- """
- if not self.is_allocation_consumption_possible(quantity):
- raise InvalidStockAdjustment(
- _('Invalid stock consumption request'))
- self.num_allocated -= quantity
- self.num_in_stock -= quantity
- self.save()
- consume_allocation.alters_data = True
-
- def cancel_allocation(self, quantity):
- # We ignore requests that request a cancellation of more than the
- # amount already allocated.
- self.num_allocated -= min(self.num_allocated, quantity)
- self.save()
- cancel_allocation.alters_data = True
-
- @property
- def is_below_threshold(self):
- if self.low_stock_threshold is None:
- return False
- return self.net_stock_level < self.low_stock_threshold
-
- # Stock wrapper methods - deprecated since 0.6
-
- @property
- def is_available_to_buy(self):
- """
- Return whether this stockrecord allows the product to be purchased
- """
- warnings.warn((
- "StockRecord.is_available_to_buy is deprecated and will be "
- "removed in 0.7. Use a strategy class to determine availability "
- "instead"), DeprecationWarning)
- return get_partner_wrapper(self.partner_id).is_available_to_buy(self)
-
- def is_purchase_permitted(self, user=None, quantity=1, product=None):
- """
- Return whether this stockrecord allows the product to be purchased by a
- specific user and quantity
- """
- warnings.warn((
- "StockRecord.is_purchase_permitted is deprecated and will be "
- "removed in 0.7. Use a strategy class to determine availability "
- "instead"), DeprecationWarning)
- return get_partner_wrapper(
- self.partner_id).is_purchase_permitted(
- self, user, quantity, product)
-
- @property
- def availability_code(self):
- """
- Return an product's availability as a code for use in CSS to add icons
- to the overall availability mark-up. For example, "instock",
- "unavailable".
- """
- warnings.warn((
- "StockRecord.availability_code is deprecated and will be "
- "removed in 0.7. Use a strategy class to determine availability "
- "instead"), DeprecationWarning)
- return get_partner_wrapper(self.partner_id).availability_code(self)
-
- @property
- def availability(self):
- """
- Return a product's availability as a string that can be displayed to
- the user. For example, "In stock", "Unavailable".
- """
- warnings.warn((
- "StockRecord.availability is deprecated and will be "
- "removed in 0.7. Use a strategy class to determine availability "
- "instead"), DeprecationWarning)
- return get_partner_wrapper(self.partner_id).availability(self)
-
- def max_purchase_quantity(self, user=None):
- """
- Return an item's availability as a string
-
- :param user: (optional) The user who wants to purchase
- """
- warnings.warn((
- "StockRecord.max_purchase_quantity is deprecated and will be "
- "removed in 0.7. Use a strategy class to determine availability "
- "instead"), DeprecationWarning)
- return get_partner_wrapper(
- self.partner_id).max_purchase_quantity(self, user)
-
- @property
- def dispatch_date(self):
- """
- Return the estimated dispatch date for a line
- """
- warnings.warn((
- "StockRecord.dispatch_date is deprecated and will be "
- "removed in 0.7. Use a strategy class to determine availability "
- "instead"), DeprecationWarning)
- return get_partner_wrapper(self.partner_id).dispatch_date(self)
-
- @property
- def lead_time(self):
- warnings.warn((
- "StockRecord.lead_time is deprecated and will be "
- "removed in 0.7. Use a strategy class to determine availability "
- "instead"), DeprecationWarning)
- return get_partner_wrapper(self.partner_id).lead_time(self)
-
- # Price methods - deprecated in 0.6
-
- @property
- def price_incl_tax(self):
- """
- Return a product's price including tax.
-
- This defaults to the price_excl_tax as tax calculations are
- domain specific. This class needs to be subclassed and tax logic
- added to this method.
- """
- warnings.warn((
- "StockRecord.price_incl_tax is deprecated and will be "
- "removed in 0.7. Use a strategy class to determine price "
- "information instead"), DeprecationWarning, stacklevel=2)
- if self.price_excl_tax is None:
- return D('0.00')
- return self.price_excl_tax + self.price_tax
-
- @property
- def price_tax(self):
- """
- Return a product's tax value
- """
- warnings.warn((
- "StockRecord.price_incl_tax is deprecated and will be "
- "removed in 0.7. Use a strategy class to determine price "
- "information instead"), DeprecationWarning)
- return get_partner_wrapper(self.partner_id).calculate_tax(self)
-
-
- class AbstractStockAlert(models.Model):
- """
- A stock alert. E.g. used to notify users when a product is 'back in stock'.
- """
- stockrecord = models.ForeignKey(
- 'partner.StockRecord', related_name='alerts',
- verbose_name=_("Stock Record"))
- threshold = models.PositiveIntegerField(_("Threshold"))
- OPEN, CLOSED = "Open", "Closed"
- status_choices = (
- (OPEN, _("Open")),
- (CLOSED, _("Closed")),
- )
- status = models.CharField(_("Status"), max_length=128, default=OPEN,
- choices=status_choices)
- date_created = models.DateTimeField(_("Date Created"), auto_now_add=True)
- date_closed = models.DateTimeField(_("Date Closed"), blank=True, null=True)
-
- def close(self):
- self.status = self.CLOSED
- self.save()
- close.alters_data = True
-
- def __unicode__(self):
- return _('<stockalert for "%(stock)s" status %(status)s>') % {'stock': self.stockrecord, 'status': self.status}
-
- class Meta:
- abstract = True
- ordering = ('-date_created',)
- verbose_name = _('Stock Alert')
- verbose_name_plural = _('Stock Alerts')
|