예제 #1
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)
        ]
예제 #2
0
class Contact(PolymorphicShuupModel):
    is_anonymous = False
    is_all_seeing = False
    default_tax_group_getter = None
    default_contact_group_identifier = None
    default_contact_group_name = None

    created_on = models.DateTimeField(auto_now_add=True,
                                      editable=False,
                                      verbose_name=_("created on"))
    modified_on = models.DateTimeField(auto_now=True,
                                       editable=False,
                                       db_index=True,
                                       null=True,
                                       verbose_name=_("modified on"))
    identifier = InternalIdentifierField(unique=True, null=True, blank=True)
    is_active = models.BooleanField(
        default=True,
        db_index=True,
        verbose_name=_("active"),
        help_text=_("Enable this if the contact is an active customer."),
    )
    shops = models.ManyToManyField(
        "shuup.Shop",
        blank=True,
        verbose_name=_("shops"),
        help_text=_("Inform which shops have access to this contact."),
    )

    registration_shop = models.ForeignKey(
        on_delete=models.CASCADE,
        to="Shop",
        related_name="registrations",
        verbose_name=_("registration shop"),
        null=True,
    )

    # TODO: parent contact?
    default_shipping_address = models.ForeignKey(
        "MutableAddress",
        null=True,
        blank=True,
        related_name="+",
        verbose_name=_("shipping address"),
        on_delete=models.PROTECT,
    )
    default_billing_address = models.ForeignKey(
        "MutableAddress",
        null=True,
        blank=True,
        related_name="+",
        verbose_name=_("billing address"),
        on_delete=models.PROTECT,
    )
    default_shipping_method = models.ForeignKey(
        "ShippingMethod",
        verbose_name=_("default shipping method"),
        blank=True,
        null=True,
        on_delete=models.SET_NULL)
    default_payment_method = models.ForeignKey(
        "PaymentMethod",
        verbose_name=_("default payment method"),
        blank=True,
        null=True,
        on_delete=models.SET_NULL)

    _language = LanguageField(
        verbose_name=_("language"),
        blank=True,
        help_text=
        _("The primary language to be used in all communications with the contact."
          ),
    )
    marketing_permission = models.BooleanField(
        default=False,
        verbose_name=_("marketing permission"),
        help_text=
        _("Enable this if the contact can receive marketing and promotional materials."
          ),
    )
    phone = models.CharField(
        max_length=64,
        blank=True,
        verbose_name=_("phone"),
        help_text=_("The primary phone number of the contact."))
    www = models.URLField(
        max_length=128,
        blank=True,
        verbose_name=_("web address"),
        help_text=_("The web address of the contact, if any."),
    )
    timezone = TimeZoneField(
        blank=True,
        null=True,
        verbose_name=_("time zone"),
        help_text=_(
            "The timezone in which the contact resides. "
            "This can be used to target the delivery of promotional materials at a particular time."
        ),
    )
    prefix = models.CharField(
        verbose_name=_("name prefix"),
        max_length=64,
        blank=True,
        help_text=_(
            "The name prefix of the contact. For example, Mr, Mrs, Dr, etc."),
    )
    name = models.CharField(max_length=256,
                            verbose_name=_("name"),
                            help_text=_("The contact name"))
    suffix = models.CharField(
        verbose_name=_("name suffix"),
        max_length=64,
        blank=True,
        help_text=_(
            "The name suffix of the contact. For example, Sr, Jr, etc."),
    )
    name_ext = models.CharField(max_length=256,
                                blank=True,
                                verbose_name=_("name extension"))
    email = models.EmailField(
        max_length=256,
        blank=True,
        verbose_name=_("email"),
        help_text=
        _("The email that will receive order confirmations and promotional materials (if permitted)."
          ),
    )
    tax_group = models.ForeignKey(
        "CustomerTaxGroup",
        blank=True,
        null=True,
        on_delete=models.PROTECT,
        verbose_name=_("tax group"),
        help_text=
        _("Select the contact tax group to use for this contact. "
          "Tax groups can be used to customize the tax rules the that apply to any of this contact's "
          "orders. Tax groups are defined in `Customer Tax Groups` and can be applied to tax rules "
          "in `Tax Rules`."),
    )
    merchant_notes = models.TextField(
        blank=True,
        verbose_name=_("merchant notes"),
        help_text=
        _("Enter any private notes for this customer that are only accessible in Shuup admin."
          ),
    )
    account_manager = models.ForeignKey(on_delete=models.CASCADE,
                                        to="PersonContact",
                                        blank=True,
                                        null=True,
                                        verbose_name=_("account manager"))
    options = PolymorphicJSONField(blank=True,
                                   null=True,
                                   verbose_name=_("options"))
    picture = FilerImageField(
        verbose_name=_("picture"),
        blank=True,
        null=True,
        related_name="picture",
        on_delete=models.SET_NULL,
        help_text=
        _("Contact picture. Can be used alongside contact profile, reviews and messages for example."
          ),
    )

    def __str__(self):
        return self.full_name

    class Meta:
        verbose_name = _("contact")
        verbose_name_plural = _("contacts")

    def __init__(self, *args, **kwargs):
        if self.default_tax_group_getter:
            kwargs.setdefault("tax_group", self.default_tax_group_getter())
        super(Contact, self).__init__(*args, **kwargs)

    @property
    def full_name(self):
        return (" ".join([self.prefix, self.name, self.suffix])).strip()

    @property
    def language(self):
        if self._language is not None:
            return self._language
        return configuration.get(None, "default_contact_language",
                                 settings.LANGUAGE_CODE)

    @language.setter
    def language(self, value):
        self._language = value

    def save(self, *args, **kwargs):
        add_to_default_group = bool(self.pk is None
                                    and self.default_contact_group_identifier)
        super(Contact, self).save(*args, **kwargs)
        if add_to_default_group:
            self.groups.add(self.get_default_group())

    def get_price_display_options(self, **kwargs):
        """
        Get price display options of the contact.

        If the default group (`get_default_group`) defines price display
        options and the contact is member of it, return it.

        If contact is not (anymore) member of the default group or the
        default group does not define options, return one of the groups
        which defines options.  If there is more than one such groups,
        it is undefined which options will be used.

        If contact is not a member of any group that defines price
        display options, return default constructed
        `PriceDisplayOptions`.

        Subclasses may still override this default behavior.

        :rtype: PriceDisplayOptions
        """
        group = kwargs.get("group", None)
        shop = kwargs.get("shop", None)
        if not group:
            groups_with_options = self.groups.with_price_display_options(shop)
            if groups_with_options:
                default_group = self.default_group
                if groups_with_options.filter(pk=default_group.pk).exists():
                    group = default_group
                else:
                    # Contact was removed from the default group.
                    group = groups_with_options.first()

        if not group:
            group = self.default_group

        return get_price_display_options_for_group_and_shop(group, shop)

    @classmethod
    def get_default_group(cls):
        """
        Get or create default contact group for the class.

        Identifier of the group is specified by the class property
        `default_contact_group_identifier`.

        If new group is created, its name is set to value of
        `default_contact_group_name` class property.

        :rtype: core.models.ContactGroup
        """
        obj, created = ContactGroup.objects.get_or_create(
            identifier=cls.default_contact_group_identifier,
            defaults={"name": cls.default_contact_group_name})
        return obj

    @cached_property
    def default_group(self):
        return self.get_default_group()

    def add_to_shops(self, registration_shop, shops):
        """
        Add contact to multiple shops

        :param registration_shop: Shop where contact registers.
        :type registration_shop: core.models.Shop
        :param shops: A list of shops.
        :type shops: list
        :return:
        """
        # set `registration_shop` first to ensure it's being
        # used if not already set
        for shop in [registration_shop] + shops:
            self.add_to_shop(shop)

    def add_to_shop(self, shop):
        self.shops.add(shop)
        if not self.registration_shop:
            self.registration_shop = shop
            self.save()

    def registered_in(self, shop):
        return self.registration_shop == shop

    def in_shop(self, shop, only_registration=False):
        if only_registration:
            return self.registered_in(shop)
        if self.shops.filter(pk=shop.pk).exists():
            return True
        return self.registered_in(shop)

    @property
    def groups_ids(self):
        return get_groups_ids(self) if self.pk else [self.default_group.pk]
예제 #3
0
class Contact(PolymorphicShuupModel):
    is_anonymous = False
    is_all_seeing = False
    default_tax_group_getter = None
    default_contact_group_identifier = None
    default_contact_group_name = None

    created_on = models.DateTimeField(auto_now_add=True,
                                      editable=False,
                                      verbose_name=_('created on'))
    modified_on = models.DateTimeField(auto_now=True,
                                       editable=False,
                                       db_index=True,
                                       null=True,
                                       verbose_name=_('modified on'))
    identifier = InternalIdentifierField(unique=True, null=True, blank=True)
    is_active = models.BooleanField(
        default=True,
        db_index=True,
        verbose_name=_('active'),
        help_text=_("Check this if the contact is an active customer."))
    shops = models.ManyToManyField(
        "shuup.Shop",
        blank=True,
        verbose_name=_('shops'),
        help_text=_("Inform which shops have access to this contact."))
    # TODO: parent contact?
    default_shipping_address = models.ForeignKey(
        "MutableAddress",
        null=True,
        blank=True,
        related_name="+",
        verbose_name=_('shipping address'),
        on_delete=models.PROTECT)
    default_billing_address = models.ForeignKey(
        "MutableAddress",
        null=True,
        blank=True,
        related_name="+",
        verbose_name=_('billing address'),
        on_delete=models.PROTECT)
    default_shipping_method = models.ForeignKey(
        "ShippingMethod",
        verbose_name=_('default shipping method'),
        blank=True,
        null=True,
        on_delete=models.SET_NULL)
    default_payment_method = models.ForeignKey(
        "PaymentMethod",
        verbose_name=_('default payment method'),
        blank=True,
        null=True,
        on_delete=models.SET_NULL)

    _language = LanguageField(
        verbose_name=_('language'),
        blank=True,
        help_text=
        _("The primary language to be used in all communications with the contact."
          ))
    marketing_permission = models.BooleanField(
        default=True,
        verbose_name=_('marketing permission'),
        help_text=
        _("Check this if the contact can receive marketing and promotional materials."
          ))
    phone = models.CharField(
        max_length=64,
        blank=True,
        verbose_name=_('phone'),
        help_text=_("The primary phone number of the contact."))
    www = models.URLField(
        max_length=128,
        blank=True,
        verbose_name=_('web address'),
        help_text=_("The web address of the contact, if any."))
    timezone = TimeZoneField(
        blank=True,
        null=True,
        verbose_name=_('time zone'),
        help_text=_(
            "The timezone in which the contact resides. This can be used to target the delivery of promotional materials "
            "at a particular time."))
    prefix = models.CharField(
        verbose_name=_('name prefix'),
        max_length=64,
        blank=True,
        help_text=_(
            "The name prefix of the contact. For example, Mr, Mrs, Dr, etc."))
    name = models.CharField(max_length=256,
                            verbose_name=_('name'),
                            help_text=_("The contact name"))
    suffix = models.CharField(
        verbose_name=_('name suffix'),
        max_length=64,
        blank=True,
        help_text=_(
            "The name suffix of the contact. For example, Sr, Jr, etc."))
    name_ext = models.CharField(max_length=256,
                                blank=True,
                                verbose_name=_('name extension'))
    email = models.EmailField(
        max_length=256,
        blank=True,
        verbose_name=_('email'),
        help_text=
        _("The email that will receive order confirmations and promotional materials (if permitted)."
          ))

    tax_group = models.ForeignKey(
        "CustomerTaxGroup",
        blank=True,
        null=True,
        on_delete=models.PROTECT,
        verbose_name=_('tax group'),
        help_text=
        _("Select the contact tax group to use for this contact. "
          "Tax groups can be used to customize the tax rules the that apply to any of this contacts orders. "
          "Tax groups are defined in Settings - Customer Tax Groups and can be applied to tax rules in "
          "Settings - Tax Rules"))
    merchant_notes = models.TextField(
        blank=True,
        verbose_name=_('merchant notes'),
        help_text=
        _("Enter any private notes for this customer that are only accessible in Shuup admin."
          ))
    account_manager = models.ForeignKey("PersonContact",
                                        blank=True,
                                        null=True,
                                        verbose_name=_('account manager'))

    def __str__(self):
        return self.full_name

    class Meta:
        verbose_name = _('contact')
        verbose_name_plural = _('contacts')

    def __init__(self, *args, **kwargs):
        if self.default_tax_group_getter:
            kwargs.setdefault("tax_group", self.default_tax_group_getter())
        super(Contact, self).__init__(*args, **kwargs)

    @property
    def full_name(self):
        return (" ".join([self.prefix, self.name, self.suffix])).strip()

    @property
    def language(self):
        if self._language is not None:
            return self._language
        return configuration.get(None, "default_contact_language",
                                 settings.LANGUAGE_CODE)

    @language.setter
    def language(self, value):
        self._language = value

    def get_price_display_options(self):
        """
        Get price display options of the contact.

        If the default group (`get_default_group`) defines price display
        options and the contact is member of it, return it.

        If contact is not (anymore) member of the default group or the
        default group does not define options, return one of the groups
        which defines options.  If there is more than one such groups,
        it is undefined which options will be used.

        If contact is not a member of any group that defines price
        display options, return default constructed
        `PriceDisplayOptions`.

        Subclasses may still override this default behavior.

        :rtype: PriceDisplayOptions
        """
        groups_with_options = self.groups.with_price_display_options()
        if groups_with_options:
            default_group = self.get_default_group()
            if groups_with_options.filter(pk=default_group.pk).exists():
                group_with_options = default_group
            else:
                # Contact was removed from the default group.
                group_with_options = groups_with_options.first()
            return group_with_options.get_price_display_options()
        return PriceDisplayOptions()

    def save(self, *args, **kwargs):
        add_to_default_group = bool(self.pk is None
                                    and self.default_contact_group_identifier)
        super(Contact, self).save(*args, **kwargs)
        if add_to_default_group:
            self.groups.add(self.get_default_group())

    @classmethod
    def get_default_group(cls):
        """
        Get or create default contact group for the class.

        Identifier of the group is specified by the class property
        `default_contact_group_identifier`.

        If new group is created, its name is set to value of
        `default_contact_group_name` class property.

        :rtype: core.models.ContactGroup
        """
        obj, created = ContactGroup.objects.get_or_create(
            identifier=cls.default_contact_group_identifier,
            defaults={"name": cls.default_contact_group_name})
        return obj
예제 #4
0
파일: _orders.py 프로젝트: dragonsg/shuup-1
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
        )
예제 #5
0
파일: _contacts.py 프로젝트: abduladi/shuup
class Contact(PolymorphicShuupModel):
    is_anonymous = False
    is_all_seeing = False
    default_tax_group_getter = None
    default_contact_group_identifier = None
    default_contact_group_name = None

    created_on = models.DateTimeField(auto_now_add=True,
                                      editable=False,
                                      verbose_name=_('created on'))
    identifier = InternalIdentifierField(unique=True, null=True, blank=True)
    is_active = models.BooleanField(default=True,
                                    db_index=True,
                                    verbose_name=_('active'))
    # TODO: parent contact?
    default_shipping_address = models.ForeignKey(
        "MutableAddress",
        null=True,
        blank=True,
        related_name="+",
        verbose_name=_('shipping address'),
        on_delete=models.PROTECT)
    default_billing_address = models.ForeignKey(
        "MutableAddress",
        null=True,
        blank=True,
        related_name="+",
        verbose_name=_('billing address'),
        on_delete=models.PROTECT)
    default_shipping_method = models.ForeignKey(
        "ShippingMethod",
        verbose_name=_('default shipping method'),
        blank=True,
        null=True,
        on_delete=models.SET_NULL)
    default_payment_method = models.ForeignKey(
        "PaymentMethod",
        verbose_name=_('default payment method'),
        blank=True,
        null=True,
        on_delete=models.SET_NULL)

    language = LanguageField(verbose_name=_('language'), blank=True)
    marketing_permission = models.BooleanField(
        default=True, verbose_name=_('marketing permission'))
    phone = models.CharField(max_length=64,
                             blank=True,
                             verbose_name=_('phone'))
    www = models.URLField(max_length=128,
                          blank=True,
                          verbose_name=_('web address'))
    timezone = TimeZoneField(blank=True,
                             null=True,
                             verbose_name=_('time zone'))
    prefix = models.CharField(verbose_name=_('name prefix'),
                              max_length=64,
                              blank=True)
    name = models.CharField(max_length=256, verbose_name=_('name'))
    suffix = models.CharField(verbose_name=_('name suffix'),
                              max_length=64,
                              blank=True)
    name_ext = models.CharField(max_length=256,
                                blank=True,
                                verbose_name=_('name extension'))
    email = models.EmailField(max_length=256,
                              blank=True,
                              verbose_name=_('email'))

    tax_group = models.ForeignKey("CustomerTaxGroup",
                                  blank=True,
                                  null=True,
                                  on_delete=models.PROTECT,
                                  verbose_name=_('tax group'))
    merchant_notes = models.TextField(blank=True,
                                      verbose_name=_('merchant notes'))
    account_manager = models.ForeignKey("PersonContact",
                                        blank=True,
                                        null=True,
                                        verbose_name=_('account manager'))

    def __str__(self):
        return self.full_name

    class Meta:
        verbose_name = _('contact')
        verbose_name_plural = _('contacts')

    def __init__(self, *args, **kwargs):
        if self.default_tax_group_getter:
            kwargs.setdefault("tax_group", self.default_tax_group_getter())
        super(Contact, self).__init__(*args, **kwargs)

    @property
    def full_name(self):
        return (" ".join([self.prefix, self.name, self.suffix])).strip()

    def get_price_display_options(self):
        """
        Get price display options of the contact.

        If the default group (`get_default_group`) defines price display
        options and the contact is member of it, return it.

        If contact is not (anymore) member of the default group or the
        default group does not define options, return one of the groups
        which defines options.  If there is more than one such groups,
        it is undefined which options will be used.

        If contact is not a member of any group that defines price
        display options, return default constructed
        `PriceDisplayOptions`.

        Subclasses may still override this default behavior.

        :rtype: PriceDisplayOptions
        """
        groups_with_options = self.groups.with_price_display_options()
        if groups_with_options:
            default_group = self.get_default_group()
            if groups_with_options.filter(pk=default_group.pk).exists():
                group_with_options = default_group
            else:
                # Contact was removed from the default group.
                group_with_options = groups_with_options.first()
            return group_with_options.get_price_display_options()
        return PriceDisplayOptions()

    def save(self, *args, **kwargs):
        add_to_default_group = bool(self.pk is None
                                    and self.default_contact_group_identifier)
        super(Contact, self).save(*args, **kwargs)
        if add_to_default_group:
            self.groups.add(self.get_default_group())

    @classmethod
    def get_default_group(cls):
        """
        Get or create default contact group for the class.

        Identifier of the group is specified by the class property
        `default_contact_group_identifier`.

        If new group is created, its name is set to value of
        `default_contact_group_name` class property.

        :rtype: core.models.ContactGroup
        """
        obj, created = ContactGroup.objects.get_or_create(
            identifier=cls.default_contact_group_identifier,
            defaults={"name": cls.default_contact_group_name})
        return obj