| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469 |
- from decimal import Decimal as D, ROUND_DOWN, ROUND_UP
-
- from django.core import exceptions
- from django.db.models import get_model
- from django.template.defaultfilters import date
- from django.db import models
- from django.utils.timezone import now
- from django.utils.translation import ungettext, ugettext 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.utils import slugify
- from oscar.apps.offer.managers import ActiveOfferManager
- from oscar.templatetags.currency_filters import currency
- from oscar.models.fields import PositiveDecimalField, ExtendedURLField
-
-
- def load_proxy(proxy_class):
- module, classname = proxy_class.rsplit('.', 1)
- try:
- mod = import_module(module)
- except ImportError, 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 '<a href="%s">%s</a>' % (
- reverse('dashboard:range-update', kwargs={'pk': range.pk}),
- range.name)
-
-
- 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 = models.SlugField(_("Slug"), max_length=128, unique=True, null=True)
- 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 = 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:
- ordering = ['-priority']
- verbose_name = _("Conditional offer")
- verbose_name_plural = _("Conditional offers")
-
- # The way offers are looked up involves the fields (offer_type, status,
- # start_datetime, end_datetime). Ideally, you want a DB index that
- # covers these 4 fields (will add support for this in Django 1.5)
-
- def save(self, *args, **kwargs):
- if not self.slug:
- self.slug = slugify(self.name)
-
- # 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 __unicode__(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 0
- return self.get_max_applications(user) > 0
-
- def is_condition_satisfied(self, basket):
- return self.condition.proxy().is_satisfied(basket)
-
- def is_condition_partially_satisfied(self, basket):
- return self.condition.proxy().is_partially_satisfied(basket)
-
- def get_upsell_message(self, basket):
- return self.condition.proxy().get_upsell_message(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):
- """
- Applies any deferred benefits. These are things like adding loyalty
- points to somone's account.
- """
- return self.benefit.proxy().apply_deferred(basket)
-
- 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 = models.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):
- 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 format_datetime(dt):
- # Only show hours/minutes if they have been specified
- if dt.hour == 0 and dt.minute == 0:
- return date(dt, settings.DATE_FORMAT)
- return date(dt, 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': format_datetime(self.start_datetime),
- 'end': format_datetime(self.end_datetime)}
- is_satisfied = self.start_datetime <= today <= self.end_datetime
- elif self.start_datetime:
- desc = _("Available from %(start)s") % {
- 'start': format_datetime(self.start_datetime)}
- is_satisfied = today >= self.start_datetime
- elif self.end_datetime:
- desc = _("Available until %(end)s") % {
- 'end': format_datetime(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(
- 'variants', 'images', 'product_class__options',
- 'product_options')
- return cond_range.included_products.filter(is_discountable=True)
-
-
- 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,
- null=True, blank=True)
- value = PositiveDecimalField(_('Value'), decimal_places=2, max_digits=12,
- null=True, blank=True)
-
- proxy_class = models.CharField(_("Custom class"), null=True, blank=True,
- max_length=255, unique=True, default=None)
-
- class Meta:
- verbose_name = _("Condition")
- verbose_name_plural = _("Conditions")
-
- def proxy(self):
- """
- Return the proxy model
- """
- field_dict = dict(self.__dict__)
- for field in 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 __unicode__(self):
- return self.proxy().name
-
- @property
- def name(self):
- return self.description
-
- @property
- def description(self):
- return self.proxy().description
-
- def consume_items(self, basket, affected_lines):
- pass
-
- def is_satisfied(self, 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, 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, basket):
- return None
-
- def can_apply_condition(self, product):
- """
- Determines whether the condition can be applied to a given product
- """
- return (self.range.contains_product(product)
- and product.is_discountable and product.has_stockrecord)
-
- def get_applicable_lines(self, 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():
- product = line.product
- if not self.can_apply_condition(product):
- continue
- price = line.unit_price_incl_tax
- if not price:
- continue
- line_tuples.append((price, line))
- if most_expensive_first:
- return sorted(line_tuples, reverse=True)
- return sorted(line_tuples)
-
-
- 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 = 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 = models.CharField(_("Custom class"), null=True, blank=True,
- max_length=255, unique=True, default=None)
-
- class Meta:
- verbose_name = _("Benefit")
- verbose_name_plural = _("Benefits")
-
- def proxy(self):
- field_dict = dict(self.__dict__)
- for field in 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 __unicode__(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=None):
- return ZERO_DISCOUNT
-
- def apply_deferred(self, basket):
- 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, product):
- """
- Determines whether the benefit can be applied to a given product
- """
- return product.has_stockrecord and product.is_discountable
-
- def get_applicable_lines(self, 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(product)):
- continue
- price = line.unit_price_incl_tax
- 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)
-
- def shipping_discount(self, charge):
- return D('0.00')
-
-
- 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)
- 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"))
- 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 Classes"))
- 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 = models.CharField(
- _("Custom class"), null=True, blank=True, 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
-
- class Meta:
- verbose_name = _("Range")
- verbose_name_plural = _("Ranges")
-
- def __unicode__(self):
- return self.name
-
- def contains_product(self, product):
- """
- 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):
- 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 self.proxy_class is None
-
-
- # ==========
- # 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': unicode(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, basket):
- """
- Determines whether a given basket meets this condition
- """
- num_matches = 0
- for line in basket.all_lines():
- if (self.can_apply_condition(line.product)
- 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.product)
- 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, basket):
- num_matches = self._get_num_matches(basket)
- return 0 < num_matches < self.value
-
- def get_upsell_message(self, 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, 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(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': unicode(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, 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(product) 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(product) and product.id not in covered_ids):
- covered_ids.append(product.id)
- return len(covered_ids)
-
- def get_upsell_message(self, 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, basket):
- return 0 < self._get_num_covered_products(basket) < self.value
-
- def consume_items(self, 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(product):
- 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, basket):
- covered_ids = []
- value = D('0.00')
- for line in basket.all_lines():
- if (self.can_apply_condition(line.product) and line.product.id not in covered_ids):
- covered_ids.append(line.product.id)
- value += line.unit_price_incl_tax
- 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': unicode(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, basket):
- """
- Determine whether a given basket meets this condition
- """
- value_of_matches = D('0.00')
- for line in basket.all_lines():
- product = line.product
- if (self.can_apply_condition(product) and product.has_stockrecord
- and line.quantity_without_discount > 0):
- price = line.unit_price_incl_tax
- 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, 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():
- product = line.product
- if (self.can_apply_condition(product) and product.has_stockrecord
- and line.quantity_without_discount > 0):
- price = line.unit_price_incl_tax
- 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, basket):
- value_of_matches = self._get_value_of_matches(basket)
- return D('0.00') < value_of_matches < self.value
-
- def get_upsell_message(self, basket):
- value_of_matches = self._get_value_of_matches(basket)
- return _('Spend %(value)s more from %(range)s') % {
- 'value': currency(self.value - value_of_matches),
- 'range': self.range}
-
- def consume_items(self, 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 = line.unit_price_incl_tax
- 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(
- 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 = range(0, 3)
- 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
-
-
- # 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=None):
- line_tuples = self.get_applicable_lines(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))
- line.discount(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(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=None):
- # Fetch basket lines that are in the range and available to be used in
- # an offer.
- line_tuples = self.get_applicable_lines(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)
- line.discount(line_discount, qty)
- affected_lines.append((line, line_discount, qty))
- applied_discount += line_discount
-
- condition.consume_items(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 __unicode__(self):
- return self._description % {
- 'amount': currency(self.value)}
-
- @property
- def description(self):
- return self.__unicode__()
-
- class Meta:
- proxy = True
- verbose_name = _("Fixed price benefit")
- verbose_name_plural = _("Fixed price benefits")
-
- def apply(self, basket, condition, offer=None):
- 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(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][0]
- 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)
- line.discount(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=None):
- line_tuples = self.get_applicable_lines(basket)
- if not line_tuples:
- return ZERO_DISCOUNT
-
- # Cheapest line gives free product
- discount, line = line_tuples[0]
- line.discount(discount, 1)
-
- affected_lines = [(line, discount, 1)]
- condition.consume_items(basket, affected_lines)
-
- return BasketDiscount(discount)
-
-
- # =================
- # Shipping benefits
- # =================
-
-
- class ShippingBenefit(Benefit):
-
- def apply(self, basket, condition, offer=None):
- condition.consume_items(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):
- return charge * self.value / D('100.0')
|