Пример #1
0
class StoredBasket(models.Model):
    # A combination of the PK and key is used to retrieve a basket for session situations.
    key = models.CharField(max_length=32, default=generate_key)
    owner_contact = models.ForeignKey("shoop.Contact", blank=True, null=True)
    owner_user = models.ForeignKey(settings.AUTH_USER_MODEL,
                                   blank=True,
                                   null=True)

    created_on = models.DateTimeField(auto_now_add=True,
                                      db_index=True,
                                      editable=False)
    updated_on = models.DateTimeField(auto_now=True,
                                      db_index=True,
                                      editable=False)
    persistent = models.BooleanField(db_index=True, default=False)
    deleted = models.BooleanField(db_index=True, default=False)
    finished = models.BooleanField(db_index=True, default=False)
    title = models.CharField(max_length=64, blank=True)
    data = TaggedJSONField()

    # For statistics etc., as `data` is opaque:
    taxless_total = MoneyField(default=0, null=True, blank=True)
    taxful_total = MoneyField(default=0, null=True, blank=True)
    product_count = models.IntegerField(default=0)
    products = ManyToManyField("shoop.Product", blank=True)

    class Meta:
        app_label = "shoop_front"
Пример #2
0
class SuppliedProduct(models.Model):
    supplier = models.ForeignKey("Supplier")
    product = models.ForeignKey("Product")
    sku = models.CharField(db_index=True, max_length=128, verbose_name=_('SKU'))
    alert_limit = models.IntegerField(default=0, verbose_name=_('alert limit'))
    purchase_price = MoneyField(verbose_name=_('purchase price'), blank=True, null=True)
    suggested_retail_price = MoneyField(verbose_name=_('suggested retail price'), blank=True, null=True)
    physical_count = QuantityField(editable=False, verbose_name=_('physical stock count'))
    logical_count = QuantityField(editable=False, verbose_name=_('logical stock count'))

    class Meta:
        unique_together = (("supplier", "product", ), )
Пример #3
0
class SimpleProductPrice(models.Model):
    product = models.ForeignKey("shoop.Product", related_name="+")
    shop = models.ForeignKey("shoop.Shop", db_index=True)
    group = models.ForeignKey("shoop.ContactGroup", db_index=True)
    price = MoneyField()

    # TODO: (TAX) Check includes_tax consistency (see below)
    #
    # SimpleProductPrice entries in single shop should all have same
    # value of includes_tax, because inconsistencies in taxfulness of
    # prices may cause basket totals to be unsummable, since taxes are
    # unknown before customer has given their address and TaxfulPrice
    # cannot be summed with TaxlessPrice.

    class Meta:
        unique_together = (('product', 'shop', 'group'), )
        verbose_name = _(u"product price")
        verbose_name_plural = _(u"product prices")

    def __repr__(self):
        return "<SimpleProductPrice (p%s,s%s,g%s): price %s" % (
            self.product_id,
            self.shop_id,
            self.group_id,
            self.price,
        )
Пример #4
0
class OrderLineTax(ShoopModel, LineTax):
    order_line = models.ForeignKey(
        OrderLine, related_name='taxes', on_delete=models.PROTECT,
        verbose_name=_('order line'))
    tax = models.ForeignKey(  # TODO: (TAX) Should we allow NULL? When deciding, see get_tax_summary
        "Tax", related_name="order_line_taxes",
        on_delete=models.PROTECT, verbose_name=_('tax')
    )
    name = models.CharField(max_length=200, verbose_name=_('tax name'))
    amount = MoneyField(verbose_name=_('tax amount'))
    base_amount = MoneyField(
        verbose_name=_('base amount'),
        help_text=_('Amount that this tax is calculated from'))
    ordering = models.IntegerField(default=0, verbose_name=_('ordering'))

    class Meta:
        ordering = ["ordering"]

    def __str__(self):
        return "%s: %s on %s" % (self.name, self.amount, self.base_amount)
Пример #5
0
class Tax(TranslatableShoopModel):
    identifier_attr = 'code'

    code = InternalIdentifierField(unique=True)

    translations = TranslatedFields(name=models.CharField(max_length=64), )

    rate = models.DecimalField(
        max_digits=6,
        decimal_places=5,
        blank=True,
        null=True,
        verbose_name=_('tax rate'),
        help_text=
        _("The percentage rate of the tax. Mutually exclusive with flat amounts."
          ))
    amount = MoneyField(
        default=None,
        blank=True,
        null=True,
        verbose_name=_('tax amount'),
        help_text=
        _("The flat amount of the tax. Mutually exclusive with percentage rates."
          ))
    enabled = models.BooleanField(default=True, verbose_name=_('enabled'))

    def clean(self):
        super(Tax, self).clean()
        if self.rate is None and self.amount is None:
            raise ValidationError(_('Either rate or amount is required'))
        if self.amount is not None and self.rate is not None:
            raise ValidationError(_('Cannot have both rate and amount'))

    def save(self, *args, **kwargs):
        self.clean()
        if self.pk:
            # TODO: (TAX) Make it possible to disable Tax
            raise ImmutabilityError('Tax objects are immutable')
        super(Tax, self).save(*args, **kwargs)

    def calculate_amount(self, base_amount):
        if self.amount is not None:
            return self.amount
        if self.rate is not None:
            return self.rate * base_amount
        raise ValueError("Improperly configured tax: %s" % self)

    class Meta:
        verbose_name = _('tax')
        verbose_name_plural = _('taxes')
Пример #6
0
class Payment(models.Model):
    # TODO: Revise!!!
    order = models.ForeignKey("Order",
                              related_name='payments',
                              on_delete=models.PROTECT)
    created_on = models.DateTimeField(auto_now_add=True)
    gateway_id = models.CharField(max_length=32)  # TODO: do we need this?
    payment_identifier = models.CharField(max_length=96, unique=True)
    amount = MoneyField()
    description = models.CharField(max_length=256, blank=True)

    # TODO: Currency here?

    class Meta:
        verbose_name = _('payment')
        verbose_name_plural = _('payments')
Пример #7
0
class Product(AttributableMixin, TranslatableModel):

    COMMON_SELECT_RELATED = ("type", "primary_image", "tax_class")

    # Metadata
    created_on = models.DateTimeField(auto_now_add=True, editable=False)
    modified_on = models.DateTimeField(auto_now=True, editable=False)
    deleted = models.BooleanField(default=False, editable=False, db_index=True)

    # Behavior
    mode = EnumIntegerField(ProductMode, default=ProductMode.NORMAL)
    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)
    tax_class = models.ForeignKey("TaxClass", verbose_name=_('tax class'))

    # 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"))

    # 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.
    purchase_price = MoneyField(verbose_name=_('purchase price'))
    suggested_retail_price = MoneyField(verbose_name=_('suggested retail price'))
    manufacturer = models.ForeignKey("Manufacturer", blank=True, null=True, verbose_name=_('manufacturer'))
    primary_image = models.ForeignKey(
        "ProductMedia", null=True, blank=True,
        related_name="primary_image_for_products",
        on_delete=models.SET_NULL)

    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, 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: shoop.core.models.shops.Shop
        :rtype: shoop.core.models.product_shops.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.filter(shop=shop).first()
        if shop_inst:
            shop_inst._product_cache = self
            shop_inst._shop_cache = shop
            shop_inst_cache[shop] = shop_inst

        return shop_inst

    def get_cheapest_child_price(self, context, quantity=1):
        return sorted(
            c.get_price(context, quantity=quantity)
            for c in self.variation_children.all()
        )[0]

    def get_price(self, context, quantity=1):
        """
        :type context: shoop.core.contexts.PriceTaxContext
        :rtype: shoop.core.pricing.Price
        """
        from shoop.core.pricing import get_pricing_module
        module = get_pricing_module()
        pricing_context = module.get_context(context)
        return module.get_price(pricing_context, product_id=self.pk, quantity=quantity)

    def get_base_price(self):
        from shoop.core.pricing import get_pricing_module
        module = get_pricing_module()
        return module.get_base_price(product_id=self.pk)

    def get_taxed_price(self, context, quantity=1):
        """
        :type context: shoop.core.contexts.PriceTaxContext
        :rtype: shoop.core.pricing.TaxedPrice
        """
        from shoop.core import taxing
        module = taxing.get_tax_module()
        return module.determine_product_tax(context, self)

    def get_available_attribute_queryset(self):
        if self.type_id:
            return self.type.attributes.visible()
        else:
            return Attribute.objects.none()

    @staticmethod
    def _get_slug_name(self):
        if self.deleted:
            return None
        return (self.safe_translation_getter("name") or 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).count():
            self.mode = ProductMode.PACKAGE_PARENT
            self.external_url = None
            self.variation_children.clear()
        elif self.variation_children.count():
            if ProductVariationResult.objects.filter(product=self).count():
                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):
        if parent.mode == ProductMode.VARIATION_CHILD:
            raise ValueError("Multilevel parentage hierarchies aren't supported (parent is a child already)")
        if parent.mode == ProductMode.VARIABLE_VARIATION_PARENT and not variables:
            raise ValueError("Parent is a variable variation parent, yet variables were not passed to `link_to_parent`")
        if parent.mode == ProductMode.SIMPLE_VARIATION_PARENT and variables:
            raise ValueError("Parent is a simple variation parent, yet variables were passed to `link_to_parent`")
        if self.mode == ProductMode.SIMPLE_VARIATION_PARENT:
            raise ValueError(
                "Multilevel parentage hierarchies aren't supported (this product is a simple variation parent)"
            )
        if self.mode == ProductMode.VARIABLE_VARIATION_PARENT:
            raise ValueError(
                "Multilevel parentage hierarchies aren't supported (this product is a variable variation parent)"
            )

        self.unlink_from_parent()
        self.variation_parent = parent
        self.verify_mode()
        self.save()
        if parent.mode not in (ProductMode.SIMPLE_VARIATION_PARENT, ProductMode.VARIABLE_VARIATION_PARENT):
            parent.verify_mode()
            parent.save()

        if variables:
            mapping = {}
            for variable_identifier, value_identifier in variables.items():
                variable_identifier, _ = ProductVariationVariable.objects.get_or_create(
                    product=parent, identifier=variable_identifier
                )
                value_identifier, _ = ProductVariationVariableValue.objects.get_or_create(
                    variable=variable_identifier, identifier=value_identifier
                )
                mapping[variable_identifier] = value_identifier
            pvr = ProductVariationResult.objects.create(
                product=parent,
                combination_hash=hash_combination(mapping),
                result=self
            )
            if parent.mode == ProductMode.SIMPLE_VARIATION_PARENT:
                parent.verify_mode()
                parent.save()
            return pvr
        else:
            return True

    def make_package(self, package_def):
        if self.mode != ProductMode.NORMAL:
            raise ValueError("Product is currently not a normal product, can't turn into package")

        for child_product, quantity in six.iteritems(package_def):
            if child_product.mode in (ProductMode.SIMPLE_VARIATION_PARENT, ProductMode.VARIABLE_VARIATION_PARENT):
                raise ValueError("Variation parents can not belong into a package")
            if child_product.mode == ProductMode.PACKAGE_PARENT:
                raise ValueError("Can't nest packages")

            if quantity <= 0:
                raise ValueError("Quantity %s is invalid" % quantity)
            ProductPackageLink.objects.create(parent=self, child=child_product, quantity=quantity)
        self.verify_mode()

    def get_package_child_to_quantity_map(self):
        if self.mode == ProductMode.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 {}
Пример #8
0
class Order(models.Model):
    # Identification
    shop = UnsavedForeignKey("Shop")
    created_on = models.DateTimeField(auto_now_add=True, editable=False)
    identifier = InternalIdentifierField(db_index=True,
                                         verbose_name=_('order identifier'))
    # TODO: label is actually a choice field, need to check migrations/choice deconstruction
    label = models.CharField(max_length=32,
                             db_index=True,
                             verbose_name=_('label'))
    # The key shouldn't be possible to deduce (i.e. it should be random), but it is
    # not a secret. (It could, however, be used as key material for an actual secret.)
    key = models.CharField(max_length=32,
                           unique=True,
                           blank=False,
                           verbose_name=_('key'))
    reference_number = models.CharField(max_length=64,
                                        db_index=True,
                                        unique=True,
                                        blank=True,
                                        null=True,
                                        verbose_name=_('reference number'))

    # Contact information
    customer = UnsavedForeignKey("Contact",
                                 related_name='customer_orders',
                                 blank=True,
                                 null=True,
                                 verbose_name=_('customer'))
    orderer = UnsavedForeignKey("PersonContact",
                                related_name='orderer_orders',
                                blank=True,
                                null=True,
                                verbose_name=_('orderer'))
    billing_address = UnsavedForeignKey("Address",
                                        related_name="billing_orders",
                                        blank=True,
                                        null=True,
                                        on_delete=models.PROTECT,
                                        verbose_name=_('billing address'))
    shipping_address = UnsavedForeignKey("Address",
                                         related_name='shipping_orders',
                                         blank=True,
                                         null=True,
                                         on_delete=models.PROTECT,
                                         verbose_name=_('shipping address'))
    vat_code = models.CharField(max_length=20,
                                blank=True,
                                verbose_name=_('VAT code'))
    phone = models.CharField(max_length=32,
                             blank=True,
                             verbose_name=_('phone'))
    email = models.EmailField(max_length=128,
                              blank=True,
                              verbose_name=_('email address'))

    # Status
    creator = UnsavedForeignKey(settings.AUTH_USER_MODEL,
                                related_name='orders_created',
                                blank=True,
                                null=True,
                                verbose_name=_('creating user'))
    deleted = models.BooleanField(db_index=True, default=False)
    status = UnsavedForeignKey("OrderStatus", verbose_name=_('status'))
    payment_status = EnumIntegerField(PaymentStatus,
                                      db_index=True,
                                      default=PaymentStatus.NOT_PAID,
                                      verbose_name=_('payment status'))
    shipping_status = EnumIntegerField(ShippingStatus,
                                       db_index=True,
                                       default=ShippingStatus.NOT_SHIPPED,
                                       verbose_name=_('shipping status'))

    # Methods
    payment_method = UnsavedForeignKey("PaymentMethod",
                                       related_name="payment_orders",
                                       blank=True,
                                       null=True,
                                       default=None,
                                       on_delete=models.PROTECT,
                                       verbose_name=_('payment method'))
    payment_method_name = models.CharField(
        max_length=64,
        blank=True,
        default="",
        verbose_name=_('payment method name'))
    payment_data = JSONField(blank=True, null=True)

    shipping_method = UnsavedForeignKey("ShippingMethod",
                                        related_name='shipping_orders',
                                        blank=True,
                                        null=True,
                                        default=None,
                                        on_delete=models.PROTECT,
                                        verbose_name=_('shipping method'))
    shipping_method_name = models.CharField(
        max_length=64,
        blank=True,
        default="",
        verbose_name=_('shipping method name'))
    shipping_data = JSONField(blank=True, null=True)

    extra_data = JSONField(blank=True, null=True)

    # Money stuff
    taxful_total_price = MoneyField(editable=False,
                                    verbose_name=_('grand total'))
    taxless_total_price = MoneyField(editable=False,
                                     verbose_name=_('taxless total'))
    display_currency = models.CharField(max_length=4, blank=True)
    display_currency_rate = models.DecimalField(max_digits=36,
                                                decimal_places=9,
                                                default=1)

    # Other
    ip_address = models.GenericIPAddressField(null=True,
                                              blank=True,
                                              verbose_name=_('IP address'))
    # order_date is not `auto_now_add` for backdating purposes
    order_date = models.DateTimeField(editable=False,
                                      verbose_name=_('order date'))
    payment_date = models.DateTimeField(null=True,
                                        editable=False,
                                        verbose_name=_('payment date'))

    # TODO: (TAX) Add me? customer_tax_group = models.ForeignKey(CustomerTaxGroup, blank=True, null=True)
    language = LanguageField(blank=True, verbose_name=_('language'))
    customer_comment = models.TextField(blank=True,
                                        verbose_name=_('customer comment'))
    admin_comment = models.TextField(blank=True,
                                     verbose_name=_('admin comment/notes'))
    require_verification = models.BooleanField(
        default=False, verbose_name=_('requires verification'))
    all_verified = models.BooleanField(default=False,
                                       verbose_name=_('all lines verified'))
    marketing_permission = models.BooleanField(
        default=True, verbose_name=_('marketing permission'))

    common_select_related = ("billing_address", )
    objects = OrderQuerySet.as_manager()

    class Meta:
        ordering = ("-id", )
        verbose_name = _('order')
        verbose_name_plural = _('orders')

    def __str__(self):  # pragma: no cover
        if self.billing_address_id:
            name = self.billing_address.name
        else:
            name = "-"
        if settings.SHOOP_ENABLE_MULTIPLE_SHOPS:
            return "Order %s (%s, %s)" % (self.identifier, self.shop.name,
                                          name)
        else:
            return "Order %s (%s)" % (self.identifier, name)

    def cache_prices(self):
        taxful_total = Decimal(0)
        taxless_total = Decimal(0)
        for line in self.lines.all():
            taxful_total += line.taxful_total_price.amount
            taxless_total += line.taxless_total_price.amount
        self.taxful_total_price = _round_price(taxful_total)
        self.taxless_total_price = _round_price(taxless_total)

    def _cache_contact_values(self):
        sources = [
            self.shipping_address,
            self.billing_address,
            self.customer,
            self.orderer,
        ]

        fields = ("vat_code", "email", "phone")

        for field in fields:
            if getattr(self, field, None):
                continue
            for source in sources:
                val = getattr(source, field, None)
                if val:
                    setattr(self, field, val)
                    break

    def _cache_values(self):
        self._cache_contact_values()

        if not self.label:
            self.label = settings.SHOOP_DEFAULT_ORDER_LABEL

        if not self.display_currency:
            self.display_currency = settings.SHOOP_HOME_CURRENCY
            self.display_currency_rate = 1

        if self.shipping_method_id and not self.shipping_method_name:
            self.shipping_method_name = self.shipping_method.safe_translation_getter(
                "name",
                default=self.shipping_method.identifier,
                any_language=True)

        if self.payment_method_id and not self.payment_method_name:
            self.payment_method_name = self.payment_method.safe_translation_getter(
                "name",
                default=self.payment_method.identifier,
                any_language=True)

        if not self.key:
            self.key = get_random_string(32)

    def _save_identifiers(self):
        self.identifier = "%s" % (get_order_identifier(self))
        self.reference_number = get_reference_number(self)
        super(Order, self).save(update_fields=(
            "identifier",
            "reference_number",
        ))

    def full_clean(self, exclude=None, validate_unique=True):
        self._cache_values()
        return super(Order, self).full_clean(exclude, validate_unique)

    def create_immutable_address_copies(self):
        for field in ("billing_address", "shipping_address"):
            address = getattr(self, field, None)
            if address and not address.is_immutable:
                if address.pk:
                    address = address.copy()
                    address.set_immutable()
                else:
                    address.set_immutable()
                setattr(self, field, address)

    def save(self, *args, **kwargs):
        if not self.creator_id:
            if not settings.SHOOP_ALLOW_ANONYMOUS_ORDERS:
                raise ValidationError(
                    "Anonymous (userless) orders are not allowed "
                    "when SHOOP_ALLOW_ANONYMOUS_ORDERS is not enabled.")
        self._cache_values()
        first_save = (not self.pk)
        self.create_immutable_address_copies()
        super(Order, self).save(*args, **kwargs)
        if first_save:  # Have to do a double save the first time around to be able to save identifiers
            self._save_identifiers()

    def delete(self, using=None):
        if not self.deleted:
            self.deleted = True
            self.add_log_entry("Deleted.", kind=LogEntryKind.DELETION)
            # Bypassing local `save()` on purpose.
            super(Order, self).save(update_fields=("deleted", ), using=using)

    def set_canceled(self):
        if self.status.role != OrderStatusRole.CANCELED:
            self.status = OrderStatus.objects.get_default_canceled()
            self.save()

    def _set_paid(self):
        if self.payment_status != PaymentStatus.FULLY_PAID:  # pragma: no branch
            self.add_log_entry(_('Order marked as paid.'))
            self.payment_status = PaymentStatus.FULLY_PAID
            self.payment_date = now()
            self.save()

    def is_paid(self):
        return (self.payment_status == PaymentStatus.FULLY_PAID)

    def get_total_paid_amount(self):
        amounts = self.payments.values_list('amount', flat=True)
        return sum(amounts, Decimal(0))

    def create_payment(self, amount, payment_identifier=None, description=''):
        """
        Create a payment with given amount for this order.

        If the order already has payments and sum of their amounts is
        equal or greater than self.taxful_total_price, an exception is raised.

        If the end sum of all payments is equal or greater than
        self.taxful_total_price, then the order is marked as paid.

        :param amount: amount of the payment to be created
        :param gateway_id: identifier of the gateway used to make this payment. Leave empty for non-gateway payments.
        :param payment_identifier: Identifier of the created payment. If
           not set, default value of "gateway_id:order_id:number" will
           be used (where number is number of payments in the order).
        :param description: Description of the payment. Will be set to `method` property of the created payment.

        Returns the created Payment object.
        """
        payments = self.payments.order_by('created_on')

        total_paid_amount = self.get_total_paid_amount()
        if total_paid_amount >= self.taxful_total_price:
            raise NoPaymentToCreateException(
                "Order %s has already been fully paid (%s >= %s)." %
                (self.pk, total_paid_amount, self.taxful_total_price))

        if not payment_identifier:
            number = payments.count() + 1
            payment_identifier = '%d:%d' % (self.id, number)

        payment = self.payments.create(
            payment_identifier=payment_identifier,
            amount=amount,
            description=description,
        )

        if self.get_total_paid_amount() >= self.taxful_total_price:
            self._set_paid()  # also calls save

        return payment

    def create_shipment(self, supplier, product_quantities):
        """
        Create a shipment for this order from `product_quantities`.
        `product_quantities` is expected to be a dict mapping Product instances to quantities.

        Only quantities over 0 are taken into account, and if the mapping is empty or has no quantity value
        over 0, `NoProductsToShipException` will be raised.

        :param supplier: The Supplier for this product. No validation is made
                         as to whether the given supplier supplies the products.
        :param product_quantities: a dict mapping Product instances to quantities to ship
        :type product_quantities: dict[shoop.shop.models.products.Product, decimal.Decimal]
        :raises: NoProductsToShipException
        :return: Saved, complete Shipment object
        :rtype: shoop.core.models.shipments.Shipment
        """
        if not product_quantities or not any(
                quantity > 0 for quantity in product_quantities.values()):
            raise NoProductsToShipException(
                "No products to ship (`quantities` is empty or has no quantity over 0)."
            )

        from .shipments import Shipment, ShipmentProduct

        shipment = Shipment(order=self, supplier=supplier)
        shipment.save()

        for product, quantity in product_quantities.items():
            if quantity > 0:
                sp = ShipmentProduct(shipment=shipment,
                                     product=product,
                                     quantity=quantity)
                sp.cache_values()
                sp.save()

        shipment.cache_values()
        shipment.save()

        self.add_log_entry(_(u"Shipment #%d created.") % shipment.id)
        self.check_and_set_fully_shipped()
        return shipment

    def create_shipment_of_all_products(self, supplier=None):
        """
        Create a shipment of all the products in this Order, no matter whether or not any have been previously
        marked as shipped or not.

        See the documentation for `create_shipment`.

        :param supplier: The Supplier to use. If `None`, the first supplier in
                         the order is used. (If several are in the order, this fails.)
        :return: Saved, complete Shipment object
        :rtype: shoop.shop.models.shipments.Shipment
        """
        suppliers_to_product_quantities = defaultdict(
            lambda: defaultdict(lambda: 0))
        lines = (self.lines.filter(type=OrderLineType.PRODUCT).values_list(
            "supplier_id", "product_id", "quantity"))
        for supplier_id, product_id, quantity in lines:
            if product_id:
                suppliers_to_product_quantities[supplier_id][
                    product_id] += quantity

        if not suppliers_to_product_quantities:
            raise NoProductsToShipException(
                "Could not find any products to ship.")

        if supplier is None:
            if len(suppliers_to_product_quantities) > 1:  # pragma: no cover
                raise ValueError(
                    "Can only use create_shipment_of_all_products when there is only one supplier"
                )
            supplier_id, quantities = suppliers_to_product_quantities.popitem()
            supplier = Supplier.objects.get(pk=supplier_id)
        else:
            quantities = suppliers_to_product_quantities[supplier.id]

        products = dict(
            (product.pk, product)
            for product in Product.objects.filter(pk__in=quantities.keys()))
        quantities = dict((products[product_id], quantity)
                          for (product_id, quantity) in quantities.items())
        return self.create_shipment(supplier, quantities)

    def check_all_verified(self):
        if not self.all_verified:
            new_all_verified = (not self.lines.filter(verified=False).exists())
            if new_all_verified:
                self.all_verified = True
                if self.require_verification:
                    self.add_log_entry(
                        _('All rows requiring verification have been verified.'
                          ))
                    self.require_verification = False
                self.save()
        return self.all_verified

    def get_purchased_attachments(self):
        from .product_media import ProductMedia

        if self.payment_status != PaymentStatus.FULLY_PAID:
            return ProductMedia.objects.none()
        prods = self.lines.exclude(product=None).values_list("product_id",
                                                             flat=True)
        return ProductMedia.objects.filter(product__in=prods,
                                           enabled=True,
                                           purchased=True)

    def get_tax_summary(self):
        """
        :rtype: taxing.TaxSummary
        """
        all_line_taxes = []
        untaxed = TaxlessPrice(0)
        for line in self.lines.all():
            line_taxes = list(line.taxes.all())
            all_line_taxes.extend(line_taxes)
            if not line_taxes:
                untaxed += line.taxless_total_price
        return taxing.TaxSummary.from_line_taxes(all_line_taxes, untaxed)

    def get_product_ids_and_quantities(self):
        quantities = defaultdict(lambda: 0)
        for product_id, quantity in self.lines.filter(
                type=OrderLineType.PRODUCT).values_list(
                    "product_id", "quantity"):
            quantities[product_id] += quantity
        return dict(quantities)

    def is_complete(self):
        return (self.status.role == OrderStatusRole.COMPLETE)

    def can_set_complete(self):
        fully_shipped = (self.shipping_status == ShippingStatus.FULLY_SHIPPED)
        canceled = (self.status.role == OrderStatusRole.CANCELED)
        return (not self.is_complete()) and fully_shipped and (not canceled)

    def check_and_set_fully_shipped(self):
        if self.shipping_status != ShippingStatus.FULLY_SHIPPED:
            if not self.get_unshipped_products():
                self.shipping_status = ShippingStatus.FULLY_SHIPPED
                self.add_log_entry(
                    _(u"All products have been shipped. Fully Shipped status set."
                      ))
                self.save(update_fields=("shipping_status", ))
                return True

    def get_known_additional_data(self):
        """
        Get a list of "known additional data" in this order's payment_data, shipping_data and extra_data.
        The list is returned in the order the fields are specified in the settings entries for said known keys.
        `dict(that_list)` can of course be used to "flatten" the list into a dict.
        :return: list of 2-tuples.
        """
        output = []
        for data_dict, name_mapping in (
            (self.payment_data, settings.SHOOP_ORDER_KNOWN_PAYMENT_DATA_KEYS),
            (self.shipping_data,
             settings.SHOOP_ORDER_KNOWN_SHIPPING_DATA_KEYS),
            (self.extra_data, settings.SHOOP_ORDER_KNOWN_EXTRA_DATA_KEYS),
        ):
            if hasattr(data_dict, "get"):
                for key, display_name in name_mapping:
                    if key in data_dict:
                        output.append(
                            (force_text(display_name), data_dict[key]))
        return output

    def get_product_summary(self):
        """Return a dict of product IDs -> {ordered, unshipped, shipped}"""

        products = defaultdict(lambda: defaultdict(lambda: Decimal(0)))
        lines = (self.lines.filter(type=OrderLineType.PRODUCT).values_list(
            "product_id", "quantity"))
        for product_id, quantity in lines:
            products[product_id]['ordered'] += quantity
            products[product_id]['unshipped'] += quantity

        from .shipments import ShipmentProduct

        shipment_prods = (ShipmentProduct.objects.filter(
            shipment__order=self).values_list("product_id", "quantity"))
        for product_id, quantity in shipment_prods:
            products[product_id]['shipped'] += quantity
            products[product_id]['unshipped'] -= quantity

        return products

    def get_unshipped_products(self):
        return dict(
            (product, summary_datum)
            for product, summary_datum in self.get_product_summary().items()
            if summary_datum['unshipped'])

    def get_status_display(self):
        return force_text(self.status)
Пример #9
0
class OrderLine(models.Model, LinePriceMixin):
    order = UnsavedForeignKey("Order",
                              related_name='lines',
                              on_delete=models.PROTECT,
                              verbose_name=_('order'))
    product = UnsavedForeignKey("Product",
                                blank=True,
                                null=True,
                                related_name="order_lines",
                                on_delete=models.PROTECT,
                                verbose_name=_('product'))
    supplier = UnsavedForeignKey("Supplier",
                                 blank=True,
                                 null=True,
                                 related_name="order_lines",
                                 on_delete=models.PROTECT,
                                 verbose_name=_('supplier'))

    parent_line = UnsavedForeignKey("self",
                                    related_name="child_lines",
                                    blank=True,
                                    null=True,
                                    on_delete=models.PROTECT,
                                    verbose_name=_('parent line'))
    ordering = models.IntegerField(default=0, verbose_name=_('ordering'))
    type = EnumIntegerField(OrderLineType,
                            default=OrderLineType.PRODUCT,
                            verbose_name=_('line type'))
    sku = models.CharField(max_length=48,
                           blank=True,
                           verbose_name=_('line SKU'))
    text = models.CharField(max_length=256, verbose_name=_('line text'))
    accounting_identifier = models.CharField(
        max_length=32, blank=True, verbose_name=_('accounting identifier'))
    require_verification = models.BooleanField(
        default=False, verbose_name=_('require verification'))
    verified = models.BooleanField(default=False, verbose_name=_('verified'))
    extra_data = JSONField(blank=True, null=True)

    # The following fields govern calculation of the prices
    quantity = QuantityField(verbose_name=_('quantity'), default=1)
    _unit_price_amount = MoneyField(verbose_name=_('unit price amount'))
    _total_discount_amount = MoneyField(
        verbose_name=_('total amount of discount'))
    _prices_include_tax = models.BooleanField(default=True)

    objects = OrderLineManager()

    class Meta:
        verbose_name = _('order line')
        verbose_name_plural = _('order lines')

    def __str__(self):
        return "%dx %s (%s)" % (self.quantity, self.text,
                                self.get_type_display())

    @property
    def unit_price(self):
        """
        Unit price of OrderLine.

        :rtype: Price
        """
        if self._prices_include_tax:
            return TaxfulPrice(self._unit_price_amount)
        else:
            return TaxlessPrice(self._unit_price_amount)

    @unit_price.setter
    def unit_price(self, price):
        """
        Set unit price of OrderLine.

        :type price: TaxfulPrice|TaxlessPrice
        """
        self._check_input_price(price)
        self._unit_price_amount = price.amount
        self._prices_include_tax = price.includes_tax

    @property
    def total_discount(self):
        """
        Total discount of OrderLine.

        :rtype: Price
        """
        if self._prices_include_tax:
            return TaxfulPrice(self._total_discount_amount)
        else:
            return TaxlessPrice(self._total_discount_amount)

    @total_discount.setter
    def total_discount(self, discount):
        """
        Set total discount of OrderLine.

        :type discount: TaxfulPrice|TaxlessPrice
        """
        self._check_input_price(discount)
        self._total_discount_amount = discount.amount
        self._prices_include_tax = discount.includes_tax

    @property
    def total_tax_amount(self):
        """
        :rtype: decimal.Decimal
        """
        return sum((x.amount for x in self.taxes.all()), decimal.Decimal(0))

    def _check_input_price(self, price):
        if not isinstance(price, Price):
            raise TypeError('%r is not a Price object' % (price, ))
        if self._unit_price_amount or self._total_discount_amount:
            if price.includes_tax != self._prices_include_tax:
                tp = TaxfulPrice if self._prices_include_tax else TaxlessPrice
                msg = 'Cannot accept %r because we want a %s'
                raise TypeError(msg % (price, tp.__name__))

    def save(self, *args, **kwargs):
        if not self.sku:
            self.sku = u""
        if self.type == OrderLineType.PRODUCT and not self.product_id:
            raise ValidationError(
                "Product-type order line can not be saved without a set product"
            )

        if self.product_id and self.type != OrderLineType.PRODUCT:
            raise ValidationError(
                "Order line has product but is not of Product type")

        if self.product_id and not self.supplier_id:
            raise ValidationError("Order line has product but no supplier")

        return super(OrderLine, self).save(*args, **kwargs)
Пример #10
0
class ShopProduct(models.Model):
    shop = models.ForeignKey("Shop", related_name="shop_products")
    product = UnsavedForeignKey("Product", related_name="shop_products")
    suppliers = models.ManyToManyField("Supplier",
                                       related_name="shop_products",
                                       blank=True)

    visible = models.BooleanField(default=True, db_index=True)
    listed = models.BooleanField(default=True, db_index=True)
    purchasable = models.BooleanField(default=True, db_index=True)
    searchable = models.BooleanField(default=True, db_index=True)
    visibility_limit = EnumIntegerField(
        ProductVisibility,
        db_index=True,
        default=ProductVisibility.VISIBLE_TO_ALL,
        verbose_name=_('visibility limitations'))
    visibility_groups = models.ManyToManyField(
        "ContactGroup",
        related_name='visible_products',
        verbose_name=_('visible for groups'),
        blank=True)
    purchase_multiple = QuantityField(default=0,
                                      verbose_name=_('purchase multiple'))
    minimum_purchase_quantity = QuantityField(
        default=1, verbose_name=_('minimum purchase'))
    limit_shipping_methods = models.BooleanField(default=False)
    limit_payment_methods = models.BooleanField(default=False)
    shipping_methods = models.ManyToManyField(
        "ShippingMethod",
        related_name='shipping_products',
        verbose_name=_('shipping methods'),
        blank=True)
    payment_methods = models.ManyToManyField("PaymentMethod",
                                             related_name='payment_products',
                                             verbose_name=_('payment methods'),
                                             blank=True)
    primary_category = models.ForeignKey("Category",
                                         related_name='primary_shop_products',
                                         verbose_name=_('primary category'),
                                         blank=True,
                                         null=True)
    categories = models.ManyToManyField("Category",
                                        related_name='shop_products',
                                        verbose_name=_('categories'),
                                        blank=True)
    shop_primary_image = models.ForeignKey(
        "ProductMedia",
        null=True,
        blank=True,
        related_name="primary_image_for_shop_products",
        on_delete=models.SET_NULL)

    # the default price of this product in the shop, taxfulness is determined in
    # `Shop.prices_include_tax`
    default_price = MoneyField(verbose_name=_("Default price"),
                               null=True,
                               blank=True)

    class Meta:
        unique_together = ((
            "shop",
            "product",
        ), )

    def is_list_visible(self):
        """
        Return True if this product should be visible in listings in general,
        without taking into account any other visibility limitations.
        :rtype: bool
        """
        if self.product.deleted:
            return False
        if not self.visible:
            return False
        if not self.listed:
            return False
        if self.product.is_variation_child():
            return False
        return True

    @property
    def primary_image(self):
        if self.shop_primary_image_id:
            return self.shop_primary_image
        else:
            return self.product.primary_image

    def get_visibility_errors(self, customer):
        if self.product.deleted:
            yield ValidationError(_('This product has been deleted.'),
                                  code="product_deleted")
        if customer and customer.is_all_seeing:  # None of the further conditions matter for omniscient customers.
            return

        if not self.visible:
            yield ValidationError(_('This product is not visible.'),
                                  code="product_not_visible")

        is_logged_in = (bool(customer) and not customer.is_anonymous)

        if not is_logged_in and self.visibility_limit != ProductVisibility.VISIBLE_TO_ALL:
            yield ValidationError(
                _('The Product is invisible to users not logged in.'),
                code="product_not_visible_to_anonymous")

        if self.visibility_limit == ProductVisibility.VISIBLE_TO_GROUPS:
            # TODO: Optimization
            user_groups = set(customer.groups.all().values_list("pk",
                                                                flat=True))
            my_groups = set(self.visibility_groups.values_list("pk",
                                                               flat=True))
            if not bool(user_groups & my_groups):
                yield ValidationError(
                    _('This product is not visible to your group.'),
                    code="product_not_visible_to_group")

        for receiver, response in get_visibility_errors.send(
                ShopProduct, shop_product=self, customer=customer):
            for error in response:
                yield error

    # TODO: Refactor get_orderability_errors, it's too complex
    def get_orderability_errors(  # noqa (C901)
            self,
            supplier,
            quantity,
            customer,
            ignore_minimum=False):
        """
        Yield ValidationErrors that would cause this product to not be orderable.

        :param supplier: Supplier to order this product from. May be None.
        :type supplier: shoop.core.models.suppliers.Supplier
        :param quantity: Quantity to order.
        :type quantity: int|Decimal
        :param customer: Customer contact.
        :type customer: shoop.core.models.Contact
        :param ignore_minimum: Ignore any limitations caused by quantity minimums.
        :type ignore_minimum: bool
        :return: Iterable[ValidationError]
        """
        for error in self.get_visibility_errors(customer):
            yield error

        if not ignore_minimum and quantity < self.minimum_purchase_quantity:
            yield ValidationError(_(
                'The purchase quantity needs to be at least %d for this product.'
            ) % self.minimum_purchase_quantity,
                                  code="purchase_quantity_not_met")

        if supplier and not self.suppliers.filter(pk=supplier.pk).exists():
            yield ValidationError(_('The product is not supplied by %s.') %
                                  supplier,
                                  code="invalid_supplier")

        if self.product.is_package_parent():
            for child_product, child_quantity in six.iteritems(
                    self.product.get_package_child_to_quantity_map()):
                child_shop_product = child_product.get_shop_instance(
                    shop=self.shop)
                if not child_shop_product:
                    yield ValidationError("%s: Not available in %s" %
                                          (child_product, self.shop),
                                          code="invalid_shop")
                for error in child_shop_product.get_orderability_errors(
                        supplier=supplier,
                        quantity=(quantity * child_quantity),
                        customer=customer,
                        ignore_minimum=ignore_minimum):
                    code = getattr(error, "code", None)
                    yield ValidationError("%s: %s" % (child_product, error),
                                          code=code)

        if supplier and self.product.stock_behavior == StockBehavior.STOCKED:
            for error in supplier.get_orderability_errors(self,
                                                          quantity,
                                                          customer=customer):
                yield error

        purchase_multiple = self.purchase_multiple
        if quantity > 0 and purchase_multiple > 1 and (quantity %
                                                       purchase_multiple) != 0:
            p = (quantity // purchase_multiple)
            smaller_p = max(purchase_multiple, p * purchase_multiple)
            larger_p = max(purchase_multiple, (p + 1) * purchase_multiple)
            if larger_p == smaller_p:
                message = _(
                    'The product can only be ordered in multiples of %(package_size)s, '
                    'for example %(smaller_p)s %(unit)s.') % {
                        "package_size": purchase_multiple,
                        "smaller_p": smaller_p,
                        "unit": self.product.sales_unit,
                    }
            else:
                message = _(
                    'The product can only be ordered in multiples of %(package_size)s, '
                    'for example %(smaller_p)s or %(larger_p)s %(unit)s.') % {
                        "package_size": purchase_multiple,
                        "smaller_p": smaller_p,
                        "larger_p": larger_p,
                        "unit": self.product.sales_unit,
                    }
            yield ValidationError(message, code="invalid_purchase_multiple")

        for receiver, response in get_orderability_errors.send(
                ShopProduct,
                shop_product=self,
                customer=customer,
                supplier=supplier,
                quantity=quantity):
            for error in response:
                yield error

    def raise_if_not_orderable(self,
                               supplier,
                               customer,
                               quantity,
                               ignore_minimum=False):
        for message in self.get_orderability_errors(
                supplier=supplier,
                quantity=quantity,
                customer=customer,
                ignore_minimum=ignore_minimum):
            raise ProductNotOrderableProblem(message.args[0])

    def raise_if_not_visible(self, customer):
        for message in self.get_visibility_errors(customer=customer):
            raise ProductNotVisibleProblem(message.args[0])

    def is_orderable(self, supplier, customer, quantity):
        for message in self.get_orderability_errors(supplier=supplier,
                                                    quantity=quantity,
                                                    customer=customer):
            return False
        return True

    @property
    def quantity_step(self):
        """
        Quantity step for purchasing this product.

        :rtype: decimal.Decimal

        Example:
            <input type="number" step="{{ shop_product.quantity_step }}">
        """
        if self.purchase_multiple:
            return self.purchase_multiple
        return self.product.sales_unit.quantity_step

    @property
    def rounded_minimum_purchase_quantity(self):
        """
        The minimum purchase quantity, rounded to the sales unit's precision.

        :rtype: decimal.Decimal

        Example:
            <input type="number"
                min="{{ shop_product.rounded_minimum_purchase_quantity }}"
                value="{{ shop_product.rounded_minimum_purchase_quantity }}">

        """
        return self.product.sales_unit.round(self.minimum_purchase_quantity)

    @property
    def images(self):
        return self.product.media.filter(
            shops=self.shop, kind=ProductMediaKind.IMAGE).order_by("ordering")