Source code for shuup.core.models._service_base

# This file is part of Shuup.
#
# Copyright (c) 2012-2017, Shoop Commerce Ltd. All rights reserved.
#
# This source code is licensed under the OSL-3.0 license found in the
# LICENSE file in the root directory of this source tree.

from __future__ import unicode_literals

import functools
import random

import six
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import ugettext_lazy as _
from filer.fields.image import FilerImageField
from jsonfield import JSONField
from parler.managers import TranslatableQuerySet
from parler.models import TranslatedField, TranslatedFields

from shuup.core.fields import InternalIdentifierField
from shuup.core.pricing import PriceInfo

from ._base import (
    PolymorphicShuupModel, PolymorphicTranslatableShuupModel,
    PolyTransModelBase, TranslatableShuupModel
)
from ._product_shops import ShopProduct
from ._shops import Shop


class ServiceProvider(PolymorphicTranslatableShuupModel):
    """
    Entity that provides services.

    Good examples of service providers are `Carrier` and
    `PaymentProcessor`.

    When subclassing `ServiceProvider`, set value for `service_model`
    class attribute.  It should be a model class which is subclass of
    `Service`.
    """
    identifier = InternalIdentifierField(unique=True)
    enabled = models.BooleanField(default=True, verbose_name=_("enabled"), help_text=_(
            "Check this if this service provider can be used when placing orders"
        )
    )
    name = TranslatedField(any_language=True)
    logo = FilerImageField(
        blank=True, null=True, on_delete=models.SET_NULL,
        verbose_name=_("logo"))

    base_translations = TranslatedFields(
        name=models.CharField(max_length=100, verbose_name=_("name"), help_text=_("The service provider name.")),
    )

    #: Model class of the provided services (subclass of `Service`)
    service_model = None

[docs] def get_service_choices(self): """ Get all service choices of this provider. Subclasses should implement this method. :rtype: list[ServiceChoice] """ raise NotImplementedError
[docs] def create_service(self, choice_identifier, **kwargs): """ Create a service for given choice identifier. Subclass implementation may attach some `behavior components <ServiceBehaviorComponent>` to the created service. Subclasses should provide implementation for `_create_service` or override this. Base class implementation calls the `_create_service` method with resolved `choice_identifier`. :type choice_identifier: str|None :param choice_identifier: Identifier of the service choice to use. If None, use the default service choice. :rtype: shuup.core.models.Service """ if choice_identifier is None: choice_identifier = self.get_service_choices()[0].identifier return self._create_service(choice_identifier, **kwargs)
def _create_service(self, choice_identifier, **kwargs): """ Create a service for given choice identifier. :type choice_identifier: str :rtype: shuup.core.models.Service """ raise NotImplementedError
[docs] def get_effective_name(self, service, source): """ Get effective name of the service for given order source. Base class implementation will just return name of the given service, but that may be changed in a subclass. :type service: shuup.core.models.Service :type source: shuup.core.order_creator.OrderSource :rtype: str """ return service.name
class ServiceChoice(object): """ Choice of service provided by a service provider. """ def __init__(self, identifier, name): """ Initialize service choice. :type identifier: str :param identifier: Internal identifier for the service. Should be unique within a single `ServiceProvider`. :type name: str :param name: Descriptive name of the service in currently active language. """ self.identifier = identifier self.name = name class ServiceQuerySet(TranslatableQuerySet): def enabled(self): no_provider_filter = { self.model.provider_attr: None, } enabled_filter = { self.model.provider_attr + '__enabled': True, 'enabled': True, } return self.exclude(**no_provider_filter).filter(**enabled_filter) def for_shop(self, shop): return self.filter(shop=shop) def available_ids(self, shop, products): """ Retrieve common available services for a shop and product IDs. :param shop_id: Shop ID :type shop_id: int :param product_ids: Product IDs :type product_ids: set[int] :return: Set of service IDs :rtype: set[int] """ shop_product_m2m = self.model.shop_product_m2m shop_product_limiter_attr = "limit_%s" % self.model.shop_product_m2m limiting_products_query = { "shop": shop, "product__in": products, shop_product_limiter_attr: True } enabled_for_shop = self.enabled().for_shop(shop) available_ids = set(enabled_for_shop.values_list("pk", flat=True)) for shop_product in ShopProduct.objects.filter(**limiting_products_query): available_ids &= set(getattr(shop_product, shop_product_m2m).values_list("pk", flat=True)) if not available_ids: # Out of IDs, better just fail fast break return available_ids def available(self, shop, products): return self.filter(pk__in=self.available_ids(shop, products)) class Service(TranslatableShuupModel): """ Abstract base model for services. Each enabled service should be linked to a service provider and should have a choice identifier specified in its `choice_identifier` field. The choice identifier should be valid for the service provider, i.e. it should be one of the `ServiceChoice.identifier` values returned by the `ServiceProvider.get_service_choices` method. """ identifier = InternalIdentifierField(unique=True, verbose_name=_("identifier")) enabled = models.BooleanField(default=False, verbose_name=_("enabled"), help_text=_( "Check this if this service is selectable on checkout." )) shop = models.ForeignKey(Shop, verbose_name=_("shop"), help_text=_("The shop for this service.")) choice_identifier = models.CharField( blank=True, max_length=64, verbose_name=_("choice identifier")) # These are for migrating old methods to new architecture old_module_identifier = models.CharField(max_length=64, blank=True) old_module_data = JSONField(blank=True, null=True) name = TranslatedField(any_language=True) description = TranslatedField() logo = FilerImageField( blank=True, null=True, on_delete=models.SET_NULL, verbose_name=_("logo")) tax_class = models.ForeignKey( 'TaxClass', on_delete=models.PROTECT, verbose_name=_("tax class"), help_text=_( "The tax class to use for this service. Tax classes are defined in Settings - Tax Classes." ) ) behavior_components = models.ManyToManyField( 'ServiceBehaviorComponent', verbose_name=_("behavior components")) objects = ServiceQuerySet.as_manager()
[docs] class Meta: abstract = True
@property def provider(self): """ :rtype: shuup.core.models.ServiceProvider """ return getattr(self, self.provider_attr)
[docs] def get_effective_name(self, source): """ Get effective name of the service for given order source. By default, effective name is the same as name of this service, but if there is a service provider with a custom implementation for `~shuup.core.models.ServiceProvider.get_effective_name` method, then this can be different. :type source: shuup.core.order_creator.OrderSource :rtype: str """ if not self.provider: return self.name return self.provider.get_effective_name(self, source)
[docs] def is_available_for(self, source): """ Return true if service is available for given source. :type source: shuup.core.order_creator.OrderSource :rtype: bool """ return not any(self.get_unavailability_reasons(source))
[docs] def get_unavailability_reasons(self, source): """ Get reasons of being unavailable for given source. :type source: shuup.core.order_creator.OrderSource :rtype: Iterable[ValidationError] """ if not self.provider or not self.provider.enabled or not self.enabled: yield ValidationError(_("%s is disabled") % self, code='disabled') if source.shop.id != self.shop_id: yield ValidationError( _("%s is for different shop") % self, code='wrong_shop') for component in self.behavior_components.all(): for reason in component.get_unavailability_reasons(self, source): yield reason
[docs] def get_total_cost(self, source): """ Get total cost of this service for items in given source. :type source: shuup.core.order_creator.OrderSource :rtype: PriceInfo """ return _sum_costs(self.get_costs(source), source)
[docs] def get_costs(self, source): """ Get costs of this service for items in given source. :type source: shuup.core.order_creator.OrderSource :return: description, price and tax class of the costs :rtype: Iterable[ServiceCost] """ for component in self.behavior_components.all(): for cost in component.get_costs(self, source): yield cost
[docs] def get_lines(self, source): """ Get lines for given source. Lines are created based on costs. Costs without description are combined to single line. :type source: shuup.core.order_creator.OrderSource :rtype: Iterable[shuup.core.order_creator.SourceLine] """ for (num, line_data) in enumerate(self._get_line_data(source), 1): (price_info, tax_class, text) = line_data yield self._create_line(source, num, price_info, tax_class, text)
def _get_line_data(self, source): # Split to costs with and without description costs_with_description = [] costs_without_description = [] for cost in self.get_costs(source): if cost.description: costs_with_description.append(cost) else: assert cost.tax_class is None costs_without_description.append(cost) if not (costs_with_description or costs_without_description): costs_without_description = [ServiceCost(source.create_price(0))] effective_name = self.get_effective_name(source) # Yield the combined cost first if costs_without_description: combined_price_info = _sum_costs(costs_without_description, source) yield (combined_price_info, self.tax_class, effective_name) # Then the costs with description, one line for each cost for cost in costs_with_description: tax_class = (cost.tax_class or self.tax_class) text = _('%(service_name)s: %(sub_item)s') % { 'service_name': effective_name, 'sub_item': cost.description, } yield (cost.price_info, tax_class, text) def _create_line(self, source, num, price_info, tax_class, text): return source.create_line( line_id=self._generate_line_id(num), type=self.line_type, quantity=price_info.quantity, text=text, base_unit_price=price_info.base_unit_price, discount_amount=price_info.discount_amount, tax_class=tax_class, ) def _generate_line_id(self, num): return "%s-%02d-%08x" % ( self.line_type.name.lower(), num, random.randint(0, 0x7FFFFFFF)) def _make_sure_is_usable(self): if not self.provider: raise ValueError('%r has no %s' % (self, self.provider_attr)) if not self.enabled: raise ValueError('%r is disabled' % (self,)) if not self.provider.enabled: raise ValueError( '%s of %r is disabled' % (self.provider_attr, self)) def _sum_costs(costs, source): """ Sum price info of given costs and return the sum as PriceInfo. :type costs: Iterable[ServiceCost] :type source: shuup.core.order_creator.OrderSource :rtype: PriceInfo """ def plus(pi1, pi2): assert pi1.quantity == pi2.quantity return PriceInfo( pi1.price + pi2.price, pi1.base_price + pi2.base_price, quantity=pi1.quantity, ) zero_price = source.create_price(0) zero_pi = PriceInfo(zero_price, zero_price, quantity=1) return functools.reduce(plus, (x.price_info for x in costs), zero_pi) class ServiceCost(object): """ A cost of a service. One service might have several costs. """ def __init__( self, price, description=None, tax_class=None, base_price=None): """ Initialize cost from values. Note: If tax_class is specified, also description must be given. :type price: shuup.core.pricing.Price :type description: str|None :type tax_class: shuup.core.models.TaxClass|None :type base_price: shuup.core.pricing.Price|None """ if tax_class and not description: raise ValueError('Cost with tax class must have description') self.price = price self.description = description self.tax_class = tax_class self.base_price = base_price if base_price is not None else price @property def price_info(self): return PriceInfo(self.price, self.base_price, quantity=1) class ServiceBehaviorComponent(PolymorphicShuupModel): #: Name for the component (lazy translated) name = None #: Help text for the component (lazy translated) help_text = None def __init__(self, *args, **kwargs): if type(self) != ServiceBehaviorComponent and self.name is None: raise TypeError('%s.name is not defined' % type(self).__name__) super(ServiceBehaviorComponent, self).__init__(*args, **kwargs)
[docs] def get_unavailability_reasons(self, service, source): """ :type service: Service :type source: shuup.core.order_creator.OrderSource :rtype: Iterable[ValidationError] """ return ()
[docs] def get_costs(self, service, source): """ Return costs for for this object. This should be implemented in subclass. This method is used to calculate price for ``ShippingMethod`` and ``PaymentMethod`` objects. :type service: Service :type source: shuup.core.order_creator.OrderSource :rtype: Iterable[ServiceCost] """ return ()
[docs] def get_delivery_time(self, service, source): """ :type service: Service :type source: shuup.core.order_creator.OrderSource :rtype: shuup.utils.dates.DurationRange|None """ return None
class TranslatableServiceBehaviorComponent(six.with_metaclass( PolyTransModelBase, ServiceBehaviorComponent, TranslatableShuupModel)): class Meta: abstract = True