| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689 |
- import os
- import re
- import six
- import operator
- from decimal import Decimal as D, ROUND_DOWN, ROUND_UP
-
- from django.core import exceptions
- from django.template.defaultfilters import date as date_filter
- from django.db import models
- from django.utils.encoding import python_2_unicode_compatible
- from django.utils.timezone import now, get_current_timezone
- from django.utils.translation import ungettext, ugettext_lazy as _
- from django.utils.importlib import import_module
- from django.core.exceptions import ValidationError
- from django.core.urlresolvers import reverse
- from django.conf import settings
-
- from oscar.core.compat import AUTH_USER_MODEL
- from oscar.core.loading import get_class, get_model
- from oscar.apps.offer.managers import ActiveOfferManager
- from oscar.templatetags.currency_filters import currency
- from oscar.models import fields
-
- BrowsableRangeManager = get_class('offer.managers', 'BrowsableRangeManager')
-
-
- def load_proxy(proxy_class):
- module, classname = proxy_class.rsplit('.', 1)
- try:
- mod = import_module(module)
- except ImportError as e:
- raise exceptions.ImproperlyConfigured(
- "Error importing module %s: %s" % (module, e))
- try:
- return getattr(mod, classname)
- except AttributeError:
- raise exceptions.ImproperlyConfigured(
- "Module %s does not define a %s" % (module, classname))
-
-
- def range_anchor(range):
- return u'<a href="%s">%s</a>' % (
- reverse('dashboard:range-update', kwargs={'pk': range.pk}),
- range.name)
-
-
- def unit_price(offer, line):
- """
- Return the relevant price for a given basket line.
-
- This is required so offers can apply in circumstances where tax isn't known
- """
- return line.unit_effective_price
-
-
- def apply_discount(line, discount, quantity):
- """
- Apply a given discount to the passed basket
- """
- line.discount(discount, quantity, incl_tax=False)
-
-
- @python_2_unicode_compatible
- class ConditionalOffer(models.Model):
- """
- A conditional offer (eg buy 1, get 10% off)
- """
- name = models.CharField(
- _("Name"), max_length=128, unique=True,
- help_text=_("This is displayed within the customer's basket"))
- slug = fields.AutoSlugField(
- _("Slug"), max_length=128, unique=True, populate_from='name')
- description = models.TextField(_("Description"), blank=True,
- help_text=_("This is displayed on the offer"
- " browsing page"))
-
- # Offers come in a few different types:
- # (a) Offers that are available to all customers on the site. Eg a
- # 3-for-2 offer.
- # (b) Offers that are linked to a voucher, and only become available once
- # that voucher has been applied to the basket
- # (c) Offers that are linked to a user. Eg, all students get 10% off. The
- # code to apply this offer needs to be coded
- # (d) Session offers - these are temporarily available to a user after some
- # trigger event. Eg, users coming from some affiliate site get 10%
- # off.
- SITE, VOUCHER, USER, SESSION = ("Site", "Voucher", "User", "Session")
- TYPE_CHOICES = (
- (SITE, _("Site offer - available to all users")),
- (VOUCHER, _("Voucher offer - only available after entering "
- "the appropriate voucher code")),
- (USER, _("User offer - available to certain types of user")),
- (SESSION, _("Session offer - temporary offer, available for "
- "a user for the duration of their session")),
- )
- offer_type = models.CharField(
- _("Type"), choices=TYPE_CHOICES, default=SITE, max_length=128)
-
- # We track a status variable so it's easier to load offers that are
- # 'available' in some sense.
- OPEN, SUSPENDED, CONSUMED = "Open", "Suspended", "Consumed"
- status = models.CharField(_("Status"), max_length=64, default=OPEN)
-
- condition = models.ForeignKey(
- 'offer.Condition', verbose_name=_("Condition"))
- benefit = models.ForeignKey('offer.Benefit', verbose_name=_("Benefit"))
-
- # Some complicated situations require offers to be applied in a set order.
- priority = models.IntegerField(
- _("Priority"), default=0,
- help_text=_("The highest priority offers are applied first"))
-
- # AVAILABILITY
-
- # Range of availability. Note that if this is a voucher offer, then these
- # dates are ignored and only the dates from the voucher are used to
- # determine availability.
- start_datetime = models.DateTimeField(
- _("Start date"), blank=True, null=True)
- end_datetime = models.DateTimeField(
- _("End date"), blank=True, null=True,
- help_text=_("Offers are active until the end of the 'end date'"))
-
- # Use this field to limit the number of times this offer can be applied in
- # total. Note that a single order can apply an offer multiple times so
- # this is not the same as the number of orders that can use it.
- max_global_applications = models.PositiveIntegerField(
- _("Max global applications"),
- help_text=_("The number of times this offer can be used before it "
- "is unavailable"), blank=True, null=True)
-
- # Use this field to limit the number of times this offer can be used by a
- # single user. This only works for signed-in users - it doesn't really
- # make sense for sites that allow anonymous checkout.
- max_user_applications = models.PositiveIntegerField(
- _("Max user applications"),
- help_text=_("The number of times a single user can use this offer"),
- blank=True, null=True)
-
- # Use this field to limit the number of times this offer can be applied to
- # a basket (and hence a single order).
- max_basket_applications = models.PositiveIntegerField(
- _("Max basket applications"),
- blank=True, null=True,
- help_text=_("The number of times this offer can be applied to a "
- "basket (and order)"))
-
- # Use this field to limit the amount of discount an offer can lead to.
- # This can be helpful with budgeting.
- max_discount = models.DecimalField(
- _("Max discount"), decimal_places=2, max_digits=12, null=True,
- blank=True,
- help_text=_("When an offer has given more discount to orders "
- "than this threshold, then the offer becomes "
- "unavailable"))
-
- # TRACKING
-
- total_discount = models.DecimalField(
- _("Total Discount"), decimal_places=2, max_digits=12,
- default=D('0.00'))
- num_applications = models.PositiveIntegerField(
- _("Number of applications"), default=0)
- num_orders = models.PositiveIntegerField(
- _("Number of Orders"), default=0)
-
- redirect_url = fields.ExtendedURLField(
- _("URL redirect (optional)"), blank=True)
- date_created = models.DateTimeField(_("Date Created"), auto_now_add=True)
-
- objects = models.Manager()
- active = ActiveOfferManager()
-
- # We need to track the voucher that this offer came from (if it is a
- # voucher offer)
- _voucher = None
-
- class Meta:
- app_label = 'offer'
- ordering = ['-priority']
- verbose_name = _("Conditional offer")
- verbose_name_plural = _("Conditional offers")
-
- def save(self, *args, **kwargs):
- # Check to see if consumption thresholds have been broken
- if not self.is_suspended:
- if self.get_max_applications() == 0:
- self.status = self.CONSUMED
- else:
- self.status = self.OPEN
-
- return super(ConditionalOffer, self).save(*args, **kwargs)
-
- def get_absolute_url(self):
- return reverse('offer:detail', kwargs={'slug': self.slug})
-
- def __str__(self):
- return self.name
-
- def clean(self):
- if (self.start_datetime and self.end_datetime and
- self.start_datetime > self.end_datetime):
- raise exceptions.ValidationError(
- _('End date should be later than start date'))
-
- @property
- def is_open(self):
- return self.status == self.OPEN
-
- @property
- def is_suspended(self):
- return self.status == self.SUSPENDED
-
- def suspend(self):
- self.status = self.SUSPENDED
- self.save()
- suspend.alters_data = True
-
- def unsuspend(self):
- self.status = self.OPEN
- self.save()
- suspend.alters_data = True
-
- def is_available(self, user=None, test_date=None):
- """
- Test whether this offer is available to be used
- """
- if self.is_suspended:
- return False
- if test_date is None:
- test_date = now()
- predicates = []
- if self.start_datetime:
- predicates.append(self.start_datetime > test_date)
- if self.end_datetime:
- predicates.append(test_date > self.end_datetime)
- if any(predicates):
- return False
- return self.get_max_applications(user) > 0
-
- def is_condition_satisfied(self, basket):
- return self.condition.proxy().is_satisfied(self, basket)
-
- def is_condition_partially_satisfied(self, basket):
- return self.condition.proxy().is_partially_satisfied(self, basket)
-
- def get_upsell_message(self, basket):
- return self.condition.proxy().get_upsell_message(self, basket)
-
- def apply_benefit(self, basket):
- """
- Applies the benefit to the given basket and returns the discount.
- """
- if not self.is_condition_satisfied(basket):
- return ZERO_DISCOUNT
- return self.benefit.proxy().apply(
- basket, self.condition.proxy(), self)
-
- def apply_deferred_benefit(self, basket, order, application):
- """
- Applies any deferred benefits. These are things like adding loyalty
- points to somone's account.
- """
- return self.benefit.proxy().apply_deferred(basket, order, application)
-
- def set_voucher(self, voucher):
- self._voucher = voucher
-
- def get_voucher(self):
- return self._voucher
-
- def get_max_applications(self, user=None):
- """
- Return the number of times this offer can be applied to a basket for a
- given user.
- """
- if self.max_discount and self.total_discount >= self.max_discount:
- return 0
-
- # Hard-code a maximum value as we need some sensible upper limit for
- # when there are not other caps.
- limits = [10000]
- if self.max_user_applications and user:
- limits.append(max(0, self.max_user_applications -
- self.get_num_user_applications(user)))
- if self.max_basket_applications:
- limits.append(self.max_basket_applications)
- if self.max_global_applications:
- limits.append(
- max(0, self.max_global_applications - self.num_applications))
- return min(limits)
-
- def get_num_user_applications(self, user):
- OrderDiscount = get_model('order', 'OrderDiscount')
- aggregates = OrderDiscount.objects.filter(offer_id=self.id,
- order__user=user)\
- .aggregate(total=models.Sum('frequency'))
- return aggregates['total'] if aggregates['total'] is not None else 0
-
- def shipping_discount(self, charge):
- return self.benefit.proxy().shipping_discount(charge)
-
- def record_usage(self, discount):
- self.num_applications += discount['freq']
- self.total_discount += discount['discount']
- self.num_orders += 1
- self.save()
- record_usage.alters_data = True
-
- def availability_description(self):
- """
- Return a description of when this offer is available
- """
- restrictions = self.availability_restrictions()
- descriptions = [r['description'] for r in restrictions]
- return "<br/>".join(descriptions)
-
- def availability_restrictions(self): # noqa (too complex (15))
- restrictions = []
- if self.is_suspended:
- restrictions.append({
- 'description': _("Offer is suspended"),
- 'is_satisfied': False})
-
- if self.max_global_applications:
- remaining = self.max_global_applications - self.num_applications
- desc = _("Limited to %(total)d uses (%(remainder)d remaining)") \
- % {'total': self.max_global_applications,
- 'remainder': remaining}
- restrictions.append({'description': desc,
- 'is_satisfied': remaining > 0})
-
- if self.max_user_applications:
- if self.max_user_applications == 1:
- desc = _("Limited to 1 use per user")
- else:
- desc = _("Limited to %(total)d uses per user") \
- % {'total': self.max_user_applications}
- restrictions.append({'description': desc,
- 'is_satisfied': True})
-
- if self.max_basket_applications:
- if self.max_user_applications == 1:
- desc = _("Limited to 1 use per basket")
- else:
- desc = _("Limited to %(total)d uses per basket") \
- % {'total': self.max_basket_applications}
- restrictions.append({
- 'description': desc,
- 'is_satisfied': True})
-
- def hide_time_if_zero(dt):
- # Only show hours/minutes if they have been specified
- if dt.tzinfo:
- localtime = dt.astimezone(get_current_timezone())
- else:
- localtime = dt
- if localtime.hour == 0 and localtime.minute == 0:
- return date_filter(localtime, settings.DATE_FORMAT)
- return date_filter(localtime, settings.DATETIME_FORMAT)
-
- if self.start_datetime or self.end_datetime:
- today = now()
- if self.start_datetime and self.end_datetime:
- desc = _("Available between %(start)s and %(end)s") \
- % {'start': hide_time_if_zero(self.start_datetime),
- 'end': hide_time_if_zero(self.end_datetime)}
- is_satisfied \
- = self.start_datetime <= today <= self.end_datetime
- elif self.start_datetime:
- desc = _("Available from %(start)s") % {
- 'start': hide_time_if_zero(self.start_datetime)}
- is_satisfied = today >= self.start_datetime
- elif self.end_datetime:
- desc = _("Available until %(end)s") % {
- 'end': hide_time_if_zero(self.end_datetime)}
- is_satisfied = today <= self.end_datetime
- restrictions.append({
- 'description': desc,
- 'is_satisfied': is_satisfied})
-
- if self.max_discount:
- desc = _("Limited to a cost of %(max)s") % {
- 'max': currency(self.max_discount)}
- restrictions.append({
- 'description': desc,
- 'is_satisfied': self.total_discount < self.max_discount})
-
- return restrictions
-
- @property
- def has_products(self):
- return self.condition.range is not None
-
- def products(self):
- """
- Return a queryset of products in this offer
- """
- Product = get_model('catalogue', 'Product')
- if not self.has_products:
- return Product.objects.none()
-
- cond_range = self.condition.range
- if cond_range.includes_all_products:
- # Return ALL the products
- return Product.browsable.select_related('product_class',
- 'stockrecord')\
- .filter(is_discountable=True)\
- .prefetch_related('children', 'images',
- 'product_class__options', 'product_options')
- return cond_range.included_products.filter(is_discountable=True)
-
-
- @python_2_unicode_compatible
- class Condition(models.Model):
- COUNT, VALUE, COVERAGE = ("Count", "Value", "Coverage")
- TYPE_CHOICES = (
- (COUNT, _("Depends on number of items in basket that are in "
- "condition range")),
- (VALUE, _("Depends on value of items in basket that are in "
- "condition range")),
- (COVERAGE, _("Needs to contain a set number of DISTINCT items "
- "from the condition range")))
- range = models.ForeignKey(
- 'offer.Range', verbose_name=_("Range"), null=True, blank=True)
- type = models.CharField(_('Type'), max_length=128, choices=TYPE_CHOICES,
- blank=True)
- value = fields.PositiveDecimalField(
- _('Value'), decimal_places=2, max_digits=12, null=True, blank=True)
-
- proxy_class = fields.NullCharField(
- _("Custom class"), max_length=255, unique=True, default=None)
-
- class Meta:
- app_label = 'offer'
- verbose_name = _("Condition")
- verbose_name_plural = _("Conditions")
-
- def proxy(self):
- """
- Return the proxy model
- """
- field_dict = dict(self.__dict__)
- for field in list(field_dict.keys()):
- if field.startswith('_'):
- del field_dict[field]
-
- if self.proxy_class:
- klass = load_proxy(self.proxy_class)
- return klass(**field_dict)
- klassmap = {
- self.COUNT: CountCondition,
- self.VALUE: ValueCondition,
- self.COVERAGE: CoverageCondition}
- if self.type in klassmap:
- return klassmap[self.type](**field_dict)
- return self
-
- def __str__(self):
- return self.proxy().name
-
- @property
- def name(self):
- """
- A plaintext description of the condition.
-
- This is used in the dropdowns within the offer dashboard.
- """
- return self.description
-
- @property
- def description(self):
- """
- A (optionally HTML) description of the condition.
- """
- return self.proxy().description
-
- def consume_items(self, offer, basket, affected_lines):
- pass
-
- def is_satisfied(self, offer, basket):
- """
- Determines whether a given basket meets this condition. This is
- stubbed in this top-class object. The subclassing proxies are
- responsible for implementing it correctly.
- """
- return False
-
- def is_partially_satisfied(self, offer, basket):
- """
- Determine if the basket partially meets the condition. This is useful
- for up-selling messages to entice customers to buy something more in
- order to qualify for an offer.
- """
- return False
-
- def get_upsell_message(self, offer, basket):
- return None
-
- def can_apply_condition(self, line):
- """
- Determines whether the condition can be applied to a given basket line
- """
- if not line.stockrecord_id:
- return False
- product = line.product
- return self.range.contains_product(product) and product.is_discountable
-
- def get_applicable_lines(self, offer, basket, most_expensive_first=True):
- """
- Return line data for the lines that can be consumed by this condition
- """
- line_tuples = []
- for line in basket.all_lines():
- if not self.can_apply_condition(line):
- continue
-
- price = unit_price(offer, line)
- if not price:
- continue
- line_tuples.append((price, line))
- key = operator.itemgetter(0)
- if most_expensive_first:
- return sorted(line_tuples, reverse=True, key=key)
- return sorted(line_tuples, key=key)
-
-
- @python_2_unicode_compatible
- class Benefit(models.Model):
- range = models.ForeignKey(
- 'offer.Range', null=True, blank=True, verbose_name=_("Range"))
-
- # Benefit types
- PERCENTAGE, FIXED, MULTIBUY, FIXED_PRICE = (
- "Percentage", "Absolute", "Multibuy", "Fixed price")
- SHIPPING_PERCENTAGE, SHIPPING_ABSOLUTE, SHIPPING_FIXED_PRICE = (
- 'Shipping percentage', 'Shipping absolute', 'Shipping fixed price')
- TYPE_CHOICES = (
- (PERCENTAGE, _("Discount is a percentage off of the product's value")),
- (FIXED, _("Discount is a fixed amount off of the product's value")),
- (MULTIBUY, _("Discount is to give the cheapest product for free")),
- (FIXED_PRICE,
- _("Get the products that meet the condition for a fixed price")),
- (SHIPPING_ABSOLUTE,
- _("Discount is a fixed amount of the shipping cost")),
- (SHIPPING_FIXED_PRICE, _("Get shipping for a fixed price")),
- (SHIPPING_PERCENTAGE, _("Discount is a percentage off of the shipping"
- " cost")),
- )
- type = models.CharField(
- _("Type"), max_length=128, choices=TYPE_CHOICES, blank=True)
-
- # The value to use with the designated type. This can be either an integer
- # (eg for multibuy) or a decimal (eg an amount) which is slightly
- # confusing.
- value = fields.PositiveDecimalField(
- _("Value"), decimal_places=2, max_digits=12, null=True, blank=True)
-
- # If this is not set, then there is no upper limit on how many products
- # can be discounted by this benefit.
- max_affected_items = models.PositiveIntegerField(
- _("Max Affected Items"), blank=True, null=True,
- help_text=_("Set this to prevent the discount consuming all items "
- "within the range that are in the basket."))
-
- # A custom benefit class can be used instead. This means the
- # type/value/max_affected_items fields should all be None.
- proxy_class = fields.NullCharField(
- _("Custom class"), max_length=255, unique=True, default=None)
-
- class Meta:
- app_label = 'offer'
- verbose_name = _("Benefit")
- verbose_name_plural = _("Benefits")
-
- def proxy(self):
- field_dict = dict(self.__dict__)
- for field in list(field_dict.keys()):
- if field.startswith('_'):
- del field_dict[field]
-
- if self.proxy_class:
- klass = load_proxy(self.proxy_class)
- return klass(**field_dict)
- klassmap = {
- self.PERCENTAGE: PercentageDiscountBenefit,
- self.FIXED: AbsoluteDiscountBenefit,
- self.MULTIBUY: MultibuyDiscountBenefit,
- self.FIXED_PRICE: FixedPriceBenefit,
- self.SHIPPING_ABSOLUTE: ShippingAbsoluteDiscountBenefit,
- self.SHIPPING_FIXED_PRICE: ShippingFixedPriceBenefit,
- self.SHIPPING_PERCENTAGE: ShippingPercentageDiscountBenefit}
- if self.type in klassmap:
- return klassmap[self.type](**field_dict)
- raise RuntimeError("Unrecognised benefit type (%s)" % self.type)
-
- def __str__(self):
- name = self.proxy().name
- if self.max_affected_items:
- name += ungettext(
- " (max %d item)",
- " (max %d items)",
- self.max_affected_items) % self.max_affected_items
- return name
-
- @property
- def name(self):
- return self.description
-
- @property
- def description(self):
- return self.proxy().description
-
- def apply(self, basket, condition, offer):
- return ZERO_DISCOUNT
-
- def apply_deferred(self, basket, order, application):
- return None
-
- def clean(self):
- if not self.type:
- return
- method_name = 'clean_%s' % self.type.lower().replace(' ', '_')
- if hasattr(self, method_name):
- getattr(self, method_name)()
-
- def clean_multibuy(self):
- if not self.range:
- raise ValidationError(
- _("Multibuy benefits require a product range"))
- if self.value:
- raise ValidationError(
- _("Multibuy benefits don't require a value"))
- if self.max_affected_items:
- raise ValidationError(
- _("Multibuy benefits don't require a 'max affected items' "
- "attribute"))
-
- def clean_percentage(self):
- if not self.range:
- raise ValidationError(
- _("Percentage benefits require a product range"))
- if self.value > 100:
- raise ValidationError(
- _("Percentage discount cannot be greater than 100"))
-
- def clean_shipping_absolute(self):
- if not self.value:
- raise ValidationError(
- _("A discount value is required"))
- if self.range:
- raise ValidationError(
- _("No range should be selected as this benefit does not "
- "apply to products"))
- if self.max_affected_items:
- raise ValidationError(
- _("Shipping discounts don't require a 'max affected items' "
- "attribute"))
-
- def clean_shipping_percentage(self):
- if self.value > 100:
- raise ValidationError(
- _("Percentage discount cannot be greater than 100"))
- if self.range:
- raise ValidationError(
- _("No range should be selected as this benefit does not "
- "apply to products"))
- if self.max_affected_items:
- raise ValidationError(
- _("Shipping discounts don't require a 'max affected items' "
- "attribute"))
-
- def clean_shipping_fixed_price(self):
- if self.range:
- raise ValidationError(
- _("No range should be selected as this benefit does not "
- "apply to products"))
- if self.max_affected_items:
- raise ValidationError(
- _("Shipping discounts don't require a 'max affected items' "
- "attribute"))
-
- def clean_fixed_price(self):
- if self.range:
- raise ValidationError(
- _("No range should be selected as the condition range will "
- "be used instead."))
-
- def clean_absolute(self):
- if not self.range:
- raise ValidationError(
- _("Fixed discount benefits require a product range"))
- if not self.value:
- raise ValidationError(
- _("Fixed discount benefits require a value"))
-
- def round(self, amount):
- """
- Apply rounding to discount amount
- """
- if hasattr(settings, 'OSCAR_OFFER_ROUNDING_FUNCTION'):
- return settings.OSCAR_OFFER_ROUNDING_FUNCTION(amount)
- return amount.quantize(D('.01'), ROUND_DOWN)
-
- def _effective_max_affected_items(self):
- """
- Return the maximum number of items that can have a discount applied
- during the application of this benefit
- """
- return self.max_affected_items if self.max_affected_items else 10000
-
- def can_apply_benefit(self, line):
- """
- Determines whether the benefit can be applied to a given basket line
- """
- return line.stockrecord and line.product.is_discountable
-
- def get_applicable_lines(self, offer, basket, range=None):
- """
- Return the basket lines that are available to be discounted
-
- :basket: The basket
- :range: The range of products to use for filtering. The fixed-price
- benefit ignores its range and uses the condition range
- """
- if range is None:
- range = self.range
- line_tuples = []
- for line in basket.all_lines():
- product = line.product
-
- if (not range.contains(product) or
- not self.can_apply_benefit(line)):
- continue
-
- price = unit_price(offer, line)
- if not price:
- # Avoid zero price products
- continue
- if line.quantity_without_discount == 0:
- continue
- line_tuples.append((price, line))
-
- # We sort lines to be cheapest first to ensure consistent applications
- return sorted(line_tuples, key=operator.itemgetter(0))
-
- def shipping_discount(self, charge):
- return D('0.00')
-
-
- @python_2_unicode_compatible
- class Range(models.Model):
- """
- Represents a range of products that can be used within an offer
- """
- name = models.CharField(_("Name"), max_length=128, unique=True)
- slug = fields.AutoSlugField(
- _("Slug"), max_length=128, unique=True, populate_from="name")
-
- description = models.TextField(blank=True)
-
- # Whether this range is public
- is_public = models.BooleanField(
- _('Is public?'), default=False,
- help_text=_("Public ranges have a customer-facing page"))
-
- includes_all_products = models.BooleanField(
- _('Includes all products?'), default=False)
-
- included_products = models.ManyToManyField(
- 'catalogue.Product', related_name='includes', blank=True,
- verbose_name=_("Included Products"), through='offer.RangeProduct')
- excluded_products = models.ManyToManyField(
- 'catalogue.Product', related_name='excludes', blank=True,
- verbose_name=_("Excluded Products"))
- classes = models.ManyToManyField(
- 'catalogue.ProductClass', related_name='classes', blank=True,
- verbose_name=_("Product Types"))
- included_categories = models.ManyToManyField(
- 'catalogue.Category', related_name='includes', blank=True,
- verbose_name=_("Included Categories"))
-
- # Allow a custom range instance to be specified
- proxy_class = fields.NullCharField(
- _("Custom class"), max_length=255, default=None, unique=True)
-
- date_created = models.DateTimeField(_("Date Created"), auto_now_add=True)
-
- __included_product_ids = None
- __excluded_product_ids = None
- __class_ids = None
-
- objects = models.Manager()
- browsable = BrowsableRangeManager()
-
- class Meta:
- app_label = 'offer'
- verbose_name = _("Range")
- verbose_name_plural = _("Ranges")
-
- def __str__(self):
- return self.name
-
- def get_absolute_url(self):
- return reverse('catalogue:range', kwargs={
- 'slug': self.slug})
-
- def _save(self, *args, **kwargs):
- super(Range, self).save(*args, **kwargs)
-
- def add_product(self, product, display_order=None):
- """ Add product to the range
-
- When adding product that is already in the range, prevent re-adding it.
- If display_order is specified, update it.
-
- Standard display_order for a new product in the range (0) puts
- the product at the top of the list.
-
- display_order needs to be tested for None because
-
- >>> display_order = 0
- >>> not display_order
- True
- >>> display_order is None
- False
- """
- initial_order = 0 if display_order is None else display_order
- relation, __ = RangeProduct.objects.get_or_create(
- range=self, product=product,
- defaults={'display_order': initial_order})
-
- if (display_order is not None and
- relation.display_order != display_order):
- relation.display_order = display_order
- relation.save()
-
- def remove_product(self, product):
- """ Remove product from range """
- RangeProduct.objects.filter(range=self, product=product).delete()
-
- def contains_product(self, product): # noqa (too complex (12))
- """
- Check whether the passed product is part of this range
- """
- # We look for shortcircuit checks first before
- # the tests that require more database queries.
-
- if settings.OSCAR_OFFER_BLACKLIST_PRODUCT and \
- settings.OSCAR_OFFER_BLACKLIST_PRODUCT(product):
- return False
-
- # Delegate to a proxy class if one is provided
- if self.proxy_class:
- return load_proxy(self.proxy_class)().contains_product(product)
-
- excluded_product_ids = self._excluded_product_ids()
- if product.id in excluded_product_ids:
- return False
- if self.includes_all_products:
- return True
- if product.product_class_id in self._class_ids():
- return True
- included_product_ids = self._included_product_ids()
- if product.id in included_product_ids:
- return True
- test_categories = self.included_categories.all()
- if test_categories:
- for category in product.categories.all():
- for test_category in test_categories:
- if category == test_category \
- or category.is_descendant_of(test_category):
- return True
- return False
-
- # Shorter alias
- contains = contains_product
-
- def _included_product_ids(self):
- if self.__included_product_ids is None:
- self.__included_product_ids = [row['id'] for row in
- self.included_products.values('id')]
- return self.__included_product_ids
-
- def _excluded_product_ids(self):
- if not self.id:
- return []
- if self.__excluded_product_ids is None:
- self.__excluded_product_ids = [row['id'] for row in
- self.excluded_products.values('id')]
- return self.__excluded_product_ids
-
- def _class_ids(self):
- if None == self.__class_ids:
- self.__class_ids = [row['id'] for row in self.classes.values('id')]
- return self.__class_ids
-
- def num_products(self):
- # Delegate to a proxy class if one is provided
- if self.proxy_class:
- return load_proxy(self.proxy_class)().num_products()
- if self.includes_all_products:
- return None
- return self.included_products.all().count()
-
- @property
- def is_editable(self):
- """
- Test whether this product can be edited in the dashboard
- """
- return not self.proxy_class
-
-
- class RangeProduct(models.Model):
- """ Allow ordering products inside ranges """
- range = models.ForeignKey('offer.Range')
- product = models.ForeignKey('catalogue.Product')
- display_order = models.IntegerField(default=0)
-
- class Meta:
- app_label = 'offer'
- unique_together = ('range', 'product')
-
- # ==========
- # Conditions
- # ==========
-
-
- class CountCondition(Condition):
- """
- An offer condition dependent on the NUMBER of matching items from the
- basket.
- """
- _description = _("Basket includes %(count)d item(s) from %(range)s")
-
- @property
- def name(self):
- return self._description % {
- 'count': self.value,
- 'range': six.text_type(self.range).lower()}
-
- @property
- def description(self):
- return self._description % {
- 'count': self.value,
- 'range': range_anchor(self.range)}
-
- class Meta:
- proxy = True
- verbose_name = _("Count condition")
- verbose_name_plural = _("Count conditions")
-
- def is_satisfied(self, offer, basket):
- """
- Determines whether a given basket meets this condition
- """
- num_matches = 0
- for line in basket.all_lines():
- if (self.can_apply_condition(line)
- and line.quantity_without_discount > 0):
- num_matches += line.quantity_without_discount
- if num_matches >= self.value:
- return True
- return False
-
- def _get_num_matches(self, basket):
- if hasattr(self, '_num_matches'):
- return getattr(self, '_num_matches')
- num_matches = 0
- for line in basket.all_lines():
- if (self.can_apply_condition(line)
- and line.quantity_without_discount > 0):
- num_matches += line.quantity_without_discount
- self._num_matches = num_matches
- return num_matches
-
- def is_partially_satisfied(self, offer, basket):
- num_matches = self._get_num_matches(basket)
- return 0 < num_matches < self.value
-
- def get_upsell_message(self, offer, basket):
- num_matches = self._get_num_matches(basket)
- delta = self.value - num_matches
- return ungettext('Buy %(delta)d more product from %(range)s',
- 'Buy %(delta)d more products from %(range)s', delta) \
- % {'delta': delta, 'range': self.range}
-
- def consume_items(self, offer, basket, affected_lines):
- """
- Marks items within the basket lines as consumed so they
- can't be reused in other offers.
-
- :basket: The basket
- :affected_lines: The lines that have been affected by the discount.
- This should be list of tuples (line, discount, qty)
- """
- # We need to count how many items have already been consumed as part of
- # applying the benefit, so we don't consume too many items.
- num_consumed = 0
- for line, __, quantity in affected_lines:
- num_consumed += quantity
- to_consume = max(0, self.value - num_consumed)
- if to_consume == 0:
- return
-
- for __, line in self.get_applicable_lines(offer, basket,
- most_expensive_first=True):
- quantity_to_consume = min(line.quantity_without_discount,
- to_consume)
- line.consume(quantity_to_consume)
- to_consume -= quantity_to_consume
- if to_consume == 0:
- break
-
-
- class CoverageCondition(Condition):
- """
- An offer condition dependent on the number of DISTINCT matching items from
- the basket.
- """
- _description = _("Basket includes %(count)d distinct item(s) from"
- " %(range)s")
-
- @property
- def name(self):
- return self._description % {
- 'count': self.value,
- 'range': six.text_type(self.range).lower()}
-
- @property
- def description(self):
- return self._description % {
- 'count': self.value,
- 'range': range_anchor(self.range)}
-
- class Meta:
- proxy = True
- verbose_name = _("Coverage Condition")
- verbose_name_plural = _("Coverage Conditions")
-
- def is_satisfied(self, offer, basket):
- """
- Determines whether a given basket meets this condition
- """
- covered_ids = []
- for line in basket.all_lines():
- if not line.is_available_for_discount:
- continue
- product = line.product
- if (self.can_apply_condition(line) and product.id not in
- covered_ids):
- covered_ids.append(product.id)
- if len(covered_ids) >= self.value:
- return True
- return False
-
- def _get_num_covered_products(self, basket):
- covered_ids = []
- for line in basket.all_lines():
- if not line.is_available_for_discount:
- continue
- product = line.product
- if (self.can_apply_condition(line) and product.id not in
- covered_ids):
- covered_ids.append(product.id)
- return len(covered_ids)
-
- def get_upsell_message(self, offer, basket):
- delta = self.value - self._get_num_covered_products(basket)
- return ungettext('Buy %(delta)d more product from %(range)s',
- 'Buy %(delta)d more products from %(range)s', delta) \
- % {'delta': delta, 'range': self.range}
-
- def is_partially_satisfied(self, offer, basket):
- return 0 < self._get_num_covered_products(basket) < self.value
-
- def consume_items(self, offer, basket, affected_lines):
- """
- Marks items within the basket lines as consumed so they
- can't be reused in other offers.
- """
- # Determine products that have already been consumed by applying the
- # benefit
- consumed_products = []
- for line, __, quantity in affected_lines:
- consumed_products.append(line.product)
-
- to_consume = max(0, self.value - len(consumed_products))
- if to_consume == 0:
- return
-
- for line in basket.all_lines():
- product = line.product
- if not self.can_apply_condition(line):
- continue
- if product in consumed_products:
- continue
- if not line.is_available_for_discount:
- continue
- # Only consume a quantity of 1 from each line
- line.consume(1)
- consumed_products.append(product)
- to_consume -= 1
- if to_consume == 0:
- break
-
- def get_value_of_satisfying_items(self, offer, basket):
- covered_ids = []
- value = D('0.00')
- for line in basket.all_lines():
- if (self.can_apply_condition(line) and line.product.id not in
- covered_ids):
- covered_ids.append(line.product.id)
- value += unit_price(offer, line)
- if len(covered_ids) >= self.value:
- return value
- return value
-
-
- class ValueCondition(Condition):
- """
- An offer condition dependent on the VALUE of matching items from the
- basket.
- """
- _description = _("Basket includes %(amount)s from %(range)s")
-
- @property
- def name(self):
- return self._description % {
- 'amount': currency(self.value),
- 'range': six.text_type(self.range).lower()}
-
- @property
- def description(self):
- return self._description % {
- 'amount': currency(self.value),
- 'range': range_anchor(self.range)}
-
- class Meta:
- proxy = True
- verbose_name = _("Value condition")
- verbose_name_plural = _("Value conditions")
-
- def is_satisfied(self, offer, basket):
- """
- Determine whether a given basket meets this condition
- """
- value_of_matches = D('0.00')
- for line in basket.all_lines():
- if (self.can_apply_condition(line) and
- line.quantity_without_discount > 0):
- price = unit_price(offer, line)
- value_of_matches += price * int(line.quantity_without_discount)
- if value_of_matches >= self.value:
- return True
- return False
-
- def _get_value_of_matches(self, offer, basket):
- if hasattr(self, '_value_of_matches'):
- return getattr(self, '_value_of_matches')
- value_of_matches = D('0.00')
- for line in basket.all_lines():
- if (self.can_apply_condition(line) and
- line.quantity_without_discount > 0):
- price = unit_price(offer, line)
- value_of_matches += price * int(line.quantity_without_discount)
- self._value_of_matches = value_of_matches
- return value_of_matches
-
- def is_partially_satisfied(self, offer, basket):
- value_of_matches = self._get_value_of_matches(offer, basket)
- return D('0.00') < value_of_matches < self.value
-
- def get_upsell_message(self, offer, basket):
- value_of_matches = self._get_value_of_matches(offer, basket)
- return _('Spend %(value)s more from %(range)s') % {
- 'value': currency(self.value - value_of_matches),
- 'range': self.range}
-
- def consume_items(self, offer, basket, affected_lines):
- """
- Marks items within the basket lines as consumed so they
- can't be reused in other offers.
-
- We allow lines to be passed in as sometimes we want them sorted
- in a specific order.
- """
- # Determine value of items already consumed as part of discount
- value_consumed = D('0.00')
- for line, __, qty in affected_lines:
- price = unit_price(offer, line)
- value_consumed += price * qty
-
- to_consume = max(0, self.value - value_consumed)
- if to_consume == 0:
- return
-
- for price, line in self.get_applicable_lines(
- offer, basket, most_expensive_first=True):
- quantity_to_consume = min(
- line.quantity_without_discount,
- (to_consume / price).quantize(D(1), ROUND_UP))
- line.consume(quantity_to_consume)
- to_consume -= price * quantity_to_consume
- if to_consume <= 0:
- break
-
-
- # ============
- # Result types
- # ============
-
-
- class ApplicationResult(object):
- is_final = is_successful = False
- # Basket discount
- discount = D('0.00')
- description = None
-
- # Offer applications can affect 3 distinct things
- # (a) Give a discount off the BASKET total
- # (b) Give a discount off the SHIPPING total
- # (a) Trigger a post-order action
- BASKET, SHIPPING, POST_ORDER = 0, 1, 2
- affects = None
-
- @property
- def affects_basket(self):
- return self.affects == self.BASKET
-
- @property
- def affects_shipping(self):
- return self.affects == self.SHIPPING
-
- @property
- def affects_post_order(self):
- return self.affects == self.POST_ORDER
-
-
- class BasketDiscount(ApplicationResult):
- """
- For when an offer application leads to a simple discount off the basket's
- total
- """
- affects = ApplicationResult.BASKET
-
- def __init__(self, amount):
- self.discount = amount
-
- @property
- def is_successful(self):
- return self.discount > 0
-
- def __str__(self):
- return '<Basket discount of %s>' % self.discount
-
- def __repr__(self):
- return '%s(%r)' % (self.__class__.__name__, self.discount)
-
-
- # Helper global as returning zero discount is quite common
- ZERO_DISCOUNT = BasketDiscount(D('0.00'))
-
-
- class ShippingDiscount(ApplicationResult):
- """
- For when an offer application leads to a discount from the shipping cost
- """
- is_successful = is_final = True
- affects = ApplicationResult.SHIPPING
-
-
- SHIPPING_DISCOUNT = ShippingDiscount()
-
-
- class PostOrderAction(ApplicationResult):
- """
- For when an offer condition is met but the benefit is deferred until after
- the order has been placed. Eg buy 2 books and get 100 loyalty points.
- """
- is_final = is_successful = True
- affects = ApplicationResult.POST_ORDER
-
- def __init__(self, description):
- self.description = description
-
-
- # ========
- # Benefits
- # ========
-
-
- class PercentageDiscountBenefit(Benefit):
- """
- An offer benefit that gives a percentage discount
- """
- _description = _("%(value)s%% discount on %(range)s")
-
- @property
- def name(self):
- return self._description % {
- 'value': self.value,
- 'range': self.range.name.lower()}
-
- @property
- def description(self):
- return self._description % {
- 'value': self.value,
- 'range': range_anchor(self.range)}
-
- class Meta:
- proxy = True
- verbose_name = _("Percentage discount benefit")
- verbose_name_plural = _("Percentage discount benefits")
-
- def apply(self, basket, condition, offer):
- line_tuples = self.get_applicable_lines(offer, basket)
-
- discount = D('0.00')
- affected_items = 0
- max_affected_items = self._effective_max_affected_items()
- affected_lines = []
- for price, line in line_tuples:
- if affected_items >= max_affected_items:
- break
- quantity_affected = min(line.quantity_without_discount,
- max_affected_items - affected_items)
- line_discount = self.round(self.value / D('100.0') * price
- * int(quantity_affected))
- apply_discount(line, line_discount, quantity_affected)
-
- affected_lines.append((line, line_discount, quantity_affected))
- affected_items += quantity_affected
- discount += line_discount
-
- if discount > 0:
- condition.consume_items(offer, basket, affected_lines)
- return BasketDiscount(discount)
-
-
- class AbsoluteDiscountBenefit(Benefit):
- """
- An offer benefit that gives an absolute discount
- """
- _description = _("%(value)s discount on %(range)s")
-
- @property
- def name(self):
- return self._description % {
- 'value': currency(self.value),
- 'range': self.range.name.lower()}
-
- @property
- def description(self):
- return self._description % {
- 'value': currency(self.value),
- 'range': range_anchor(self.range)}
-
- class Meta:
- proxy = True
- verbose_name = _("Absolute discount benefit")
- verbose_name_plural = _("Absolute discount benefits")
-
- def apply(self, basket, condition, offer):
- # Fetch basket lines that are in the range and available to be used in
- # an offer.
- line_tuples = self.get_applicable_lines(offer, basket)
- if not line_tuples:
- return ZERO_DISCOUNT
-
- # Determine which lines can have the discount applied to them
- max_affected_items = self._effective_max_affected_items()
- num_affected_items = 0
- affected_items_total = D('0.00')
- lines_to_discount = []
- for price, line in line_tuples:
- if num_affected_items >= max_affected_items:
- break
- qty = min(line.quantity_without_discount,
- max_affected_items - num_affected_items)
- lines_to_discount.append((line, price, qty))
- num_affected_items += qty
- affected_items_total += qty * price
-
- # Guard against zero price products causing problems
- if not affected_items_total:
- return ZERO_DISCOUNT
-
- # Ensure we don't try to apply a discount larger than the total of the
- # matching items.
- discount = min(self.value, affected_items_total)
-
- # Apply discount equally amongst them
- affected_lines = []
- applied_discount = D('0.00')
- for i, (line, price, qty) in enumerate(lines_to_discount):
- if i == len(lines_to_discount) - 1:
- # If last line, then take the delta as the discount to ensure
- # the total discount is correct and doesn't mismatch due to
- # rounding.
- line_discount = discount - applied_discount
- else:
- # Calculate a weighted discount for the line
- line_discount = self.round(
- ((price * qty) / affected_items_total) * discount)
- apply_discount(line, line_discount, qty)
- affected_lines.append((line, line_discount, qty))
- applied_discount += line_discount
-
- condition.consume_items(offer, basket, affected_lines)
-
- return BasketDiscount(discount)
-
-
- class FixedPriceBenefit(Benefit):
- """
- An offer benefit that gives the items in the condition for a
- fixed price. This is useful for "bundle" offers.
-
- Note that we ignore the benefit range here and only give a fixed price
- for the products in the condition range. The condition cannot be a value
- condition.
-
- We also ignore the max_affected_items setting.
- """
- _description = _("The products that meet the condition are sold "
- "for %(amount)s")
-
- def __str__(self):
- return self._description % {
- 'amount': currency(self.value)}
-
- @property
- def description(self):
- return six.text_type(self)
-
- class Meta:
- proxy = True
- verbose_name = _("Fixed price benefit")
- verbose_name_plural = _("Fixed price benefits")
-
- def apply(self, basket, condition, offer): # noqa (too complex (10))
- if isinstance(condition, ValueCondition):
- return ZERO_DISCOUNT
-
- # Fetch basket lines that are in the range and available to be used in
- # an offer.
- line_tuples = self.get_applicable_lines(offer, basket,
- range=condition.range)
- if not line_tuples:
- return ZERO_DISCOUNT
-
- # Determine the lines to consume
- num_permitted = int(condition.value)
- num_affected = 0
- value_affected = D('0.00')
- covered_lines = []
- for price, line in line_tuples:
- if isinstance(condition, CoverageCondition):
- quantity_affected = 1
- else:
- quantity_affected = min(
- line.quantity_without_discount,
- num_permitted - num_affected)
- num_affected += quantity_affected
- value_affected += quantity_affected * price
- covered_lines.append((price, line, quantity_affected))
- if num_affected >= num_permitted:
- break
- discount = max(value_affected - self.value, D('0.00'))
- if not discount:
- return ZERO_DISCOUNT
-
- # Apply discount to the affected lines
- discount_applied = D('0.00')
- last_line = covered_lines[-1][1]
- for price, line, quantity in covered_lines:
- if line == last_line:
- # If last line, we just take the difference to ensure that
- # rounding doesn't lead to an off-by-one error
- line_discount = discount - discount_applied
- else:
- line_discount = self.round(
- discount * (price * quantity) / value_affected)
- apply_discount(line, line_discount, quantity)
- discount_applied += line_discount
- return BasketDiscount(discount)
-
-
- class MultibuyDiscountBenefit(Benefit):
- _description = _("Cheapest product from %(range)s is free")
-
- @property
- def name(self):
- return self._description % {
- 'range': self.range.name.lower()}
-
- @property
- def description(self):
- return self._description % {
- 'range': range_anchor(self.range)}
-
- class Meta:
- proxy = True
- verbose_name = _("Multibuy discount benefit")
- verbose_name_plural = _("Multibuy discount benefits")
-
- def apply(self, basket, condition, offer):
- line_tuples = self.get_applicable_lines(offer, basket)
- if not line_tuples:
- return ZERO_DISCOUNT
-
- # Cheapest line gives free product
- discount, line = line_tuples[0]
- apply_discount(line, discount, 1)
-
- affected_lines = [(line, discount, 1)]
- condition.consume_items(offer, basket, affected_lines)
-
- return BasketDiscount(discount)
-
-
- # =================
- # Shipping benefits
- # =================
-
-
- class ShippingBenefit(Benefit):
-
- def apply(self, basket, condition, offer):
- condition.consume_items(offer, basket, affected_lines=())
- return SHIPPING_DISCOUNT
-
- class Meta:
- proxy = True
-
-
- class ShippingAbsoluteDiscountBenefit(ShippingBenefit):
- _description = _("%(amount)s off shipping cost")
-
- @property
- def description(self):
- return self._description % {
- 'amount': currency(self.value)}
-
- class Meta:
- proxy = True
- verbose_name = _("Shipping absolute discount benefit")
- verbose_name_plural = _("Shipping absolute discount benefits")
-
- def shipping_discount(self, charge):
- return min(charge, self.value)
-
-
- class ShippingFixedPriceBenefit(ShippingBenefit):
- _description = _("Get shipping for %(amount)s")
-
- @property
- def description(self):
- return self._description % {
- 'amount': currency(self.value)}
-
- class Meta:
- proxy = True
- verbose_name = _("Fixed price shipping benefit")
- verbose_name_plural = _("Fixed price shipping benefits")
-
- def shipping_discount(self, charge):
- if charge < self.value:
- return D('0.00')
- return charge - self.value
-
-
- class ShippingPercentageDiscountBenefit(ShippingBenefit):
- _description = _("%(value)s%% off of shipping cost")
-
- @property
- def description(self):
- return self._description % {
- 'value': self.value}
-
- class Meta:
- proxy = True
- verbose_name = _("Shipping percentage discount benefit")
- verbose_name_plural = _("Shipping percentage discount benefits")
-
- def shipping_discount(self, charge):
- discount = charge * self.value / D('100.0')
- return discount.quantize(D('0.01'))
-
-
- class RangeProductFileUpload(models.Model):
- range = models.ForeignKey('offer.Range', related_name='file_uploads',
- verbose_name=_("Range"))
- filepath = models.CharField(_("File Path"), max_length=255)
- size = models.PositiveIntegerField(_("Size"))
- uploaded_by = models.ForeignKey(AUTH_USER_MODEL,
- verbose_name=_("Uploaded By"))
- date_uploaded = models.DateTimeField(_("Date Uploaded"), auto_now_add=True)
-
- PENDING, FAILED, PROCESSED = 'Pending', 'Failed', 'Processed'
- choices = (
- (PENDING, PENDING),
- (FAILED, FAILED),
- (PROCESSED, PROCESSED),
- )
- status = models.CharField(_("Status"), max_length=32, choices=choices,
- default=PENDING)
- error_message = models.CharField(_("Error Message"), max_length=255,
- blank=True)
-
- # Post-processing audit fields
- date_processed = models.DateTimeField(_("Date Processed"), null=True)
- num_new_skus = models.PositiveIntegerField(_("Number of New SKUs"),
- null=True)
- num_unknown_skus = models.PositiveIntegerField(_("Number of Unknown SKUs"),
- null=True)
- num_duplicate_skus = models.PositiveIntegerField(
- _("Number of Duplicate SKUs"), null=True)
-
- class Meta:
- ordering = ('-date_uploaded',)
- verbose_name = _("Range Product Uploaded File")
- verbose_name_plural = _("Range Product Uploaded Files")
-
- @property
- def filename(self):
- return os.path.basename(self.filepath)
-
- def mark_as_failed(self, message=None):
- self.date_processed = now()
- self.error_message = message
- self.status = self.FAILED
- self.save()
-
- def mark_as_processed(self, num_new, num_unknown, num_duplicate):
- self.status = self.PROCESSED
- self.date_processed = now()
- self.num_new_skus = num_new
- self.num_unknown_skus = num_unknown
- self.num_duplicate_skus = num_duplicate
- self.save()
-
- def was_processing_successful(self):
- return self.status == self.PROCESSED
-
- def process(self):
- """
- Process the file upload and add products to the range
- """
- all_ids = set(self.extract_ids())
- products = self.range.included_products.all()
- existing_skus = products.values_list('stockrecord__partner_sku',
- flat=True)
- existing_skus = set(filter(bool, existing_skus))
- existing_upcs = products.values_list('upc', flat=True)
- existing_upcs = set(filter(bool, existing_upcs))
- existing_ids = existing_skus.union(existing_upcs)
- new_ids = all_ids - existing_ids
-
- Product = models.get_model('catalogue', 'Product')
- products = Product._default_manager.filter(
- models.Q(stockrecord__partner_sku__in=new_ids) |
- models.Q(upc__in=new_ids))
- for product in products:
- self.range.add_product(product)
-
- # Processing stats
- found_skus = products.values_list('stockrecord__partner_sku',
- flat=True)
- found_skus = set(filter(bool, found_skus))
- found_upcs = set(filter(bool, products.values_list('upc', flat=True)))
- found_ids = found_skus.union(found_upcs)
- missing_ids = new_ids - found_ids
- dupes = set(all_ids).intersection(existing_ids)
-
- self.mark_as_processed(products.count(), len(missing_ids), len(dupes))
-
- def extract_ids(self):
- """
- Extract all SKU- or UPC-like strings from the file
- """
- for line in open(self.filepath, 'r'):
- for id in re.split('[^\w:\.-]', line):
- if id:
- yield id
-
- def delete_file(self):
- os.unlink(self.filepath)
|