class Licence(TranslatableModel): """ Licence model. Instances of this models should only be created by administrators. """ name = TranslatedField() content = TranslatedField() logo = FilerImageField(verbose_name=_("logo"), on_delete=models.PROTECT, related_name="licence") url = models.CharField(_("url"), blank=True, max_length=255) # Deprecated non-translated fields for name & content # Kept around to avoid a breaking change wrt. blue-green deployments name_deprecated = models.CharField(_("name"), db_column="name", max_length=200) content_deprecated = models.TextField(_("content"), blank=False, db_column="content", default="") class Meta: db_table = "richie_licence" verbose_name = _("licence") def __str__(self): """Human representation of a licence.""" return "{model}: {name}".format(model=self._meta.verbose_name.title(), name=self.name)
class SalesUnit(_ShortNameToSymbol, TranslatableShuupModel): identifier = InternalIdentifierField(unique=True) decimals = models.PositiveSmallIntegerField( default=0, verbose_name=_(u"allowed decimal places"), help_text=_( "The number of decimal places allowed by this sales unit." "Set this to a value greater than zero if products with this sales unit can be sold in fractional quantities." )) name = TranslatedField() symbol = TranslatedField() class Meta: verbose_name = _('sales unit') verbose_name_plural = _('sales units') def __str__(self): return force_text( self.safe_translation_getter("name", default=self.identifier) or "") @property def allow_fractions(self): return self.decimals > 0 @cached_property def quantity_step(self): """ Get the quantity increment for the amount of decimals this unit allows. For zero decimals, this will be 1; for one decimal, 0.1; etc. :return: Decimal in (0..1]. :rtype: Decimal """ # This particular syntax (`10 ^ -n`) is the same that `bankers_round` uses # to figure out the quantizer. return Decimal(10)**(-int(self.decimals)) def round(self, value): return bankers_round(parse_decimal_string(value), self.decimals) @property def display_unit(self): """ Default display unit of this sales unit. Get a `DisplayUnit` object, which has this sales unit as its internal unit and is marked as a default, or if there is no default display unit for this sales unit, then a proxy object. The proxy object has the same display unit interface and mirrors the properties of the sales unit, such as symbol and decimals. :rtype: DisplayUnit """ return (get_display_unit(self) if self.pk else None) or SalesUnitAsDisplayUnit(self)
class Post(TranslatableModel): slug = models.SlugField(unique=True, max_length=250) title = TranslatedField() body = TranslatedField() # translations = TranslatedFields( # title=models.CharField(max_length=200, unique=True, ), # body = models.TextField(max_length=5000) # ) def __str__(self): return self.slug
class Traduction(TranslatableModel): title = TranslatedField(any_language=True,) slug = TranslatedField() date_add = models.DateTimeField(auto_now_add=True, null=True) date_update = models.DateTimeField(auto_now=True, null=True) status = models.BooleanField(default=True, null=True) class Meta(): verbose_name = 'Traduction' verbose_name_plural = 'Traductions' def __unicode__(self): return self.title
class WaivingCostBehaviorComponent(TranslatableServiceBehaviorComponent): name = _("Waiving cost") help_text = _("Add cost to price of the service if total price " "of products is less than a waive limit.") price_value = MoneyValueField(help_text=_( "The cost to apply to this service if the total price is below the waive limit." )) waive_limit_value = MoneyValueField(help_text=_( "The total price of products at which this service cost is waived.")) description = TranslatedField(any_language=True) translations = TranslatedFields(description=models.CharField( max_length=100, blank=True, verbose_name=_("description"), help_text=_( "The order line text to display when this behavior is applied.")), ) def get_costs(self, service, source): waive_limit = source.create_price(self.waive_limit_value) product_total = source.total_price_of_products price = source.create_price(self.price_value) description = self.safe_translation_getter('description') zero_price = source.create_price(0) if product_total and product_total >= waive_limit: yield ServiceCost(zero_price, description, base_price=price) else: yield ServiceCost(price, description)
class WeightBasedPriceRange(TranslatableModel): component = models.ForeignKey("WeightBasedPricingBehaviorComponent", related_name="ranges", on_delete=models.CASCADE) min_value = MeasurementField( unit="g", verbose_name=_("min weight (g)"), blank=True, null=True, help_text=_("The minimum weight, in grams, for this price to apply.")) max_value = MeasurementField( unit="g", verbose_name=_("max weight (g)"), blank=True, null=True, help_text=_( "The maximum weight, in grams, before this price no longer applies." )) price_value = MoneyValueField(help_text=_( "The cost to apply to this service when the weight criteria is met.")) description = TranslatedField(any_language=True) translations = TranslatedFields(description=models.CharField( max_length=100, blank=True, verbose_name=_("description"), help_text=_( "The order line text to display when this behavior is applied.")), ) def matches_to_value(self, value): return _is_in_range(value, self.min_value, self.max_value)
class Picture(TranslatableModel): """Picture database model.""" image_nr = models.IntegerField(help_text="Just a dummy number") caption = TranslatedField() class Meta: verbose_name = _("picture") verbose_name_plural = _("pictures") def __str__(self): return self.caption
class ExperimentChallengeTimelineEntry(TimeStampedModel, TranslatableModel): date = models.DateField(verbose_name=_('date'), ) content = TranslatedField() experiment_challenge = models.ForeignKey( on_delete=models.CASCADE, to='experiments.ExperimentChallenge', verbose_name=_('experiment challenge'), ) class Meta: ordering = ('date', 'created_at') verbose_name = _('experiment challenge timeline entry') verbose_name_plural = _('experiment challenge timeline entries')
class FixedCostBehaviorComponent(TranslatableServiceBehaviorComponent): name = _("Fixed cost") help_text = _("Add fixed cost to price of the service.") price_value = MoneyValueField() description = TranslatedField(any_language=True) translations = TranslatedFields(description=models.CharField( max_length=100, blank=True, verbose_name=_("description")), ) def get_costs(self, service, source): price = source.create_price(self.price_value) description = self.safe_translation_getter('description') yield ServiceCost(price, description)
class WeightBasedPriceRange(TranslatableModel): component = models.ForeignKey("WeightBasedPricingBehaviorComponent", related_name="ranges", on_delete=models.CASCADE) min_value = MeasurementField(unit="g", verbose_name=_("min weight"), blank=True, null=True) max_value = MeasurementField(unit="g", verbose_name=_("max weight"), blank=True, null=True) price_value = MoneyValueField() description = TranslatedField(any_language=True) translations = TranslatedFields(description=models.CharField( max_length=100, blank=True, verbose_name=_("description")), ) def matches_to_value(self, value): return _is_in_range(value, self.min_value, self.max_value)
class FixedCostBehaviorComponent(TranslatableServiceBehaviorComponent): name = _("Fixed cost") help_text = _("Add a fixed cost to the price of the service.") price_value = MoneyValueField( help_text=_("The fixed cost to apply to this service.")) description = TranslatedField(any_language=True) translations = TranslatedFields(description=models.CharField( max_length=100, blank=True, verbose_name=_("description"), help_text=_( "The order line text to display when this behavior is applied."), ), ) def get_costs(self, service, source): price = source.create_price(self.price_value) description = self.safe_translation_getter("description") yield ServiceCost(price, description)
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 a subclass of `Service`. """ identifier = InternalIdentifierField(unique=True) enabled = models.BooleanField( default=True, verbose_name=_("enabled"), help_text= _("Enable 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.")), ) shops = models.ManyToManyField( "shuup.Shop", verbose_name=_("shops"), related_name="service_providers", help_text= _("This service provider will be available only for order sources of the given shop. " "If blank, this service provider is available for any order source." ), blank=True, ) supplier = models.ForeignKey( "shuup.Supplier", on_delete=models.CASCADE, verbose_name=_("supplier"), related_name="service_providers", help_text= _("This service provider will be available only for order sources that contain " "all items from the configured supplier. If blank, this service provider is " "available for any order source."), blank=True, null=True, ) #: Model class of the provided services (subclass of `Service`) service_model = None def get_service_choices(self): """ Get all service choices of this provider. Subclasses should implement this method. :rtype: list[ServiceChoice] """ raise NotImplementedError def create_service(self, choice_identifier, **kwargs): """ Create a service for a given choice identifier. Subclass implementation may attach some `behavior components <ServiceBehaviorComponent>` to the created service. Subclasses should provide implementation for `_create_service` or override it. 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 a given choice identifier. :type choice_identifier: str :rtype: shuup.core.models.Service """ raise NotImplementedError def get_effective_name(self, service, source): """ Get effective name of the service for a 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 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=_( "Enable this if this service should be selectable on checkout."), ) shop = models.ForeignKey(on_delete=models.CASCADE, to=Shop, verbose_name=_("shop"), help_text=_("The shop for this service.")) supplier = models.ForeignKey( "shuup.Supplier", verbose_name=_("supplier"), on_delete=models.CASCADE, help_text=_( "The supplier for this service. This service will be available only for order sources " "that contain all items from this supplier."), null=True, blank=True, ) 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. Define by searching for `Tax Classes`." ), ) behavior_components = models.ManyToManyField( "ServiceBehaviorComponent", verbose_name=_("behavior components")) labels = models.ManyToManyField("Label", blank=True, verbose_name=_("labels")) objects = ServiceQuerySet.as_manager() class Meta: abstract = True @property def provider(self): """ :rtype: shuup.core.models.ServiceProvider """ return getattr(self, self.provider_attr) def get_effective_name(self, source): """ Get an effective name of the service for a 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) def is_available_for(self, source): """ Return true if service is available for a given source. :type source: shuup.core.order_creator.OrderSource :rtype: bool """ return not any(self.get_unavailability_reasons(source)) def get_unavailability_reasons(self, source): """ Get reasons of being unavailable for a 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 def get_total_cost(self, source): """ Get total cost of this service for items in a given source. :type source: shuup.core.order_creator.OrderSource :rtype: PriceInfo """ return _sum_costs(self.get_costs(source), source) def get_costs(self, source): """ Get costs of this service for items in a 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 def get_lines(self, source): """ Get lines for a given source. Lines are created based on costs. Costs without descriptions are combined to a 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, supplier=self.supplier, shop=self.shop, ) def _generate_line_id(self, num): return "%s-%02d-%s" % (self.line_type.name.lower(), num, uuid4().hex) def _make_sure_is_usable(self): if not self.provider: raise ValueError("Error! %r has no %s." % (self, self.provider_attr)) if not self.enabled: raise ValueError("Error! %r is disabled." % (self, )) if not self.provider.enabled: raise ValueError("Error! %s of %r is disabled." % (self.provider_attr, self))
class ServiceProvider(PolymorphicTranslatableShoopModel): """ 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")) 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")), ) #: Model class of the provided services (subclass of `Service`) service_model = None def get_service_choices(self): """ Get all service choices of this provider. Subclasses should implement this method. :rtype: list[ServiceChoice] """ raise NotImplementedError 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: shoop.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: shoop.core.models.Service """ raise NotImplementedError 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: shoop.core.models.Service :type source: shoop.core.order_creator.OrderSource :rtype: str """ return service.name
class CourseRun(TranslatableModel): """ The course run represents and records the occurence of a course between a start and an end date. """ direct_course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name="runs") # We register the foreign key in "draft_course_run" and not in "public_course_run" # so that the public course run gets deleted by cascade in the database when the # draft page is deleted. Doing it the other way would be fragile. draft_course_run = models.OneToOneField( "self", on_delete=models.CASCADE, null=True, editable=False, related_name="public_course_run", ) sync_mode = models.CharField( max_length=20, choices=CourseRunSyncMode.choices, default=CourseRunSyncMode.MANUAL, ) title = TranslatedField() resource_link = models.CharField(_("resource link"), max_length=200, blank=True, null=True) start = models.DateTimeField(_("course start"), blank=True, null=True) end = models.DateTimeField(_("course end"), blank=True, null=True) enrollment_start = models.DateTimeField(_("enrollment start"), blank=True, null=True) enrollment_end = models.DateTimeField(_("enrollment end"), blank=True, null=True) languages = MultiSelectField( max_choices=50, max_length=255, # MySQL does not allow max_length > 255 # Language choices are made lazy so that we can override them in our tests. # When set directly, they are evaluated too early and can't be changed with the # "override_settings" utility. choices=lazy(lambda: ALL_LANGUAGES, tuple)(), help_text=_( "The list of languages in which the course content is available."), ) enrollment_count = models.PositiveIntegerField( _("enrollment count"), default=0, blank=True, help_text=_("The number of enrolled students"), ) class Meta: db_table = "richie_course_run" verbose_name = _("course run") verbose_name_plural = _("course runs") def __str__(self): """Human representation of a course run.""" start = f"{self.start:%y/%m/%d %H:%M} - " if self.start else "" return f"Course run {self.id!s} starting {start:s}" def copy_translations(self, oldinstance, language=None): """Copy translation objects for a language if provided or for all languages.""" query = CourseRunTranslation.objects.filter(master=oldinstance) if language: query = query.filter(language_code=language) for translation_object in query: try: target_pk = CourseRunTranslation.objects.filter( master=self, language_code=translation_object.language_code ).values_list("pk", flat=True)[0] except IndexError: translation_object.pk = None else: translation_object.pk = target_pk translation_object.master = self translation_object.save() def mark_course_dirty(self): """ Mark the related course page as dirty if the course run has changed since it was last published, so that the modifications can be checked and confirmed by a reviewer. """ try: public_instance = self.__class__.objects.get( draft_course_run__pk=self.pk) except self.__class__.DoesNotExist: # This is a new instance, mark page dirty in all languages unless # the course run is yet to be scheduled (hidden from public page in this case) if self.state["priority"] < CourseState.TO_BE_SCHEDULED: self.direct_course.extended_object.title_set.update( publisher_state=PUBLISHER_STATE_DIRTY) return is_visible = ( self.state["priority"] < CourseState.TO_BE_SCHEDULED or public_instance.state["priority"] < CourseState.TO_BE_SCHEDULED) # Mark the related course page dirty if the course run content has changed # Break out of the for loop as soon as we found a difference for field in self._meta.fields: if field.name == "direct_course": if (public_instance.direct_course.draft_extension != self.direct_course and is_visible): self.direct_course.extended_object.title_set.update( publisher_state=PUBLISHER_STATE_DIRTY ) # mark target page dirty in all languages page = public_instance.direct_course.draft_extension.extended_object page.title_set.update( publisher_state=PUBLISHER_STATE_DIRTY ) # mark source page dirty in all languages break elif (field.editable and not field.auto_created and getattr( public_instance, field.name) != getattr(self, field.name) and is_visible): self.direct_course.extended_object.title_set.update( publisher_state=PUBLISHER_STATE_DIRTY ) # mark page dirty in all languages break def save(self, *args, **kwargs): """Enforce validation each time an instance is saved.""" self.full_clean() super().save(*args, **kwargs) # pylint: disable=signature-differs def delete(self, *args, **kwargs): """ Mark the related course page as dirty if the course about to be deleted was published and visible (not to be scheduled). """ try: # pylint: disable=no-member public_course_run = self.public_course_run except CourseRun.DoesNotExist: pass else: if public_course_run.state[ "priority"] < CourseState.TO_BE_SCHEDULED: self.direct_course.extended_object.title_set.update( publisher_state=PUBLISHER_STATE_DIRTY ) # mark page dirty in all languages return super().delete(*args, **kwargs) # pylint: disable=too-many-return-statements @staticmethod def compute_state(start, end, enrollment_start, enrollment_end): """ Compute at the current time the state of a course run that would have the dates passed in argument. A static method not using the instance allows to call it with an Elasticsearch result. """ if not start or not enrollment_start: return CourseState(CourseState.TO_BE_SCHEDULED) # course run end dates are not required and should default to forever # e.g. a course run with no end date is presumed to be always on-going end = end or MAX_DATE enrollment_end = enrollment_end or MAX_DATE now = timezone.now() if start < now: if end > now: if enrollment_end > now: # ongoing open return CourseState(CourseState.ONGOING_OPEN, enrollment_end) # ongoing closed return CourseState(CourseState.ONGOING_CLOSED) if enrollment_start < now < enrollment_end: # archived open return CourseState(CourseState.ARCHIVED_OPEN, enrollment_end) # archived closed return CourseState(CourseState.ARCHIVED_CLOSED) if enrollment_start > now: # future not yet open return CourseState(CourseState.FUTURE_NOT_YET_OPEN, start) if enrollment_end > now: # future open return CourseState(CourseState.FUTURE_OPEN, start) # future already closed return CourseState(CourseState.FUTURE_CLOSED) @property def state(self): """Return the state of the course run at the current time.""" return self.compute_state(self.start, self.end, self.enrollment_start, self.enrollment_end) def get_course(self): """Get the course for this course run.""" is_draft = self.direct_course.extended_object.publisher_is_draft ancestor_nodes = self.direct_course.extended_object.node.get_ancestors( ) return Course.objects.filter( # Joining on `cms_pages` generate duplicates for courses that are under a parent page # when this page exists both in draft and public versions. We need to exclude the # parent public page to avoid this duplication Q(extended_object__node__cms_pages__publisher_is_draft=is_draft ) # course has a parent | Q(extended_object__node__isnull=True), # course has no parent # Target courses that are ancestors of the course related to the course run Q(id=self.direct_course_id) | Q(extended_object__node__in=ancestor_nodes), # Exclude snapshots extended_object__node__parent__cms_pages__course__isnull= True, # exclude snapshots # Get the course in the same version as the course run extended_object__publisher_is_draft=is_draft, ).distinct()[0] @property def safe_title(self): """ Access the `title` translatable field from the `CourseRunTranslation` on a safe way. """ try: return self.title except ObjectDoesNotExist: return None
class SalesUnit(_ShortNameToSymbol, TranslatableE-CommerceModel): identifier = InternalIdentifierField(unique=True) decimals = models.PositiveSmallIntegerField(default=0, verbose_name=_(u"allowed decimal places"), help_text=_( "The number of decimal places allowed by this sales unit." "Set this to a value greater than zero if products with this sales unit can be sold in fractional quantities" )) name = TranslatedField() symbol = TranslatedField() class Meta: verbose_name = _('sales unit') verbose_name_plural = _('sales units') def __str__(self): return force_text(self.safe_translation_getter("name", default=self.identifier) or "") @property def allow_fractions(self): return self.decimals > 0 @cached_property def quantity_step(self): """ Get the quantity increment for the amount of decimals this unit allows. For 0 decimals, this will be 1; for 1 decimal, 0.1; etc. :return: Decimal in (0..1] :rtype: Decimal """ # This particular syntax (`10 ^ -n`) is the same that `bankers_round` uses # to figure out the quantizer. return Decimal(10) ** (-int(self.decimals)) def round(self, value): return bankers_round(parse_decimal_string(value), self.decimals) @property def display_unit(self): """ Default display unit of this sales unit. Get a `DisplayUnit` object, which has this sales unit as its internal unit and is marked as a default, or if there is no default display unit for this sales unit, then a proxy object. The proxy object has the same display unit interface and mirrors the properties of the sales unit, such as symbol and decimals. :rtype: DisplayUnit """ cache_key = "display_unit:sales_unit_{}_default_display_unit".format(self.pk) default_display_unit = cache.get(cache_key) if default_display_unit is None: default_display_unit = self.display_units.filter(default=True).first() # Set 0 to cache to prevent None values, which will not be a valid cache value # 0 will be invalid below, hence we prevent another query here cache.set(cache_key, default_display_unit or 0) return default_display_unit or SalesUnitAsDisplayUnit(self)
class Service(TranslatableShoopModel): """ 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")) shop = models.ForeignKey(Shop, verbose_name=_("shop")) 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")) behavior_components = models.ManyToManyField( 'ServiceBehaviorComponent', verbose_name=_("behavior components")) objects = ServiceQuerySet.as_manager() class Meta: abstract = True @property def provider(self): """ :rtype: shoop.core.models.ServiceProvider """ return getattr(self, self.provider_attr) def get_checkout_phase(self, **kwargs): """ :rtype: shoop.core.front.checkout.CheckoutPhaseViewMixin|None """ return self.provider.get_checkout_phase(service=self, **kwargs) 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 `~shoop.core.models.ServiceProvider.get_effective_name` method, then this can be different. :type source: shoop.core.order_creator.OrderSource :rtype: str """ if not self.provider: return self.name return self.provider.get_effective_name(self, source) def is_available_for(self, source): """ Return true if service is available for given source. :type source: shoop.core.order_creator.OrderSource :rtype: bool """ return not any(self.get_unavailability_reasons(source)) def get_unavailability_reasons(self, source): """ Get reasons of being unavailable for given source. :type source: shoop.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 != self.shop: 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 def get_total_cost(self, source): """ Get total cost of this service for items in given source. :type source: shoop.core.order_creator.OrderSource :rtype: PriceInfo """ return _sum_costs(self.get_costs(source), source) def get_costs(self, source): """ Get costs of this service for items in given source. :type source: shoop.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 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: shoop.core.order_creator.OrderSource :rtype: Iterable[shoop.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))