Exemple #1
0
    class Foo(object):
        taxful_value = 110
        taxless_value = 100
        currency = 'USD'

        taxful_price = TaxfulPriceProperty('taxful_value', 'currency')
        taxless_price = TaxlessPriceProperty('taxless_value', 'currency')
Exemple #2
0
class Basket(MoneyPropped, 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, verbose_name=_("key"), unique=True, db_index=True)

    shop = models.ForeignKey(on_delete=models.CASCADE, to="Shop", verbose_name=_("shop"))

    customer = models.ForeignKey(
        on_delete=models.CASCADE,
        to="Contact",
        blank=True,
        null=True,
        related_name="customer_core_baskets",
        verbose_name=_("customer"),
    )
    orderer = models.ForeignKey(
        on_delete=models.CASCADE,
        to="PersonContact",
        blank=True,
        null=True,
        related_name="orderer_core_baskets",
        verbose_name=_("orderer"),
    )
    creator = models.ForeignKey(
        on_delete=models.CASCADE,
        to=settings.AUTH_USER_MODEL,
        blank=True,
        null=True,
        related_name="core_baskets_created",
        verbose_name=_("creator"),
    )

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

    # For statistics etc., as `data` is opaque:
    taxful_total_price = TaxfulPriceProperty("taxful_total_price_value", "currency")
    taxless_total_price = TaxlessPriceProperty("taxless_total_price_value", "currency")

    taxless_total_price_value = MoneyValueField(default=0, null=True, blank=True, verbose_name=_("taxless total price"))
    taxful_total_price_value = MoneyValueField(default=0, null=True, blank=True, verbose_name=_("taxful total price"))
    currency = CurrencyField(verbose_name=_("currency"))
    prices_include_tax = models.BooleanField(verbose_name=_("prices include tax"))

    product_count = models.IntegerField(default=0, verbose_name=_("product_count"))
    products = ManyToManyField("Product", blank=True, verbose_name=_("products"))

    class Meta:
        verbose_name = _("basket")
        verbose_name_plural = _("baskets")
Exemple #3
0
class StoredBasket(MoneyPropped, 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, verbose_name=_('key'))

    shop = models.ForeignKey(Shop, on_delete=models.CASCADE, verbose_name=_('shop'))

    customer = models.ForeignKey(
        Contact, blank=True, null=True,
        on_delete=models.CASCADE,
        related_name="customer_baskets",
        verbose_name=_('customer')
    )
    orderer = models.ForeignKey(
        PersonContact, blank=True, null=True,
        on_delete=models.CASCADE,
        related_name="orderer_baskets",
        verbose_name=_('orderer')
    )
    creator = models.ForeignKey(
        settings.AUTH_USER_MODEL, blank=True, null=True,
        on_delete=models.CASCADE,
        related_name="baskets_created",
        verbose_name=_('creator')
    )

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

    # For statistics etc., as `data` is opaque:
    taxful_total_price = TaxfulPriceProperty('taxful_total_price_value', 'currency')
    taxless_total_price = TaxlessPriceProperty('taxless_total_price_value', 'currency')

    taxless_total_price_value = MoneyValueField(default=0, null=True, blank=True, verbose_name=_('taxless total price'))
    taxful_total_price_value = MoneyValueField(default=0, null=True, blank=True, verbose_name=_('taxful total price'))
    currency = CurrencyField(verbose_name=_('currency'))
    prices_include_tax = models.BooleanField(verbose_name=_('prices include tax'))

    product_count = models.IntegerField(default=0, verbose_name=_('product_count'))
    products = ManyToManyField(Product, blank=True, verbose_name=_('products'))

    class Meta:
        app_label = "shuup_front"
        verbose_name = _('stored basket')
        verbose_name_plural = _('stored baskets')
Exemple #4
0
class Order(MoneyPropped, models.Model):
    # Identification
    shop = UnsavedForeignKey("Shop",
                             on_delete=models.PROTECT,
                             verbose_name=_("shop"))
    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,
                                       db_index=True,
                                       verbose_name=_("modified on"))
    identifier = InternalIdentifierField(unique=True,
                                         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,
        on_delete=models.PROTECT,
        verbose_name=_("customer"),
    )
    orderer = UnsavedForeignKey(
        "PersonContact",
        related_name="orderer_orders",
        blank=True,
        null=True,
        on_delete=models.PROTECT,
        verbose_name=_("orderer"),
    )
    billing_address = models.ForeignKey(
        "ImmutableAddress",
        related_name="billing_orders",
        blank=True,
        null=True,
        on_delete=models.PROTECT,
        verbose_name=_("billing address"),
    )
    shipping_address = models.ForeignKey(
        "ImmutableAddress",
        related_name="shipping_orders",
        blank=True,
        null=True,
        on_delete=models.PROTECT,
        verbose_name=_("shipping address"),
    )
    tax_number = models.CharField(max_length=64,
                                  blank=True,
                                  verbose_name=_("tax number"))
    phone = models.CharField(max_length=64,
                             blank=True,
                             verbose_name=_("phone"))
    email = models.EmailField(max_length=128,
                              blank=True,
                              verbose_name=_("email address"))

    # Customer related information that might change after order, but is important
    # for accounting and/or reports later.
    account_manager = models.ForeignKey("PersonContact",
                                        blank=True,
                                        null=True,
                                        on_delete=models.PROTECT,
                                        verbose_name=_("account manager"))
    customer_groups = models.ManyToManyField(
        "ContactGroup",
        related_name="customer_group_orders",
        verbose_name=_("customer groups"),
        blank=True)
    tax_group = models.ForeignKey("CustomerTaxGroup",
                                  blank=True,
                                  null=True,
                                  on_delete=models.PROTECT,
                                  verbose_name=_("tax group"))

    # Status
    creator = UnsavedForeignKey(
        settings.AUTH_USER_MODEL,
        related_name="orders_created",
        blank=True,
        null=True,
        on_delete=models.PROTECT,
        verbose_name=_("creating user"),
    )
    modified_by = UnsavedForeignKey(
        settings.AUTH_USER_MODEL,
        related_name="orders_modified",
        blank=True,
        null=True,
        on_delete=models.PROTECT,
        verbose_name=_("modifier user"),
    )
    deleted = models.BooleanField(db_index=True,
                                  default=False,
                                  verbose_name=_("deleted"))
    status = UnsavedForeignKey("OrderStatus",
                               verbose_name=_("status"),
                               on_delete=models.PROTECT)
    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=100,
        blank=True,
        default="",
        verbose_name=_("payment method name"))
    payment_data = JSONField(blank=True,
                             null=True,
                             verbose_name=_("payment data"))

    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=100,
        blank=True,
        default="",
        verbose_name=_("shipping method name"))
    shipping_data = JSONField(blank=True,
                              null=True,
                              verbose_name=_("shipping data"))

    extra_data = JSONField(blank=True, null=True, verbose_name=_("extra data"))

    # Money stuff
    taxful_total_price = TaxfulPriceProperty("taxful_total_price_value",
                                             "currency")
    taxless_total_price = TaxlessPriceProperty("taxless_total_price_value",
                                               "currency")

    taxful_total_price_value = MoneyValueField(editable=False,
                                               verbose_name=_("grand total"),
                                               default=0)
    taxless_total_price_value = MoneyValueField(
        editable=False, verbose_name=_("taxless total"), default=0)
    currency = CurrencyField(verbose_name=_("currency"))
    prices_include_tax = models.BooleanField(
        verbose_name=_("prices include tax"))

    display_currency = CurrencyField(blank=True,
                                     verbose_name=_("display currency"))
    display_currency_rate = models.DecimalField(
        max_digits=36,
        decimal_places=9,
        default=1,
        verbose_name=_("display currency rate"))

    # 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,
                                      db_index=True,
                                      verbose_name=_("order date"))
    payment_date = models.DateTimeField(null=True,
                                        editable=False,
                                        verbose_name=_("payment date"))

    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=False, verbose_name=_("marketing permission"))
    _codes = JSONField(blank=True, null=True, verbose_name=_("codes"))

    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 ShuupSettings.get_setting("SHUUP_ENABLE_MULTIPLE_SHOPS"):
            return "Order %s (%s, %s)" % (self.identifier, self.shop.name,
                                          name)
        else:
            return "Order %s (%s)" % (self.identifier, name)

    @property
    def codes(self):
        return list(self._codes or [])

    @codes.setter
    def codes(self, value):
        codes = []
        for code in value:
            if not isinstance(code, six.text_type):
                raise TypeError("Error! `codes` must be a list of strings.")
            codes.append(code)
        self._codes = codes

    def cache_prices(self):
        taxful_total = TaxfulPrice(0, self.currency)
        taxless_total = TaxlessPrice(0, self.currency)
        for line in self.lines.all().prefetch_related("taxes"):
            taxful_total += line.taxful_price
            taxless_total += line.taxless_price
        self.taxful_total_price = taxful_total
        self.taxless_total_price = taxless_total

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

        fields = ("tax_number", "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

        if not self.id and self.customer:
            # These fields are used for reporting and should not
            # change after create even if empty at the moment of ordering.
            self.account_manager = getattr(self.customer, "account_manager",
                                           None)
            self.tax_group = self.customer.tax_group

    def _cache_contact_values_post_create(self):
        if self.customer:
            # These fields are used for reporting and should not
            # change after create even if empty at the  moment of ordering.
            self.customer_groups.set(self.customer.groups.all())

    def _cache_values(self):
        self._cache_contact_values()

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

        if not self.currency:
            self.currency = self.shop.currency

        if not self.prices_include_tax:
            self.prices_include_tax = self.shop.prices_include_tax

        if not self.display_currency:
            self.display_currency = self.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)

        if not self.modified_by:
            self.modified_by = self.creator

    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 save(self, *args, **kwargs):
        if not self.creator_id:
            if not settings.SHUUP_ALLOW_ANONYMOUS_ORDERS:
                raise ValidationError(
                    "Error! Anonymous (userless) orders are not allowed "
                    "when `SHUUP_ALLOW_ANONYMOUS_ORDERS` is not enabled.")
        self._cache_values()
        first_save = not self.pk
        old_status = self.status

        if not first_save:
            old_status = Order.objects.only("status").get(pk=self.pk).status

        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()
            self._cache_contact_values_post_create()

        order_changed.send(type(self), order=self)

        if self.status != old_status:
            order_status_changed.send(type(self),
                                      order=self,
                                      old_status=old_status,
                                      new_status=self.status)

    def delete(self, using=None):
        if not self.deleted:
            self.deleted = True
            self.add_log_entry("Success! Deleted (soft).",
                               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 was marked as paid."))
            self.payment_status = PaymentStatus.FULLY_PAID
            self.payment_date = local_now()
            self.save()

    def _set_partially_paid(self):
        if self.payment_status != PaymentStatus.PARTIALLY_PAID:
            self.add_log_entry(_("Order was marked as partially paid."))
            self.payment_status = PaymentStatus.PARTIALLY_PAID
            self.save()

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

    def is_partially_paid(self):
        return self.payment_status == PaymentStatus.PARTIALLY_PAID

    def is_deferred(self):
        return self.payment_status == PaymentStatus.DEFERRED

    def is_not_paid(self):
        return self.payment_status == PaymentStatus.NOT_PAID

    def get_total_paid_amount(self):
        amounts = self.payments.values_list("amount_value", flat=True)
        return Money(sum(amounts, Decimal(0)), self.currency)

    def get_total_unpaid_amount(self):
        difference = self.taxful_total_price.amount - self.get_total_paid_amount(
        )
        return max(difference, Money(0, self.currency))

    def can_create_payment(self):
        zero = Money(0, self.currency)
        return not (self.is_paid() or self.is_canceled()
                    ) and self.get_total_unpaid_amount() > zero

    def create_payment(self, amount, payment_identifier=None, description=""):
        """
        Create a payment with a 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` and the order is not
        a zero price order, 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.
        :type amount: Money
        :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
          a number of payments in the order).
        :type payment_identifier: str|None
        :param description:
          Description of the payment. Will be set to `method` property
          of the created payment.
        :type description: str

        :returns: The created Payment object
        :rtype: shuup.core.models.Payment
        """
        assert isinstance(amount, Money)
        assert amount.currency == self.currency

        payments = self.payments.order_by("created_on")

        total_paid_amount = self.get_total_paid_amount()
        if total_paid_amount >= self.taxful_total_price.amount and self.taxful_total_price:
            raise NoPaymentToCreateException(
                "Error! 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_value=amount.value,
            description=description,
        )

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

        payment_created.send(sender=type(self), order=self, payment=payment)
        return payment

    def can_create_shipment(self):
        return self.get_unshipped_products(
        ) and not self.is_canceled() and self.shipping_address

    # TODO: Rethink either the usage of shipment parameter or renaming the method for 2.0
    @atomic
    def create_shipment(self,
                        product_quantities,
                        supplier=None,
                        shipment=None):
        """
        Create a shipment for this order from `product_quantities`.
        `product_quantities` is expected to be a dict, which maps 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.

        Orders without a shipping address defined, will raise `NoShippingAddressException`.

        :param product_quantities: a dict mapping Product instances to quantities to ship.
        :type product_quantities: dict[shuup.shop.models.Product, decimal.Decimal]
        :param supplier: Optional Supplier for this product. No validation is made.
        :param shipment: Optional unsaved Shipment for ShipmentProduct's. If not given
                         Shipment is created based on supplier parameter.
        :raises: NoProductsToShipException, NoShippingAddressException
        :return: Saved, complete Shipment object.
        :rtype: shuup.core.models.Shipment
        """
        if not product_quantities or not any(
                quantity > 0 for quantity in product_quantities.values()):
            raise NoProductsToShipException(
                "Error! No products to ship (`quantities` is empty or has no quantity over 0)."
            )

        if self.shipping_address is None:
            raise NoShippingAddressException(
                "Error! Shipping address is not defined for this order.")

        assert supplier or shipment
        if shipment:
            assert shipment.order == self
        else:
            from ._shipments import Shipment

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

        if not supplier:
            supplier = shipment.supplier

        supplier.module.ship_products(shipment, product_quantities)

        self.add_log_entry(
            _("Success! Shipment #%d was created.") % shipment.id)
        self.update_shipping_status()
        shipment_created.send(sender=type(self), order=self, shipment=shipment)
        shipment_created_and_processed.send(sender=type(self),
                                            order=self,
                                            shipment=shipment)
        return shipment

    def can_create_refund(self, supplier=None):
        unrefunded_amount = self.get_total_unrefunded_amount(supplier)
        unrefunded_quantity = self.get_total_unrefunded_quantity(supplier)
        return ((unrefunded_amount.value > 0 or unrefunded_quantity > 0)
                and not self.is_canceled()
                and (self.payment_status
                     not in (PaymentStatus.NOT_PAID, PaymentStatus.CANCELED)))

    @atomic
    def create_refund(self, refund_data, created_by=None, supplier=None):
        """
        Create a refund if passed a list of refund line data.

        Refund line data is simply a list of dictionaries where
        each dictionary contains data for a particular refund line.

        Additionally, if the parent line is of `enum` type
        `OrderLineType.PRODUCT` and the `restock_products` boolean
        flag is set to `True`, the products will be restocked with the
        exact amount set in the order supplier's `quantity` field.

        :param refund_data: List of dicts containing refund data.
        :type refund_data: [dict]
        :param created_by: Refund creator's user instance, used for
                           adjusting supplier stock.
        :type created_by: django.contrib.auth.User|None
        """
        tax_module = taxing.get_tax_module()
        refund_lines = tax_module.create_refund_lines(self, supplier,
                                                      created_by, refund_data)

        self.cache_prices()
        self.save()
        self.update_shipping_status()
        self.update_payment_status()
        refund_created.send(sender=type(self),
                            order=self,
                            refund_lines=refund_lines)

    def create_full_refund(self, restock_products=False, created_by=None):
        """
        Create a full refund for entire order content, with the option of
        restocking stocked products.

        :param restock_products: Boolean indicating whether to also restock the products.
        :param created_by: Refund creator's user instance, used for
                           adjusting supplier stock.
        :type restock_products: bool|False
        """
        if self.has_refunds():
            raise NoRefundToCreateException
        self.cache_prices()
        line_data = [{
            "line": line,
            "quantity": line.quantity,
            "amount": line.taxful_price.amount,
            "restock_products": restock_products,
        } for line in self.lines.filter(quantity__gt=0)
                     if line.type != OrderLineType.REFUND]
        self.create_refund(line_data, created_by)

    def get_total_refunded_amount(self, supplier=None):
        refunds = self.lines.refunds()
        if supplier:
            refunds = refunds.filter(
                Q(parent_line__supplier=supplier) | Q(supplier=supplier))
        total = sum([line.taxful_price.amount.value for line in refunds])
        return Money(-total, self.currency)

    def get_total_unrefunded_amount(self, supplier=None):
        if supplier:
            total_refund_amount = sum([
                line.max_refundable_amount.value for line in self.lines.filter(
                    supplier=supplier).exclude(type=OrderLineType.REFUND)
            ])
            arbitrary_refunds = abs(
                sum([
                    refund_line.taxful_price.value for refund_line in
                    self.lines.filter(supplier=supplier,
                                      parent_line__isnull=True,
                                      type=OrderLineType.REFUND)
                ]))
            return (Money(max(total_refund_amount -
                              arbitrary_refunds, 0), self.currency)
                    if total_refund_amount else Money(0, self.currency))
        return max(self.taxful_total_price.amount, Money(0, self.currency))

    def get_total_unrefunded_quantity(self, supplier=None):
        queryset = self.lines.all()
        if supplier:
            queryset = queryset.filter(supplier=supplier)
        return sum([line.max_refundable_quantity for line in queryset])

    def get_total_tax_amount(self):
        return sum((line.tax_amount for line in self.lines.all()),
                   Money(0, self.currency))

    def has_refunds(self):
        return self.lines.refunds().exists()

    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: shuup.shop.models.Shipment
        """
        from ._products import ShippingMode

        suppliers_to_product_quantities = defaultdict(
            lambda: defaultdict(lambda: 0))
        lines = self.lines.filter(
            type=OrderLineType.PRODUCT,
            product__shipping_mode=ShippingMode.SHIPPED).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(
                "Error! Could not find any products to ship.")

        if supplier is None:
            if len(suppliers_to_product_quantities) > 1:  # pragma: no cover
                raise ValueError(
                    "Error! `create_shipment_of_all_products` can be used only when there is a single 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(quantities, supplier=supplier)

    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, self.currency)
        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_price
        return taxing.TaxSummary.from_line_taxes(all_line_taxes, untaxed)

    def get_product_ids_and_quantities(self, supplier=None):
        lines = self.lines.filter(type=OrderLineType.PRODUCT)
        if supplier:
            supplier_id = supplier if isinstance(
                supplier, six.integer_types) else supplier.pk
            lines = lines.filter(supplier_id=supplier_id)

        quantities = defaultdict(lambda: 0)
        for product_id, quantity in lines.values_list("product_id",
                                                      "quantity"):
            quantities[product_id] += quantity
        return dict(quantities)

    def has_products(self):
        return self.lines.products().exists()

    def has_products_requiring_shipment(self, supplier=None):
        from ._products import ShippingMode

        lines = self.lines.products().filter(
            product__shipping_mode=ShippingMode.SHIPPED)
        if supplier:
            supplier_id = supplier if isinstance(
                supplier, six.integer_types) else supplier.pk
            lines = lines.filter(supplier_id=supplier_id)
        return lines.exists()

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

    def can_set_complete(self):
        return not (self.is_complete() or self.is_canceled()
                    or bool(self.get_unshipped_products()))

    def is_fully_shipped(self):
        return self.shipping_status == ShippingStatus.FULLY_SHIPPED

    def is_partially_shipped(self):
        return self.shipping_status == ShippingStatus.PARTIALLY_SHIPPED

    def is_canceled(self):
        return self.status.role == OrderStatusRole.CANCELED

    def can_set_canceled(self):
        canceled = self.status.role == OrderStatusRole.CANCELED
        paid = self.is_paid()
        shipped = self.shipping_status != ShippingStatus.NOT_SHIPPED
        return not (canceled or paid or shipped)

    def update_shipping_status(self):
        status_before_update = self.shipping_status
        if not self.get_unshipped_products():
            self.shipping_status = ShippingStatus.FULLY_SHIPPED
        elif self.shipments.all_except_deleted().count():
            self.shipping_status = ShippingStatus.PARTIALLY_SHIPPED
        else:
            self.shipping_status = ShippingStatus.NOT_SHIPPED
        if status_before_update != self.shipping_status:
            self.add_log_entry(
                _("New shipping status is set to: %(shipping_status)s." %
                  {"shipping_status": self.shipping_status}))
            self.save(update_fields=("shipping_status", ))

    def update_payment_status(self):
        status_before_update = self.payment_status
        if self.get_total_unpaid_amount().value == 0:
            self.payment_status = PaymentStatus.FULLY_PAID
        elif self.get_total_paid_amount().value > 0:
            self.payment_status = PaymentStatus.PARTIALLY_PAID
        elif self.payment_status != PaymentStatus.DEFERRED:  # Do not make deferred here not paid
            self.payment_status = PaymentStatus.NOT_PAID
        if status_before_update != self.payment_status:
            self.add_log_entry(
                _("New payment status is set to: %(payment_status)s." %
                  {"payment_status": self.payment_status}))
            self.save(update_fields=("payment_status", ))

    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.SHUUP_ORDER_KNOWN_PAYMENT_DATA_KEYS),
            (self.shipping_data,
             settings.SHUUP_ORDER_KNOWN_SHIPPING_DATA_KEYS),
            (self.extra_data, settings.SHUUP_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, supplier=None):
        """Return a dict of product IDs -> {ordered, unshipped, refunded, shipped, line_text, suppliers}"""
        supplier_id = (supplier if isinstance(supplier, six.integer_types) else
                       supplier.pk) if supplier else None

        products = defaultdict(lambda: defaultdict(lambda: Decimal(0)))

        def _append_suppliers_info(product_id, supplier):
            if not products[product_id]["suppliers"]:
                products[product_id]["suppliers"] = [supplier]
            elif supplier not in products[product_id]["suppliers"]:
                products[product_id]["suppliers"].append(supplier)

        # Quantity for all orders
        # Note! This contains all product lines so we do not need to worry
        # about suppliers after this.
        lines = self.lines.filter(type=OrderLineType.PRODUCT)
        if supplier_id:
            lines = lines.filter(supplier_id=supplier_id)

        lines_values = lines.values_list("product_id", "text", "quantity",
                                         "supplier__name")
        for product_id, line_text, quantity, supplier_name in lines_values:
            products[product_id]["line_text"] = line_text
            products[product_id]["ordered"] += quantity
            _append_suppliers_info(product_id, supplier_name)

        # Quantity to ship
        for product_id, quantity in self._get_to_ship_quantities(supplier_id):
            products[product_id]["unshipped"] += quantity

        # Quantity shipped
        for product_id, quantity in self._get_shipped_quantities(supplier_id):
            products[product_id]["shipped"] += quantity
            products[product_id]["unshipped"] -= quantity

        # Quantity refunded
        for product_id in self._get_refunded_product_ids(supplier_id):
            refunds = self.lines.refunds().filter(
                parent_line__product_id=product_id)
            refunded_quantity = refunds.aggregate(
                total=models.Sum("quantity"))["total"] or 0
            products[product_id]["refunded"] = refunded_quantity
            products[product_id]["unshipped"] = max(
                products[product_id]["unshipped"] - refunded_quantity, 0)

        return products

    def _get_to_ship_quantities(self, supplier_id):
        from ._products import ShippingMode

        lines_to_ship = self.lines.filter(
            type=OrderLineType.PRODUCT,
            product__shipping_mode=ShippingMode.SHIPPED)
        if supplier_id:
            lines_to_ship = lines_to_ship.filter(supplier_id=supplier_id)
        return lines_to_ship.values_list("product_id", "quantity")

    def _get_shipped_quantities(self, supplier_id):
        from ._shipments import ShipmentProduct, ShipmentStatus

        shipment_prods = ShipmentProduct.objects.filter(
            shipment__order=self).exclude(
                shipment__status=ShipmentStatus.DELETED)
        if supplier_id:
            shipment_prods = shipment_prods.filter(
                shipment__supplier_id=supplier_id)
        return shipment_prods.values_list("product_id", "quantity")

    def _get_refunded_product_ids(self, supplier_id):
        refunded_prods = self.lines.refunds().filter(
            type=OrderLineType.REFUND, parent_line__type=OrderLineType.PRODUCT)
        if supplier_id:
            refunded_prods = refunded_prods.filter(
                parent_line__supplier_id=supplier_id)
        return refunded_prods.distinct().values_list("parent_line__product_id",
                                                     flat=True)

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

    def get_status_display(self):
        return force_text(self.status)

    def get_payment_method_display(self):
        return force_text(self.payment_method_name)

    def get_shipping_method_display(self):
        return force_text(self.shipping_method_name)

    def get_tracking_codes(self):
        return [
            shipment.tracking_code
            for shipment in self.shipments.all_except_deleted()
            if shipment.tracking_code
        ]

    def get_sent_shipments(self):
        return self.shipments.all_except_deleted().sent()

    def can_edit(self):
        return (settings.SHUUP_ALLOW_EDITING_ORDER and not self.has_refunds()
                and not self.is_canceled() and not self.is_complete()
                and self.shipping_status == ShippingStatus.NOT_SHIPPED
                and self.payment_status == PaymentStatus.NOT_PAID)

    def get_customer_name(self):
        name_attrs = [
            "customer", "billing_address", "orderer", "shipping_address"
        ]
        for attr in name_attrs:
            if getattr(self, "%s_id" % attr):
                return getattr(self, attr).name

    def get_available_shipping_methods(self):
        """
        Get available shipping methods.

        :rtype: list[ShippingMethod]
        """
        from shuup.core.models import ShippingMethod

        product_ids = self.lines.products().values_list("id", flat=True)
        return [
            m for m in ShippingMethod.objects.available(shop=self.shop,
                                                        products=product_ids)
            if m.is_available_for(self)
        ]

    def get_available_payment_methods(self):
        """
        Get available payment methods.

        :rtype: list[PaymentMethod]
        """
        from shuup.core.models import PaymentMethod

        product_ids = self.lines.products().values_list("id", flat=True)
        return [
            m for m in PaymentMethod.objects.available(shop=self.shop,
                                                       products=product_ids)
            if m.is_available_for(self)
        ]
Exemple #5
0
class Order(MoneyPropped, models.Model):
    # Identification
    shop = UnsavedForeignKey("Shop", on_delete=models.PROTECT, verbose_name=_('shop'))
    created_on = models.DateTimeField(auto_now_add=True, editable=False, verbose_name=_('created on'))
    modified_on = models.DateTimeField(auto_now_add=True, editable=False, verbose_name=_('modified on'))
    identifier = InternalIdentifierField(unique=True, 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,
        on_delete=models.PROTECT,
        verbose_name=_('customer'))
    orderer = UnsavedForeignKey(
        "PersonContact", related_name='orderer_orders', blank=True, null=True,
        on_delete=models.PROTECT,
        verbose_name=_('orderer'))
    billing_address = models.ForeignKey(
        "ImmutableAddress", related_name="billing_orders",
        blank=True, null=True,
        on_delete=models.PROTECT,
        verbose_name=_('billing address'))
    shipping_address = models.ForeignKey(
        "ImmutableAddress", related_name='shipping_orders',
        blank=True, null=True,
        on_delete=models.PROTECT,
        verbose_name=_('shipping address'))
    tax_number = models.CharField(max_length=20, blank=True, verbose_name=_('tax number'))
    phone = models.CharField(max_length=64, 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,
        on_delete=models.PROTECT,
        verbose_name=_('creating user'))
    modified_by = UnsavedForeignKey(
        settings.AUTH_USER_MODEL, related_name='orders_modified', blank=True, null=True,
        on_delete=models.PROTECT,
        verbose_name=_('modifier user'))
    deleted = models.BooleanField(db_index=True, default=False, verbose_name=_('deleted'))
    status = UnsavedForeignKey("OrderStatus", verbose_name=_('status'), on_delete=models.PROTECT)
    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=100, blank=True, default="",
        verbose_name=_('payment method name'))
    payment_data = JSONField(blank=True, null=True, verbose_name=_('payment data'))

    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=100, blank=True, default="",
        verbose_name=_('shipping method name'))
    shipping_data = JSONField(blank=True, null=True, verbose_name=_('shipping data'))

    extra_data = JSONField(blank=True, null=True, verbose_name=_('extra data'))

    # Money stuff
    taxful_total_price = TaxfulPriceProperty('taxful_total_price_value', 'currency')
    taxless_total_price = TaxlessPriceProperty('taxless_total_price_value', 'currency')

    taxful_total_price_value = MoneyValueField(editable=False, verbose_name=_('grand total'), default=0)
    taxless_total_price_value = MoneyValueField(editable=False, verbose_name=_('taxless total'), default=0)
    currency = CurrencyField(verbose_name=_('currency'))
    prices_include_tax = models.BooleanField(verbose_name=_('prices include tax'))

    display_currency = CurrencyField(blank=True, verbose_name=_('display currency'))
    display_currency_rate = models.DecimalField(
        max_digits=36, decimal_places=9, default=1, verbose_name=_('display currency rate')
    )

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

    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'))
    _codes = JSONField(blank=True, null=True, verbose_name=_('codes'))

    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.SHUUP_ENABLE_MULTIPLE_SHOPS:
            return "Order %s (%s, %s)" % (self.identifier, self.shop.name, name)
        else:
            return "Order %s (%s)" % (self.identifier, name)

    @property
    def codes(self):
        return list(self._codes or [])

    @codes.setter
    def codes(self, value):
        codes = []
        for code in value:
            if not isinstance(code, six.text_type):
                raise TypeError('codes must be a list of strings')
            codes.append(code)
        self._codes = codes

    def cache_prices(self):
        taxful_total = TaxfulPrice(0, self.currency)
        taxless_total = TaxlessPrice(0, self.currency)
        for line in self.lines.all():
            taxful_total += line.taxful_price
            taxless_total += line.taxless_price
        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 = ("tax_number", "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.SHUUP_DEFAULT_ORDER_LABEL

        if not self.currency:
            self.currency = self.shop.currency

        if not self.prices_include_tax:
            self.prices_include_tax = self.shop.prices_include_tax

        if not self.display_currency:
            self.display_currency = self.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)

        if not self.modified_by:
            self.modified_by = self.creator

    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 save(self, *args, **kwargs):
        if not self.creator_id:
            if not settings.SHUUP_ALLOW_ANONYMOUS_ORDERS:
                raise ValidationError(
                    "Anonymous (userless) orders are not allowed "
                    "when SHUUP_ALLOW_ANONYMOUS_ORDERS is not enabled.")
        self._cache_values()
        first_save = (not self.pk)
        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()
        for line in self.lines.exclude(product_id=None):
            line.supplier.module.update_stock(line.product_id)

    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 _set_partially_paid(self):
        if self.payment_status != PaymentStatus.PARTIALLY_PAID:
            self.add_log_entry(_('Order marked as partially paid.'))
            self.payment_status = PaymentStatus.PARTIALLY_PAID
            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_value', flat=True)
        return Money(sum(amounts, Decimal(0)), self.currency)

    def get_total_unpaid_amount(self):
        difference = self.taxful_total_price.amount - self.get_total_paid_amount()
        return max(difference, Money(0, self.currency))

    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
        :type amount: Money
        :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).
        :type payment_identifier: str|None
        :param description:
          Description of the payment. Will be set to `method` property
          of the created payment.
        :type description: str

        :returns: The created Payment object
        :rtype: shuup.core.models.Payment
        """
        assert isinstance(amount, Money)
        assert amount.currency == self.currency

        payments = self.payments.order_by('created_on')

        total_paid_amount = self.get_total_paid_amount()
        if total_paid_amount >= self.taxful_total_price.amount:
            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_value=amount.value,
            description=description,
        )

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

        return payment

    @atomic
    def create_shipment(self, product_quantities, supplier=None, shipment=None):
        """
        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 product_quantities: a dict mapping Product instances to quantities to ship
        :type product_quantities: dict[shuup.shop.models.Product, decimal.Decimal]
        :param supplier: Optional Supplier for this product. No validation is made
                         as to whether the given supplier supplies the products.
        :param shipment: Optional unsaved Shipment for ShipmentProduct's. If not given
                         Shipment is created based on supplier parameter.
        :raises: NoProductsToShipException
        :return: Saved, complete Shipment object
        :rtype: shuup.core.models.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).")

        assert (supplier or shipment)
        if shipment:
            assert shipment.order == self

        from ._shipments import ShipmentProduct
        if not shipment:
            from ._shipments import Shipment
            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.update_shipping_status()
        shipment_created.send(sender=type(self), order=self, shipment=shipment)
        return shipment

    def create_refund(self, refund_data, created_by=None):
        """
        Create a refund if passed a list of refund line data.

        Refund line data is simply a list of dictionaries where
        each dictionary contains data for a particular refund line.

        If refund line data includes a parent line, the refund is
        associated with that line and cannot exceed the line amount.

        Additionally, if the parent line is of enum type
        `OrderLineType.PRODUCT` and the `restock_products` boolean
        flag is set to `True`, the products will be restocked with the
        order's supplier the exact amount of the value of the `quantity`
        field.

        :param refund_data: List of dicts containing refund data.
        :type refund_data: [dict]
        :param created_by: Refund creator's user instance, used for
                           adjusting supplier stock.
        :type created_by: django.contrib.auth.User|None
        """
        index = self.lines.all().aggregate(models.Max("ordering"))["ordering__max"]
        zero = Money(0, self.currency)
        refund_lines = []
        for refund in refund_data:
            index += 1
            amount = refund.get("amount", zero)
            quantity = refund.get("quantity", 0)
            parent_line = refund.get("line")
            restock_products = refund.get("restock_products")

            # TODO: Also raise this if the sum amount of refunds exceeds total,
            #       order amount, and do so before creating any order lines
            self.cache_prices()
            if amount > self.taxful_total_price.amount:
                raise RefundExceedsAmountException

            unit_price = parent_line.base_unit_price.amount if parent_line else zero
            total_price = unit_price * quantity + amount

            refund_line = OrderLine.objects.create(
                text=_("Refund for %s" % parent_line.text) if parent_line else _("Manual refund"),
                order=self,
                type=OrderLineType.REFUND,
                parent_line=parent_line,
                ordering=index,
                base_unit_price_value=-total_price,
                quantity=1
            )
            refund_lines.append(refund_line)

            if parent_line and parent_line.type == OrderLineType.PRODUCT:
                product = parent_line.product
            else:
                product = None

            if restock_products and quantity and product and (product.stock_behavior == StockBehavior.STOCKED):
                parent_line.supplier.adjust_stock(product.id, quantity, created_by=created_by)

        self.cache_prices()
        self.save()
        refund_created.send(sender=type(self), order=self, refund_lines=refund_lines)

    def create_full_refund(self, restock_products=False):
        """
        Create a full for entire order contents, with the option of
        restocking stocked products.

        :param restock_products: Boolean indicating whether to restock products
        :type restock_products: bool|False
        """
        if self.has_refunds():
            raise NoRefundToCreateException
        self.cache_prices()
        amount = self.taxful_total_price.amount
        self.create_refund([{"amount": amount}])
        if restock_products:
            for product_line in self.lines.filter(
                type=OrderLineType.PRODUCT, product__stock_behavior=StockBehavior.STOCKED
            ):
                product_line.supplier.adjust_stock(product_line.product.id, product_line.quantity)

    def get_total_refunded_amount(self):
        total = sum(line.taxful_price.amount.value for line in self.lines.refunds())
        return Money(-total, self.currency)

    def get_total_unrefunded_amount(self):
        return max(self.taxful_total_price.amount, Money(0, self.currency))

    def has_refunds(self):
        return self.lines.refunds().exists()

    def can_create_refund(self):
        return (self.taxful_total_price.amount.value > 0)

    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: shuup.shop.models.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(quantities, supplier=supplier)

    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, self.currency)
        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_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 is_canceled(self):
        return (self.status.role == OrderStatusRole.CANCELED)

    def can_set_canceled(self):
        canceled = (self.status.role == OrderStatusRole.CANCELED)
        paid = self.is_paid()
        shipped = (self.shipping_status != ShippingStatus.NOT_SHIPPED)
        return not (canceled or paid or shipped)

    def update_shipping_status(self):
        if self.shipping_status == ShippingStatus.FULLY_SHIPPED:
            return

        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",))
        elif self.shipments.count():
            self.shipping_status = ShippingStatus.PARTIALLY_SHIPPED
            self.save(update_fields=("shipping_status",))

    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.SHUUP_ORDER_KNOWN_PAYMENT_DATA_KEYS),
                (self.shipping_data, settings.SHUUP_ORDER_KNOWN_SHIPPING_DATA_KEYS),
                (self.extra_data, settings.SHUUP_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)

    def get_tracking_codes(self):
        return [shipment.tracking_code for shipment in self.shipments.all() if shipment.tracking_code]

    def can_edit(self):
        return (
            not self.has_refunds() and
            not self.is_canceled() and
            not self.is_complete() and
            self.shipping_status == ShippingStatus.NOT_SHIPPED and
            self.payment_status == PaymentStatus.NOT_PAID
        )