class AbstractConditionalOffer(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 necessarily the same as the number of orders that can use it. # Also see max_basket_applications. 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). Often, an offer should only be # usable once per basket/order, so this field will commonly be set to 1. 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 # These fields are used to enforce the limits set by the # max_* fields above. 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: abstract = True 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(AbstractConditionalOffer, 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() unsuspend.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 results.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 queryset = Product.browsable else: queryset = cond_range.all_products() return queryset.filter(is_discountable=True).exclude( structure=Product.CHILD)
class AbstractConditionalOffer(models.Model): """ A conditional offer (eg buy 1, get 10% off) 有条件的报价(例如买1,获得10%的折扣) """ 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. # 报价有几种不同的类型: # (a)提供给网站上所有客户的报价。3比2报价。 # (b)与优惠券相关联的报价,只有在优惠券适用于购物篮后才可使用 # (c)与用户相关的报价。 例如,所有学生都可享受10%的折扣。 需要对应用此报价 # 的代码进行编码 # (d)会话提供 - 在某些触发事件之后,这些提供暂时可供用户使用。 例如,来自某 # 个联盟网站的用户可获得10%的折扣。 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) exclusive = models.BooleanField( _("Exclusive offer"), help_text=_("Exclusive offers cannot be combined on the same items"), # 独家报价不能组合在同一项目上 default=True ) # 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', on_delete=models.CASCADE, related_name='offers', verbose_name=_("Condition")) benefit = models.ForeignKey( 'offer.Benefit', on_delete=models.CASCADE, related_name='offers', 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, help_text=_("Offers are active from the start date. " "Leave this empty if the offer has no start date.")) # 报价从开始日期起生效,如果报价没有开始日期,则空。 end_datetime = models.DateTimeField( _("End date"), blank=True, null=True, help_text=_("Offers are active until the end date. " "Leave this empty if the offer has no expiry 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 necessarily the same as the number of orders that can use it. # Also see max_basket_applications. # 使用此字段可限制此要约的总计应用次数。 请注意,单个订单可以多次应用要约, # 因此这不一定与可以使用它的订单数量相同。 另请参见max_basket_applications。 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). Often, an offer should only be # usable once per basket/order, so this field will commonly be set to 1. # 使用此字段可限制此优惠可应用于购物篮的次数(因此也可以是单个订单)。 通常, # 报价每个购物篮/订单只能使用一次,因此该字段通常设置为1。 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 跟踪 # These fields are used to enforce the limits set by the # max_* fields above. # 这些字段用于强制执行上述max_ *字段设置的限制。 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: abstract = True app_label = 'offer' ordering = ['-priority', 'pk'] 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().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() unsuspend.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 someone'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 # 退回所有产品 queryset = Product.browsable else: queryset = cond_range.all_products() return queryset.filter(is_discountable=True).exclude( structure=Product.CHILD)