class ShipmentProduct(models.Model): shipment = models.ForeignKey(Shipment, related_name='products', on_delete=models.PROTECT, verbose_name=_("shipment")) product = models.ForeignKey("Product", related_name='shipments', on_delete=models.CASCADE, verbose_name=_("product")) quantity = QuantityField(verbose_name=_("quantity")) # volume is m^3, not mm^3, because mm^3 are tiny. like ants. unit_volume = MeasurementField(unit="m3", verbose_name=_("unit volume")) unit_weight = MeasurementField(unit="g", verbose_name=_("unit weight")) class Meta: verbose_name = _('sent product') verbose_name_plural = _('sent products') def __str__(self): # pragma: no cover return "%(quantity)s of '%(product)s' in Shipment #%(shipment_pk)s" % { 'product': self.product, 'quantity': self.quantity, 'shipment_pk': self.shipment_id, } def cache_values(self): prod = self.product self.unit_volume = (prod.width * prod.height * prod.depth) / CUBIC_MM_TO_CUBIC_METERS_DIVISOR self.unit_weight = prod.gross_weight
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 ShipmentProduct(ShuupModel): shipment = models.ForeignKey(Shipment, related_name='products', on_delete=models.PROTECT, verbose_name=_("shipment")) product = models.ForeignKey("Product", related_name='shipments', on_delete=models.CASCADE, verbose_name=_("product")) quantity = QuantityField(verbose_name=_("quantity")) unit_volume = MeasurementField(unit=get_shuup_volume_unit(), verbose_name=_("unit volume ({})".format( get_shuup_volume_unit()))) unit_weight = MeasurementField(unit=settings.SHUUP_MASS_UNIT, verbose_name=_("unit weight ({})".format( settings.SHUUP_MASS_UNIT))) class Meta: verbose_name = _('sent product') verbose_name_plural = _('sent products') def __str__(self): # pragma: no cover return "%(quantity)s of '%(product)s' in Shipment #%(shipment_pk)s" % { 'product': self.product, 'quantity': self.quantity, 'shipment_pk': self.shipment_id, } def cache_values(self): prod = self.product self.unit_volume = prod.width * prod.height * prod.depth self.unit_weight = prod.gross_weight
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 ShippingTableItem(models.Model): table = models.ForeignKey(ShippingTable, verbose_name=_("table")) region = models.ForeignKey(ShippingRegion, verbose_name=_("region")) start_weight = MeasurementField(unit="g", verbose_name=_('start weight (g)')) end_weight = MeasurementField(unit="g", verbose_name=_('end weight (g)')) price = MoneyValueField(verbose_name=_("price")) delivery_time = models.PositiveSmallIntegerField(verbose_name=_("delivery time (days)")) class Meta: verbose_name = _("shipping price table") verbose_name_plural = _("shipping price tables") def __str__(self): return "ID {0} {1} {2} - {3}->{4}: {5}-{6}".format(self.id, self.table, self.region, self.start_weight, self.end_weight, self.price, self.delivery_time)
def test_measurement_field_doesnt_know_bananas(): with pytest.raises(ImproperlyConfigured): scale = MeasurementField(unit="banana")
class ShippingTableBehaviorComponent(ServiceBehaviorComponent): add_delivery_time_days = models.PositiveSmallIntegerField(verbose_name=_("additional delivery time"), default=0, help_text=_("Extra number of days to add.")) add_price = MoneyValueField(verbose_name=_("additional price"), default=Decimal(), help_text=_("Extra amount to add.")) use_cubic_weight = models.BooleanField(verbose_name=_("Use Cubic Weight"), default=False, help_text=_("Enable this to calculate the cubic weight and use " "the heaviest measurement (real weight or cubic weight).")) cubic_weight_factor = models.DecimalField(verbose_name=_("Cubic Weight Factor (cm³)"), decimal_places=2, max_digits=10, default=Decimal(6000), help_text=_("Google it if you don't know what you're doing.")) cubic_weight_exemption = MeasurementField(unit="kg", verbose_name=_("Cubic Weight exemption value (kg)"), decimal_places=3, max_digits=8, default=Decimal(), help_text=_("The Cubic Weight will be considered if the " "sum of all products real weights " "is higher then this value.")) max_package_width = MeasurementField(unit="mm", verbose_name=_("Max package width (mm)"), decimal_places=2, max_digits=7, default=Decimal(), help_text=_("This is only used for Cubic Weight method " "since the order/basket will be splitted into packages " "for volume calculation.")) max_package_height = MeasurementField(unit="mm", verbose_name=_("Max package height (mm)"), decimal_places=2, max_digits=7, default=Decimal(), help_text=_("This is only used for Cubic Weight method " "since the order/basket will be splitted into packages " "for volume calculation.")) max_package_length = MeasurementField(unit="mm", verbose_name=_("Max package length (mm)"), decimal_places=2, max_digits=7, default=Decimal(), help_text=_("This is only used for Cubic Weight method " "since the order/basket will be splitted into packages " "for volume calculation.")) max_package_edges_sum = MeasurementField(unit="mm", verbose_name=_("Max package edge sum (mm)"), decimal_places=2, max_digits=7, default=Decimal(), help_text=_("The max sum of width, height and length of the package. " "This is only used for Cubic Weight method " "since the order/basket will be splitted into packages " "for volume calculation.")) max_package_weight = MeasurementField(unit="kg", verbose_name=_("Max package weight (kg)"), decimal_places=3, max_digits=8, default=Decimal(), help_text=_("This is only used for Cubic Weight method " "since the order/basket will be splitted into packages " "for volume calculation.")) class Meta: abstract = True def get_source_weight(self, source): """ Calculates the source weight (in kg) based on behavior component configuration. """ weight = source.total_gross_weight * G_TO_KG # transform g in kg if self.use_cubic_weight and weight > self.cubic_weight_exemption: # create the packager packager = SimplePackager() # add the constraints, if configured if self.max_package_height and self.max_package_length and \ self.max_package_width and self.max_package_edges_sum: packager.add_constraint(SimplePackageDimensionConstraint( self.max_package_width, self.max_package_length, self.max_package_height, self.max_package_edges_sum )) if self.max_package_weight: packager.add_constraint(WeightPackageConstraint(self.max_package_weight * KG_TO_G)) # split products into packages packages = packager.pack_source(source) # check if some package was created if packages: total_weight = 0 for package in packages: if package.weight > self.cubic_weight_exemption: total_weight = total_weight + (package.volume / self.cubic_weight_factor * G_TO_KG) else: total_weight = total_weight + package.weight * G_TO_KG weight = total_weight return weight def get_available_table_items(self, source): """ Fetches the available table items """ now_dt = now() weight = self.get_source_weight(source) # 1) source total weight must be in a range # 2) enabled tables # 3) enabled table carriers # 4) valid shops # 5) valid date range tables # 6) order by priority # 7) distinct rows qs = ShippingTableItem.objects.select_related('table').filter( end_weight__gte=weight, start_weight__lte=weight, table__enabled=True, table__carrier__enabled=True, table__shops__in=[source.shop] ).filter( Q(Q(table__start_date__lte=now_dt) | Q(table__start_date=None)), Q(Q(table__end_date__gte=now_dt) | Q(table__end_date=None)) ).order_by('-region__priority').distinct() return qs def get_first_available_item(self, source): table_items = self.get_available_table_items(source) for table_item in table_items: # check if the table exclude region is compatible # with the source.. if True, check next one invalid_region = False for excluded_region in table_item.table.excluded_regions.all(): if excluded_region.is_compatible_with(source): invalid_region = True break if invalid_region: continue # a valid table item was found! get out of here if table_item.region.is_compatible_with(source): return table_item def get_unavailability_reasons(self, service, source): table_item = self.get_first_available_item(source) if not table_item: return [ValidationError(_("No table found"))] return () def get_costs(self, service, source): table_item = self.get_first_available_item(source) if table_item: return [ServiceCost(source.create_price(table_item.price + self.add_price))] return () def get_delivery_time(self, service, source): table_item = self.get_first_available_item(source) if table_item: return DurationRange( timedelta(days=(table_item.delivery_time + self.add_delivery_time_days)) ) return None
class Product(TaxableItem, AttributableMixin, TranslatableModel): COMMON_SELECT_RELATED = ("type", "primary_image", "tax_class") # Metadata created_on = models.DateTimeField(auto_now_add=True, editable=False, db_index=True, verbose_name=_('created on')) modified_on = models.DateTimeField(auto_now=True, editable=False, verbose_name=_('modified on')) deleted = models.BooleanField(default=False, editable=False, db_index=True, verbose_name=_('deleted')) # Behavior mode = EnumIntegerField(ProductMode, default=ProductMode.NORMAL, verbose_name=_('mode')) variation_parent = models.ForeignKey("self", null=True, blank=True, related_name='variation_children', on_delete=models.PROTECT, verbose_name=_('variation parent')) stock_behavior = EnumIntegerField( StockBehavior, default=StockBehavior.UNSTOCKED, verbose_name=_('stock'), help_text=_( "Set to stocked if inventory should be managed within Shuup.")) shipping_mode = EnumIntegerField( ShippingMode, default=ShippingMode.SHIPPED, verbose_name=_('shipping mode'), help_text=_("Set to shipped if the product requires shipment.")) sales_unit = models.ForeignKey( "SalesUnit", verbose_name=_('sales unit'), blank=True, null=True, on_delete=models.PROTECT, help_text= _("Select a sales unit for your product. " "This is shown in your store front and is used to determine whether the product can be purchased using " "fractional amounts. Sales units are defined in Products - Sales Units." )) tax_class = models.ForeignKey( "TaxClass", verbose_name=_('tax class'), on_delete=models.PROTECT, help_text= _("Select a tax class for your product. " "The tax class is used to determine which taxes to apply to your product. " "Tax classes are defined in Settings - Tax Classes. " "The rules by which taxes are applied are defined in Settings - Tax Rules." )) # Identification type = models.ForeignKey( "ProductType", related_name='products', on_delete=models.PROTECT, db_index=True, verbose_name=_('product type'), help_text=_( "Select a product type for your product. " "These allow you to configure custom attributes to help with classification and analysis." )) sku = models.CharField( db_index=True, max_length=128, verbose_name=_('SKU'), unique=True, help_text= _("Enter a SKU (Stock Keeping Unit) number for your product. " "This is a product identification code that helps you track it through your inventory. " "People often use the number by the barcode on the product, " "but you can set up any numerical system you want to keep track of products." )) gtin = models.CharField( blank=True, max_length=40, verbose_name=_('GTIN'), help_text=_( "You can enter a Global Trade Item Number. " "This is typically a 14 digit identification number for all of your trade items. " "It can often be found by the barcode.")) barcode = models.CharField( blank=True, max_length=40, verbose_name=_('barcode'), help_text= _("You can enter the barcode number for your product. This is useful for inventory/stock tracking and analysis." )) accounting_identifier = models.CharField( max_length=32, blank=True, verbose_name=_('bookkeeping account')) profit_center = models.CharField(max_length=32, verbose_name=_('profit center'), blank=True) cost_center = models.CharField(max_length=32, verbose_name=_('cost center'), blank=True) # Physical dimensions width = MeasurementField( unit="mm", verbose_name=_('width (mm)'), help_text=_( "Set the measured width of your product or product packaging. " "This will provide customers with your product size and help with calculating shipping costs." )) height = MeasurementField( unit="mm", verbose_name=_('height (mm)'), help_text=_( "Set the measured height of your product or product packaging. " "This will provide customers with your product size and help with calculating shipping costs." )) depth = MeasurementField( unit="mm", verbose_name=_('depth (mm)'), help_text= _("Set the measured depth or length of your product or product packaging. " "This will provide customers with your product size and help with calculating shipping costs." )) net_weight = MeasurementField( unit="g", verbose_name=_('net weight (g)'), help_text=_( "Set the measured weight of your product WITHOUT its packaging. " "This will provide customers with your product weight.")) gross_weight = MeasurementField( unit="g", verbose_name=_('gross weight (g)'), help_text=_( "Set the measured gross Weight of your product WITH its packaging. " "This will help with calculating shipping costs.")) # Misc. manufacturer = models.ForeignKey( "Manufacturer", blank=True, null=True, verbose_name=_('manufacturer'), on_delete=models.PROTECT, help_text= _("Select a manufacturer for your product. These are defined in Products Settings - Manufacturers" )) primary_image = models.ForeignKey( "ProductMedia", null=True, blank=True, related_name="primary_image_for_products", on_delete=models.SET_NULL, verbose_name=_("primary image")) translations = TranslatedFields( name=models.CharField( max_length=256, verbose_name=_('name'), help_text= _("Enter a descriptive name for your product. This will be its title in your store." )), description=models.TextField( blank=True, verbose_name=_('description'), help_text= _("To make your product stand out, give it an awesome description. " "This is what will help your shoppers learn about your products. " "It will also help shoppers find them in the store and on the web." )), short_description=models.CharField( max_length=150, blank=True, verbose_name=_('short description'), help_text= _("Enter a short description for your product. " "The short description will be used to get the attention of your " "customer with a small but precise description of your product." )), slug=models.SlugField( verbose_name=_('slug'), max_length=255, blank=True, null=True, help_text=_( "Enter a URL Slug for your product. This is what your product page URL will be. " "A default will be created using the product name.")), keywords=models.TextField( blank=True, verbose_name=_('keywords'), help_text=_( "You can enter keywords that describe your product. " "This will help your shoppers learn about your products. " "It will also help shoppers find them in the store and on the web." )), status_text=models.CharField( max_length=128, blank=True, verbose_name=_('status text'), help_text=_( 'This text will be shown alongside the product in the shop. ' 'It is useful for informing customers of special stock numbers or preorders. ' '(Ex.: "Available in a month")')), variation_name=models.CharField( max_length=128, blank=True, verbose_name=_('variation name'), help_text=_( "You can enter a name for the variation of your product. " "This could be for example different colors or versions."))) objects = ProductQuerySet.as_manager() class Meta: ordering = ('-id', ) verbose_name = _('product') verbose_name_plural = _('products') def __str__(self): try: return u"%s" % self.name except ObjectDoesNotExist: return self.sku def get_shop_instance(self, shop, allow_cache=False): """ :type shop: shuup.core.models.Shop :rtype: shuup.core.models.ShopProduct """ # FIXME: Temporary removed the cache to prevent parler issues # Uncomment this as soon as https://github.com/shuup/shuup/issues/1323 is fixed # and Django Parler version is bumped with the fix # from shuup.core.utils import context_cache # key, val = context_cache.get_cached_value( # identifier="shop_product", item=self, context={"shop": shop}, allow_cache=allow_cache) # if val is not None: # return val shop_inst = self.shop_products.get(shop_id=shop.id) # context_cache.set_cached_value(key, shop_inst) return shop_inst def get_priced_children(self, context, quantity=1): """ Get child products with price infos sorted by price. :rtype: list[(Product,PriceInfo)] :return: List of products and their price infos sorted from cheapest to most expensive. """ from shuup.core.models import ShopProduct priced_children = [] for child in self.variation_children.all(): try: shop_product = child.get_shop_instance(context.shop) except ShopProduct.DoesNotExist: continue if shop_product.is_orderable(supplier=None, customer=context.customer, quantity=1): priced_children.append( (child, child.get_price_info(context, quantity=quantity))) return sorted(priced_children, key=(lambda x: x[1].price)) def get_cheapest_child_price(self, context, quantity=1): price_info = self.get_cheapest_child_price_info(context, quantity) if price_info: return price_info.price def get_child_price_range(self, context, quantity=1): """ Get the prices for cheapest and the most expensive child The attribute used for sorting is `PriceInfo.price`. Return (`None`, `None`) if `self.variation_children` do not exist. This is because we cannot return anything sensible. :type context: shuup.core.pricing.PricingContextable :type quantity: int :return: a tuple of prices :rtype: (shuup.core.pricing.Price, shuup.core.pricing.Price) """ items = [ c.get_price_info(context, quantity=quantity) for c in self.variation_children.all() ] if not items: return (None, None) infos = sorted(items, key=lambda x: x.price) return (infos[0].price, infos[-1].price) def get_cheapest_child_price_info(self, context, quantity=1): """ Get the `PriceInfo` of the cheapest variation child The attribute used for sorting is `PriceInfo.price`. Return `None` if `self.variation_children` do not exist. This is because we cannot return anything sensible. :type context: shuup.core.pricing.PricingContextable :rtype: shuup.core.pricing.PriceInfo """ items = [ c.get_price_info(context, quantity=quantity) for c in self.variation_children.all() ] if not items: return None return sorted(items, key=lambda x: x.price)[0] def get_price_info(self, context, quantity=1): """ Get `PriceInfo` object for the product in given context. Returned `PriceInfo` object contains calculated `price` and `base_price`. The calculation of prices is handled in the current pricing module. :type context: shuup.core.pricing.PricingContextable :rtype: shuup.core.pricing.PriceInfo """ from shuup.core.pricing import get_price_info return get_price_info(product=self, context=context, quantity=quantity) def get_price(self, context, quantity=1): """ Get price of the product within given context. .. note:: When the current pricing module implements pricing steps, it is possible that ``p.get_price(ctx) * 123`` is not equal to ``p.get_price(ctx, quantity=123)``, since there could be quantity discounts in effect, but usually they are equal. :type context: shuup.core.pricing.PricingContextable :rtype: shuup.core.pricing.Price """ return self.get_price_info(context, quantity).price def get_base_price(self, context, quantity=1): """ Get base price of the product within given context. Base price differs from the (effective) price when there are discounts in effect. :type context: shuup.core.pricing.PricingContextable :rtype: shuup.core.pricing.Price """ return self.get_price_info(context, quantity=quantity).base_price def get_available_attribute_queryset(self): if self.type_id: return self.type.attributes.visible() else: return Attribute.objects.none() def get_available_variation_results(self): """ Get a dict of `combination_hash` to product ID of variable variation results. :return: Mapping of combination hashes to product IDs :rtype: dict[str, int] """ return dict( ProductVariationResult.objects.filter(product=self).filter( status=1).values_list("combination_hash", "result_id")) def get_all_available_combinations(self): """ Generate all available combinations of variation variables. If the product is not a variable variation parent, the iterator is empty. Because of possible combinatorial explosion this is a generator function. (For example 6 variables with 5 options each explodes to 15,625 combinations.) :return: Iterable of combination information dicts. :rtype: Iterable[dict] """ return get_all_available_combinations(self) def clear_variation(self): """ Fully remove variation information. Make this product a non-variation parent. """ self.simplify_variation() for child in self.variation_children.all(): if child.variation_parent_id == self.pk: child.unlink_from_parent() self.verify_mode() self.save() def simplify_variation(self): """ Remove variation variables from the given variation parent, turning it into a simple variation (or a normal product, if it has no children). :param product: Variation parent to not be variable any longer. :type product: shuup.core.models.Product """ ProductVariationVariable.objects.filter(product=self).delete() ProductVariationResult.objects.filter(product=self).delete() self.verify_mode() self.save() @staticmethod def _get_slug_name(self, translation=None): if self.deleted: return None return getattr(translation, "name", self.sku) def save(self, *args, **kwargs): self.clean() if self.net_weight and self.net_weight > 0: self.gross_weight = max(self.net_weight, self.gross_weight) rv = super(Product, self).save(*args, **kwargs) generate_multilanguage_slugs(self, self._get_slug_name) return rv def clean(self): pre_clean.send(type(self), instance=self) super(Product, self).clean() post_clean.send(type(self), instance=self) def delete(self, using=None): raise NotImplementedError( "Not implemented: Use `soft_delete()` for products.") def soft_delete(self, user=None): if not self.deleted: self.deleted = True self.add_log_entry("Deleted.", kind=LogEntryKind.DELETION, user=user) # Bypassing local `save()` on purpose. super(Product, self).save(update_fields=("deleted", )) def verify_mode(self): if ProductPackageLink.objects.filter(parent=self).exists(): self.mode = ProductMode.PACKAGE_PARENT self.external_url = None self.variation_children.clear() elif ProductVariationVariable.objects.filter(product=self).exists(): self.mode = ProductMode.VARIABLE_VARIATION_PARENT elif self.variation_children.exists(): if ProductVariationResult.objects.filter(product=self).exists(): self.mode = ProductMode.VARIABLE_VARIATION_PARENT else: self.mode = ProductMode.SIMPLE_VARIATION_PARENT self.external_url = None ProductPackageLink.objects.filter(parent=self).delete() elif self.variation_parent: self.mode = ProductMode.VARIATION_CHILD ProductPackageLink.objects.filter(parent=self).delete() self.variation_children.clear() self.external_url = None else: self.mode = ProductMode.NORMAL def unlink_from_parent(self): if self.variation_parent: parent = self.variation_parent self.variation_parent = None self.save() parent.verify_mode() self.verify_mode() self.save() ProductVariationResult.objects.filter(result=self).delete() return True def link_to_parent(self, parent, variables=None, combination_hash=None): """ :param parent: The parent to link to. :type parent: Product :param variables: Optional dict of {variable identifier: value identifier} for complex variable linkage :type variables: dict|None :param combination_hash: Optional combination hash (for variable variations), if precomputed. Mutually exclusive with `variables` :type combination_hash: str|None """ if combination_hash: if variables: raise ValueError( "`combination_hash` and `variables` are mutually exclusive" ) variables = True # Simplifies the below invariant checks self._raise_if_cant_link_to_parent(parent, variables) self.unlink_from_parent() self.variation_parent = parent self.verify_mode() self.save() if not parent.is_variation_parent(): parent.verify_mode() parent.save() if variables: if not combination_hash: # No precalculated hash, need to figure that out combination_hash = get_combination_hash_from_variable_mapping( parent, variables=variables) pvr = ProductVariationResult.objects.create( product=parent, combination_hash=combination_hash, result=self) if parent.mode == ProductMode.SIMPLE_VARIATION_PARENT: parent.verify_mode() parent.save() return pvr else: return True def _raise_if_cant_link_to_parent(self, parent, variables): """ Validates relation possibility for `self.link_to_parent()` :param parent: parent product of self :type parent: Product :param variables: :type variables: dict|None """ if parent.is_variation_child(): raise ImpossibleProductModeException(_( "Multilevel parentage hierarchies aren't supported (parent is a child already)" ), code="multilevel") if parent.mode == ProductMode.VARIABLE_VARIATION_PARENT and not variables: raise ImpossibleProductModeException(_( "Parent is a variable variation parent, yet variables were not passed" ), code="no_variables") if parent.mode == ProductMode.SIMPLE_VARIATION_PARENT and variables: raise ImpossibleProductModeException( "Parent is a simple variation parent, yet variables were passed", code="extra_variables") if self.mode == ProductMode.SIMPLE_VARIATION_PARENT: raise ImpossibleProductModeException(_( "Multilevel parentage hierarchies aren't supported (this product is a simple variation parent)" ), code="multilevel") if self.mode == ProductMode.VARIABLE_VARIATION_PARENT: raise ImpossibleProductModeException(_( "Multilevel parentage hierarchies aren't supported (this product is a variable variation parent)" ), code="multilevel") def make_package(self, package_def): if self.mode != ProductMode.NORMAL: raise ImpossibleProductModeException(_( "Product is currently not a normal product, can't turn into package" ), code="abnormal") for child_product, quantity in six.iteritems(package_def): if child_product.pk == self.pk: raise ImpossibleProductModeException( _("Package can't contain itself"), code="content") # :type child_product: Product if child_product.is_variation_parent(): raise ImpossibleProductModeException( _("Variation parents can not belong into a package"), code="abnormal") if child_product.is_container(): raise ImpossibleProductModeException( _("Packages can't be nested"), code="multilevel") if quantity <= 0: raise ImpossibleProductModeException( _("Quantity %s is invalid") % quantity, code="quantity") ProductPackageLink.objects.create(parent=self, child=child_product, quantity=quantity) self.verify_mode() def get_package_child_to_quantity_map(self): if self.is_container(): product_id_to_quantity = dict( ProductPackageLink.objects.filter(parent=self).values_list( "child_id", "quantity")) products = dict((p.pk, p) for p in Product.objects.filter( pk__in=product_id_to_quantity.keys())) return { products[product_id]: quantity for (product_id, quantity) in six.iteritems(product_id_to_quantity) } return {} def is_variation_parent(self): return self.mode in (ProductMode.SIMPLE_VARIATION_PARENT, ProductMode.VARIABLE_VARIATION_PARENT) def is_variation_child(self): return (self.mode == ProductMode.VARIATION_CHILD) def get_variation_siblings(self): return Product.objects.filter( variation_parent=self.variation_parent).exclude(pk=self.pk) def is_package_parent(self): return (self.mode == ProductMode.PACKAGE_PARENT) def is_subscription_parent(self): return (self.mode == ProductMode.SUBSCRIPTION) def is_package_child(self): return ProductPackageLink.objects.filter(child=self).exists() def get_all_package_parents(self): return Product.objects.filter( pk__in=(ProductPackageLink.objects.filter( child=self).values_list("parent", flat=True))) def get_all_package_children(self): return Product.objects.filter( pk__in=(ProductPackageLink.objects.filter( parent=self).values_list("child", flat=True))) def get_public_media(self): return self.media.filter( enabled=True, public=True).exclude(kind=ProductMediaKind.IMAGE) def is_stocked(self): return (self.stock_behavior == StockBehavior.STOCKED) def is_container(self): return (self.is_package_parent() or self.is_subscription_parent())
class Shipment(models.Model): order = models.ForeignKey("Order", related_name='shipments', on_delete=models.PROTECT, verbose_name=_("order")) supplier = models.ForeignKey("Supplier", related_name='shipments', on_delete=models.PROTECT, verbose_name=_("supplier")) created_on = models.DateTimeField(auto_now_add=True, verbose_name=_("created on")) status = EnumIntegerField(ShipmentStatus, default=ShipmentStatus.NOT_SENT, verbose_name=_("status")) tracking_code = models.CharField(max_length=64, blank=True, verbose_name=_("tracking code")) description = models.CharField(max_length=255, blank=True, verbose_name=_("description")) volume = MeasurementField(unit="m3", verbose_name=_("volume")) weight = MeasurementField(unit="kg", verbose_name=_("weight")) identifier = InternalIdentifierField(unique=True) # TODO: documents = models.ManyToManyField(FilerFile) objects = ShipmentManager() class Meta: verbose_name = _('shipment') verbose_name_plural = _('shipments') def __init__(self, *args, **kwargs): super(Shipment, self).__init__(*args, **kwargs) if not self.identifier: if self.order and self.order.pk: prefix = '%s/%s/' % (self.order.pk, self.order.shipments.count()) else: prefix = '' self.identifier = prefix + get_random_string(32) def __repr__(self): # pragma: no cover return "<Shipment %s for order %s (tracking %r, created %s)>" % ( self.pk, self.order_id, self.tracking_code, self.created_on) def save(self, *args, **kwargs): super(Shipment, self).save(*args, **kwargs) for product_id in self.products.values_list("product_id", flat=True): self.supplier.module.update_stock(product_id=product_id) def delete(self, using=None): raise NotImplementedError( "Not implemented: Use `soft_delete()` for shipments.") @atomic def soft_delete(self, user=None): if self.status == ShipmentStatus.DELETED: return self.status = ShipmentStatus.DELETED self.save(update_fields=["status"]) for product_id in self.products.values_list("product_id", flat=True): self.supplier.module.update_stock(product_id=product_id) self.order.update_shipping_status() shipment_deleted.send(sender=type(self), shipment=self) def is_deleted(self): return bool(self.status == ShipmentStatus.DELETED) def cache_values(self): """ (Re)cache `.volume` and `.weight` for this Shipment from the ShipmentProducts within. """ total_volume = 0 total_weight = 0 for quantity, volume, weight in self.products.values_list( "quantity", "unit_volume", "unit_weight"): total_volume += quantity * volume total_weight += quantity * weight self.volume = total_volume self.weight = total_weight / GRAMS_TO_KILOGRAMS_DIVISOR @property def total_products(self): return (self.products.aggregate( quantity=models.Sum("quantity"))["quantity"] or 0)
class Product(TaxableItem, AttributableMixin, TranslatableModel): COMMON_SELECT_RELATED = ("type", "primary_image", "tax_class") # Metadata created_on = models.DateTimeField(auto_now_add=True, editable=False, verbose_name=_('created on')) modified_on = models.DateTimeField(auto_now=True, editable=False, verbose_name=_('modified on')) deleted = models.BooleanField(default=False, editable=False, db_index=True, verbose_name=_('deleted')) # Behavior mode = EnumIntegerField(ProductMode, default=ProductMode.NORMAL, verbose_name=_('mode')) variation_parent = models.ForeignKey( "self", null=True, blank=True, related_name='variation_children', on_delete=models.PROTECT, verbose_name=_('variation parent')) stock_behavior = EnumIntegerField(StockBehavior, default=StockBehavior.UNSTOCKED, verbose_name=_('stock')) shipping_mode = EnumIntegerField(ShippingMode, default=ShippingMode.NOT_SHIPPED, verbose_name=_('shipping mode')) sales_unit = models.ForeignKey("SalesUnit", verbose_name=_('unit'), blank=True, null=True, on_delete=models.PROTECT) tax_class = models.ForeignKey("TaxClass", verbose_name=_('tax class'), on_delete=models.PROTECT) # Identification type = models.ForeignKey( "ProductType", related_name='products', on_delete=models.PROTECT, db_index=True, verbose_name=_('product type')) sku = models.CharField(db_index=True, max_length=128, verbose_name=_('SKU'), unique=True) gtin = models.CharField(blank=True, max_length=40, verbose_name=_('GTIN'), help_text=_('Global Trade Item Number')) barcode = models.CharField(blank=True, max_length=40, verbose_name=_('barcode')) accounting_identifier = models.CharField(max_length=32, blank=True, verbose_name=_('bookkeeping account')) profit_center = models.CharField(max_length=32, verbose_name=_('profit center'), blank=True) cost_center = models.CharField(max_length=32, verbose_name=_('cost center'), blank=True) # Category is duplicated here because not all products necessarily belong in Shops (i.e. have # ShopProduct instances), but they should nevertheless be searchable by category in other # places, such as administration UIs. category = models.ForeignKey( "Category", related_name='primary_products', blank=True, null=True, verbose_name=_('primary category'), help_text=_("only used for administration and reporting"), on_delete=models.PROTECT) # Physical dimensions width = MeasurementField(unit="mm", verbose_name=_('width (mm)')) height = MeasurementField(unit="mm", verbose_name=_('height (mm)')) depth = MeasurementField(unit="mm", verbose_name=_('depth (mm)')) net_weight = MeasurementField(unit="g", verbose_name=_('net weight (g)')) gross_weight = MeasurementField(unit="g", verbose_name=_('gross weight (g)')) # Misc. manufacturer = models.ForeignKey( "Manufacturer", blank=True, null=True, verbose_name=_('manufacturer'), on_delete=models.PROTECT) primary_image = models.ForeignKey( "ProductMedia", null=True, blank=True, related_name="primary_image_for_products", on_delete=models.SET_NULL, verbose_name=_("primary image")) translations = TranslatedFields( name=models.CharField(max_length=256, verbose_name=_('name')), description=models.TextField(blank=True, verbose_name=_('description')), slug=models.SlugField(verbose_name=_('slug'), max_length=255, blank=True, null=True), keywords=models.TextField(blank=True, verbose_name=_('keywords')), status_text=models.CharField( max_length=128, blank=True, verbose_name=_('status text'), help_text=_( 'This text will be shown alongside the product in the shop.' ' (Ex.: "Available in a month")')), variation_name=models.CharField( max_length=128, blank=True, verbose_name=_('variation name')) ) objects = ProductQuerySet.as_manager() class Meta: ordering = ('-id',) verbose_name = _('product') verbose_name_plural = _('products') def __str__(self): try: return u"%s" % self.name except ObjectDoesNotExist: return self.sku def get_shop_instance(self, shop): """ :type shop: shuup.core.models.Shop :rtype: shuup.core.models.ShopProduct """ shop_inst_cache = self.__dict__.setdefault("_shop_inst_cache", {}) cached = shop_inst_cache.get(shop) if cached: return cached shop_inst = self.shop_products.get(shop=shop) shop_inst._product_cache = self shop_inst._shop_cache = shop shop_inst_cache[shop] = shop_inst return shop_inst def get_priced_children(self, context, quantity=1): """ Get child products with price infos sorted by price. :rtype: list[(Product,PriceInfo)] :return: List of products and their price infos sorted from cheapest to most expensive. """ priced_children = ( (child, child.get_price_info(context, quantity=quantity)) for child in self.variation_children.all()) return sorted(priced_children, key=(lambda x: x[1].price)) def get_cheapest_child_price(self, context, quantity=1): price_info = self.get_cheapest_child_price_info(context, quantity) if price_info: return price_info.price def get_child_price_range(self, context, quantity=1): """ Get the prices for cheapest and the most expensive child The attribute used for sorting is `PriceInfo.price`. Return (`None`, `None`) if `self.variation_children` do not exist. This is because we cannot return anything sensible. :type context: shuup.core.pricing.PricingContextable :type quantity: int :return: a tuple of prices :rtype: (shuup.core.pricing.Price, shuup.core.pricing.Price) """ items = [c.get_price_info(context, quantity=quantity) for c in self.variation_children.all()] if not items: return (None, None) infos = sorted(items, key=lambda x: x.price) return (infos[0].price, infos[-1].price) def get_cheapest_child_price_info(self, context, quantity=1): """ Get the `PriceInfo` of the cheapest variation child The attribute used for sorting is `PriceInfo.price`. Return `None` if `self.variation_children` do not exist. This is because we cannot return anything sensible. :type context: shuup.core.pricing.PricingContextable :rtype: shuup.core.pricing.PriceInfo """ items = [c.get_price_info(context, quantity=quantity) for c in self.variation_children.all()] if not items: return None return sorted(items, key=lambda x: x.price)[0] def get_price_info(self, context, quantity=1): """ Get `PriceInfo` object for the product in given context. Returned `PriceInfo` object contains calculated `price` and `base_price`. The calculation of prices is handled in the current pricing module. :type context: shuup.core.pricing.PricingContextable :rtype: shuup.core.pricing.PriceInfo """ from shuup.core.pricing import get_price_info return get_price_info(product=self, context=context, quantity=quantity) def get_price(self, context, quantity=1): """ Get price of the product within given context. .. note:: When the current pricing module implements pricing steps, it is possible that ``p.get_price(ctx) * 123`` is not equal to ``p.get_price(ctx, quantity=123)``, since there could be quantity discounts in effect, but usually they are equal. :type context: shuup.core.pricing.PricingContextable :rtype: shuup.core.pricing.Price """ return self.get_price_info(context, quantity).price def get_base_price(self, context, quantity=1): """ Get base price of the product within given context. Base price differs from the (effective) price when there are discounts in effect. :type context: shuup.core.pricing.PricingContextable :rtype: shuup.core.pricing.Price """ return self.get_price_info(context, quantity=quantity).base_price def get_available_attribute_queryset(self): if self.type_id: return self.type.attributes.visible() else: return Attribute.objects.none() def get_available_variation_results(self): """ Get a dict of `combination_hash` to product ID of variable variation results. :return: Mapping of combination hashes to product IDs :rtype: dict[str, int] """ return dict( ProductVariationResult.objects.filter(product=self).filter(status=1) .values_list("combination_hash", "result_id") ) def get_all_available_combinations(self): """ Generate all available combinations of variation variables. If the product is not a variable variation parent, the iterator is empty. Because of possible combinatorial explosion this is a generator function. (For example 6 variables with 5 options each explodes to 15,625 combinations.) :return: Iterable of combination information dicts. :rtype: Iterable[dict] """ return get_all_available_combinations(self) def clear_variation(self): """ Fully remove variation information. Make this product a non-variation parent. """ self.simplify_variation() for child in self.variation_children.all(): if child.variation_parent_id == self.pk: child.unlink_from_parent() self.verify_mode() self.save() def simplify_variation(self): """ Remove variation variables from the given variation parent, turning it into a simple variation (or a normal product, if it has no children). :param product: Variation parent to not be variable any longer. :type product: shuup.core.models.Product """ ProductVariationVariable.objects.filter(product=self).delete() ProductVariationResult.objects.filter(product=self).delete() self.verify_mode() self.save() @staticmethod def _get_slug_name(self, translation=None): if self.deleted: return None return getattr(translation, "name", self.sku) def save(self, *args, **kwargs): if self.net_weight and self.net_weight > 0: self.gross_weight = max(self.net_weight, self.gross_weight) rv = super(Product, self).save(*args, **kwargs) generate_multilanguage_slugs(self, self._get_slug_name) return rv def delete(self, using=None): raise NotImplementedError("Not implemented: Use `soft_delete()` for products.") def soft_delete(self, user=None): if not self.deleted: self.deleted = True self.add_log_entry("Deleted.", kind=LogEntryKind.DELETION, user=user) # Bypassing local `save()` on purpose. super(Product, self).save(update_fields=("deleted",)) def verify_mode(self): if ProductPackageLink.objects.filter(parent=self).exists(): self.mode = ProductMode.PACKAGE_PARENT self.external_url = None self.variation_children.clear() elif ProductVariationVariable.objects.filter(product=self).exists(): self.mode = ProductMode.VARIABLE_VARIATION_PARENT elif self.variation_children.exists(): if ProductVariationResult.objects.filter(product=self).exists(): self.mode = ProductMode.VARIABLE_VARIATION_PARENT else: self.mode = ProductMode.SIMPLE_VARIATION_PARENT self.external_url = None ProductPackageLink.objects.filter(parent=self).delete() elif self.variation_parent: self.mode = ProductMode.VARIATION_CHILD ProductPackageLink.objects.filter(parent=self).delete() self.variation_children.clear() self.external_url = None else: self.mode = ProductMode.NORMAL def unlink_from_parent(self): if self.variation_parent: parent = self.variation_parent self.variation_parent = None self.save() parent.verify_mode() self.verify_mode() self.save() ProductVariationResult.objects.filter(result=self).delete() return True def link_to_parent(self, parent, variables=None, combination_hash=None): """ :param parent: The parent to link to. :type parent: Product :param variables: Optional dict of {variable identifier: value identifier} for complex variable linkage :type variables: dict|None :param combination_hash: Optional combination hash (for variable variations), if precomputed. Mutually exclusive with `variables` :type combination_hash: str|None """ if combination_hash: if variables: raise ValueError("`combination_hash` and `variables` are mutually exclusive") variables = True # Simplifies the below invariant checks self._raise_if_cant_link_to_parent(parent, variables) self.unlink_from_parent() self.variation_parent = parent self.verify_mode() self.save() if not parent.is_variation_parent(): parent.verify_mode() parent.save() if variables: if not combination_hash: # No precalculated hash, need to figure that out combination_hash = get_combination_hash_from_variable_mapping(parent, variables=variables) pvr = ProductVariationResult.objects.create( product=parent, combination_hash=combination_hash, result=self ) if parent.mode == ProductMode.SIMPLE_VARIATION_PARENT: parent.verify_mode() parent.save() return pvr else: return True def _raise_if_cant_link_to_parent(self, parent, variables): """ Validates relation possibility for `self.link_to_parent()` :param parent: parent product of self :type parent: Product :param variables: :type variables: dict|None """ if parent.is_variation_child(): raise ImpossibleProductModeException( _("Multilevel parentage hierarchies aren't supported (parent is a child already)"), code="multilevel" ) if parent.mode == ProductMode.VARIABLE_VARIATION_PARENT and not variables: raise ImpossibleProductModeException( _("Parent is a variable variation parent, yet variables were not passed"), code="no_variables" ) if parent.mode == ProductMode.SIMPLE_VARIATION_PARENT and variables: raise ImpossibleProductModeException( "Parent is a simple variation parent, yet variables were passed", code="extra_variables" ) if self.mode == ProductMode.SIMPLE_VARIATION_PARENT: raise ImpossibleProductModeException( _("Multilevel parentage hierarchies aren't supported (this product is a simple variation parent)"), code="multilevel" ) if self.mode == ProductMode.VARIABLE_VARIATION_PARENT: raise ImpossibleProductModeException( _("Multilevel parentage hierarchies aren't supported (this product is a variable variation parent)"), code="multilevel" ) def make_package(self, package_def): if self.mode != ProductMode.NORMAL: raise ImpossibleProductModeException( _("Product is currently not a normal product, can't turn into package"), code="abnormal" ) for child_product, quantity in six.iteritems(package_def): # :type child_product: Product if child_product.is_variation_parent(): raise ImpossibleProductModeException( _("Variation parents can not belong into a package"), code="abnormal" ) if child_product.is_package_parent(): raise ImpossibleProductModeException(_("Packages can't be nested"), code="multilevel") if quantity <= 0: raise ImpossibleProductModeException(_("Quantity %s is invalid") % quantity, code="quantity") ProductPackageLink.objects.create(parent=self, child=child_product, quantity=quantity) self.verify_mode() def get_package_child_to_quantity_map(self): if self.is_package_parent(): product_id_to_quantity = dict( ProductPackageLink.objects.filter(parent=self).values_list("child_id", "quantity") ) products = dict((p.pk, p) for p in Product.objects.filter(pk__in=product_id_to_quantity.keys())) return {products[product_id]: quantity for (product_id, quantity) in six.iteritems(product_id_to_quantity)} return {} def is_variation_parent(self): return self.mode in (ProductMode.SIMPLE_VARIATION_PARENT, ProductMode.VARIABLE_VARIATION_PARENT) def is_variation_child(self): return (self.mode == ProductMode.VARIATION_CHILD) def get_variation_siblings(self): return Product.objects.filter(variation_parent=self.variation_parent).exclude(pk=self.pk) def is_package_parent(self): return (self.mode == ProductMode.PACKAGE_PARENT) def is_package_child(self): return ProductPackageLink.objects.filter(child=self).exists() def get_all_package_parents(self): return Product.objects.filter(pk__in=( ProductPackageLink.objects.filter(child=self).values_list("parent", flat=True) )) def get_all_package_children(self): return Product.objects.filter(pk__in=( ProductPackageLink.objects.filter(parent=self).values_list("child", flat=True) )) def get_public_media(self): return self.media.filter(enabled=True, public=True).exclude(kind=ProductMediaKind.IMAGE) def is_stocked(self): return (self.stock_behavior == StockBehavior.STOCKED)
class Shipment(ShuupModel): order = models.ForeignKey("Order", blank=True, null=True, related_name='shipments', on_delete=models.PROTECT, verbose_name=_("order")) supplier = models.ForeignKey("Supplier", related_name='shipments', on_delete=models.PROTECT, verbose_name=_("supplier")) created_on = models.DateTimeField(auto_now_add=True, verbose_name=_("created on")) status = EnumIntegerField(ShipmentStatus, default=ShipmentStatus.NOT_SENT, verbose_name=_("status")) tracking_code = models.CharField(max_length=64, blank=True, verbose_name=_("tracking code")) description = models.CharField(max_length=255, blank=True, verbose_name=_("description")) volume = MeasurementField(unit=get_shuup_volume_unit(), verbose_name=_("volume ({})".format( get_shuup_volume_unit()))) weight = MeasurementField(unit=settings.SHUUP_MASS_UNIT, verbose_name=_("weight ({})".format( settings.SHUUP_MASS_UNIT))) identifier = InternalIdentifierField(unique=True) type = EnumIntegerField(ShipmentType, default=ShipmentType.OUT, verbose_name=_("type")) # TODO: documents = models.ManyToManyField(FilerFile) objects = ShipmentManager() class Meta: verbose_name = _('shipment') verbose_name_plural = _('shipments') def __init__(self, *args, **kwargs): super(Shipment, self).__init__(*args, **kwargs) if not self.identifier: if self.order and self.order.pk: prefix = '%s/%s/' % (self.order.pk, self.order.shipments.count()) else: prefix = '' self.identifier = prefix + get_random_string(32) def __repr__(self): # pragma: no cover return "<Shipment %s (tracking %r, created %s)>" % ( self.pk, self.tracking_code, self.created_on) def save(self, *args, **kwargs): super(Shipment, self).save(*args, **kwargs) for product_id in self.products.values_list("product_id", flat=True): self.supplier.module.update_stock(product_id=product_id) def delete(self, using=None): raise NotImplementedError( "Error! Not implemented: `Shipment` -> `delete()`. Use `soft_delete()` instead." ) @atomic def soft_delete(self, user=None): if self.status == ShipmentStatus.DELETED: return self.status = ShipmentStatus.DELETED self.save(update_fields=["status"]) for product_id in self.products.values_list("product_id", flat=True): self.supplier.module.update_stock(product_id=product_id) if self.order: self.order.update_shipping_status() shipment_deleted.send(sender=type(self), shipment=self) def is_deleted(self): return bool(self.status == ShipmentStatus.DELETED) def cache_values(self): """ (Re)cache `.volume` and `.weight` for this Shipment from within the ShipmentProducts. """ total_volume = 0 total_weight = 0 for quantity, volume, weight in self.products.values_list( "quantity", "unit_volume", "unit_weight"): total_volume += quantity * volume total_weight += quantity * weight self.volume = total_volume self.weight = total_weight @property def total_products(self): return (self.products.aggregate( quantity=models.Sum("quantity"))["quantity"] or 0) def set_received(self, purchase_prices=None, created_by=None): """ Mark the shipment as received. In case shipment is incoming, add stock adjustment for each shipment product in this shipment. :param purchase_prices: a dict mapping product ids to purchase prices :type purchase_prices: dict[shuup.shop.models.Product, decimal.Decimal] :param created_by: user who set this shipment received :type created_by: settings.AUTH_USER_MODEL """ self.status = ShipmentStatus.RECEIVED self.save() if self.type == ShipmentType.IN: for product_id, quantity in self.products.values_list( "product_id", "quantity"): purchase_price = (purchase_prices.get(product_id, None) if purchase_prices else None) self.supplier.module.adjust_stock(product_id=product_id, delta=quantity, purchase_price=purchase_price or 0, created_by=created_by)
def test_measurement_field(): field = MeasurementField(unit="mm3") assert field.unit == "mm3" assert field.default == 0 assert field.max_digits == FORMATTED_DECIMAL_FIELD_MAX_DIGITS assert field.decimal_places == FORMATTED_DECIMAL_FIELD_DECIMAL_PLACES
class CorreiosBehaviorComponent(ServiceBehaviorComponent): CORREIOS_SERVICOS_CHOICES = ( (CorreiosServico.PAC, '({0}) PAC'.format(CorreiosServico.PAC)), (CorreiosServico.SEDEX, '({0}) Sedex'.format(CorreiosServico.SEDEX)), (CorreiosServico.SEDEX_10, '({0}) Sedex 10'.format(CorreiosServico.SEDEX_10)), (CorreiosServico.SEDEX_A_COBRAR, '({0}) Sedex a cobrar'.format(CorreiosServico.SEDEX_A_COBRAR)), (CorreiosServico.SEDEX_HOJE, '({0}) Sedex Hoje'.format(CorreiosServico.SEDEX_HOJE)), (CorreiosServico.ESEDEX, '({0}) eSedex'.format(CorreiosServico.ESEDEX)), ) name = _("Serviço dos Correios") help_text = _("Configurações do serviço") cod_servico = models.CharField( "Código do serviço", max_length=10, help_text="Código atribuído automaticamente ao criar o serviço.", choices=CORREIOS_SERVICOS_CHOICES) cod_servico_contrato = models.CharField( "Código do serviço em contrato", null=True, blank=True, max_length=10, help_text="Informe o código do serviço em contrato, se existir. " "Quando há um contrato com os Correios, " "é necessário informar este código para que " "ele seja utilizado no cálculo dos preços e prazos.") cep_origem = models.CharField( "CEP de origem", max_length=8, default='99999999', help_text="CEP de origem da encomenda. Apenas números, sem hífen.") cod_empresa = models.CharField( "Código da empresa", max_length=30, blank=True, null=True, help_text="Seu código administrativo junto à ECT, se existir. " "O código está disponível no corpo do " "contrato firmado com os Correios.") senha = models.CharField( "Senha", max_length=30, blank=True, null=True, help_text="Senha para acesso ao serviço, associada ao seu " "código administrativo, se existir. " "A senha inicial corresponde aos 8 primeiros dígitos " "do CNPJ informado no contrato.") mao_propria = models.BooleanField( "Mão própria?", default=False, help_text="Indica se a encomenda será entregue " "com o serviço adicional mão própria.") valor_declarado = models.BooleanField( "Valor declarado?", default=False, help_text="Indica se a encomenda será entregue " "com o serviço adicional valor declarado.") aviso_recebimento = models.BooleanField( "Aviso de recebimento?", default=False, help_text="Indica se a encomenda será entregue " "com o serviço adicional aviso de recebimento.") additional_delivery_time = models.PositiveIntegerField( "Prazo adicional", blank=True, default=0, validators=[MinValueValidator(0)], help_text="Indica quantos dias devem ser somados " "ao prazo original retornado pelo serviço dos Correios. " "O prazo será somado no prazo de cada encomenda diferente.") additional_price = models.DecimalField( "Preço adicional", blank=True, max_digits=9, decimal_places=2, default=Decimal(), validators=[MinValueValidator(Decimal(0))], help_text="Indica o valor, em reais, a ser somado " "ao preço original retornado pelo serviço dos Correios. " "O preço será somado no valor de cada encomenda diferente.") max_weight = MeasurementField( verbose_name="Peso máximo da embalagem (kg)", unit="kg", blank=True, validators=[MinValueValidator(Decimal(0))], default=Decimal(), help_text="Indica o peso máximo admitido para esta modalidade.") min_length = MeasurementField(verbose_name="Comprimento mínimo (mm)", unit="mm", default=110, validators=[MinValueValidator(Decimal(0))], help_text="Indica o comprimento mínimo " "para caixas e pacotes.") max_length = MeasurementField(verbose_name="Comprimento máximo (mm)", unit="mm", default=1050, validators=[MinValueValidator(Decimal(0))], help_text="Indica o comprimento máximo " "para caixas e pacotes.") min_width = MeasurementField(verbose_name="Largura mínima (mm)", unit="mm", default=160, validators=[MinValueValidator(Decimal(0))], help_text="Indica a largura mínima " "para caixas e pacotes.") max_width = MeasurementField(verbose_name="Largura máxima", unit="mm", default=1050, validators=[MinValueValidator(Decimal(0))], help_text="Indica a largura máxima " "para caixas e pacotes.") min_height = MeasurementField(verbose_name="Altura mínima (mm)", unit="mm", default=20, validators=[MinValueValidator(Decimal(0))], help_text="Indica a altura mínima " "para caixas e pacotes.") max_height = MeasurementField(verbose_name="Altura máxima (mm)", unit="mm", default=1050, validators=[MinValueValidator(Decimal(0))], help_text="Indica a altura máxima " "para caixas e pacotes.") max_edges_sum = MeasurementField( verbose_name="Soma máxima das dimensões (L + A + C) (mm)", unit="mm", default=2000, validators=[MinValueValidator(Decimal(0))], help_text="Indica a soma máxima das dimensões de " "altura + largura + comprimento " "para caixas e pacotes.") def get_unavailability_reasons(self, service, source): """ :type service: Service :type source: shuup.core.order_creator.OrderSource :rtype: Iterable[ValidationError] """ errors = [] packages = self._pack_source(source) if packages: try: results = self._get_correios_results(source, packages) if results: for result in results: if result.erro != 0: logger.warn("{0}: {1}".format( result.erro, result.msg_erro)) errors.append( ValidationError( "Alguns itens não poderão ser " "entregues pelos Correios.", code=result.erro)) except CorreiosWSServerTimeoutException: errors.append( ValidationError( "Não foi possível contatar os serviços dos Correios.")) else: errors.append( ValidationError( "Alguns itens não puderam ser empacotados nos requisitos dos Correios." )) return errors 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] """ packages = self._pack_source(source) try: results = self._get_correios_results(source, packages) total_price = Decimal() for result in results: if result.erro == 0: total_price = total_price + result.valor else: total_price = 0 logger.critical("CorreiosWS: Erro {0} ao calcular " "preço e prazo para {2}: {1}".format( result.erro, result.msg_erro, source)) break if total_price > 0: yield ServiceCost( source.create_price(total_price + self.additional_price)) except CorreiosWSServerTimeoutException: pass def get_delivery_time(self, service, source): """ :type service: Service :type source: shuup.core.order_creator.OrderSource :rtype: shuup.utils.dates.DurationRange|None """ packages = self._pack_source(source) try: results = self._get_correios_results(source, packages) except CorreiosWSServerTimeoutException: return None max_days = 1 min_days = 0 for result in results: if result.erro == 0: max_days = max(max_days, result.prazo_entrega) min_days = result.prazo_entrega if not min_days else min( min_days, result.prazo_entrega) else: logger.critical("CorreiosWS: Erro {0} ao calcular " "preço e prazo para {2}: {1}".format( result.erro, result.msg_erro, source)) return None return DurationRange.from_days( min_days + self.additional_delivery_time, max_days + self.additional_delivery_time) def _pack_source(self, source): """ Empacota itens do pedido :rtype: Iterable[shuup_order_packager.package.AbstractPackage|None] :return: Lista de pacotes ou None se for impossível empacotar pedido """ packager = cached_load("CORREIOS_PRODUCTS_PACKAGER_CLASS")() packager.add_constraint( SimplePackageDimensionConstraint(self.max_width, self.max_length, self.max_height, self.max_edges_sum)) packager.add_constraint( WeightPackageConstraint(self.max_weight * KG_TO_G)) return packager.pack_source(source) def _get_correios_results(self, source, packages): """ Obtém uma lista dos resultados obtidos dos correios para determinado pedido :type source: shuup.core.order_creator.OrderSource :rtype: list of shuup_correios.correios.CorreiosWS.CorreiosWSServiceResult """ results = [] cep_origem = "".join([d for d in self.cep_origem if d.isdigit()]) pedido_total = source.total_price_of_products.value shipping_address = source.shipping_address if not shipping_address: shipping_address = source.billing_address if not shipping_address: return [] cep_destino = "".join( [d for d in shipping_address.postal_code if d.isdigit()]) for package in packages: results.append( CorreiosWS.get_preco_prazo( cep_destino, cep_origem, self.cod_servico_contrato if self.cod_servico_contrato else self.cod_servico, package, self.cod_empresa, self.senha, self.mao_propria, pedido_total if self.valor_declarado else 0.0, self.aviso_recebimento, self.min_width, self.min_length, self.min_height)) return results