class Contact(NameMixin, PolymorphicModel): is_anonymous = False is_all_seeing = False default_tax_group_getter = 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') ) 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)
class Order(models.Model): # Identification shop = UnsavedForeignKey("Shop", on_delete=models.PROTECT) created_on = models.DateTimeField(auto_now_add=True, editable=False) 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 = UnsavedForeignKey("Address", related_name="billing_orders", blank=True, null=True, on_delete=models.PROTECT, verbose_name=_('billing address')) shipping_address = UnsavedForeignKey("Address", related_name='shipping_orders', blank=True, null=True, on_delete=models.PROTECT, verbose_name=_('shipping address')) tax_number = models.CharField(max_length=20, blank=True, verbose_name=_('Tax number')) phone = models.CharField(max_length=32, blank=True, verbose_name=_('phone')) email = models.EmailField(max_length=128, blank=True, verbose_name=_('email address')) # Status creator = UnsavedForeignKey(settings.AUTH_USER_MODEL, related_name='orders_created', blank=True, null=True, on_delete=models.PROTECT, verbose_name=_('creating user')) deleted = models.BooleanField(db_index=True, default=False) 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=64, blank=True, default="", verbose_name=_('payment method name')) payment_data = JSONField(blank=True, null=True) shipping_method = UnsavedForeignKey("ShippingMethod", related_name='shipping_orders', blank=True, null=True, default=None, on_delete=models.PROTECT, verbose_name=_('shipping method')) shipping_method_name = models.CharField( max_length=64, blank=True, default="", verbose_name=_('shipping method name')) shipping_data = JSONField(blank=True, null=True) extra_data = JSONField(blank=True, null=True) # Money stuff taxful_total_price = 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() prices_include_tax = models.BooleanField( ) # TODO: (TAX) Document Order.prices_include_tax display_currency = CurrencyField(blank=True) display_currency_rate = models.DecimalField(max_digits=36, decimal_places=9, default=1) # Other ip_address = models.GenericIPAddressField(null=True, blank=True, verbose_name=_('IP address')) # order_date is not `auto_now_add` for backdating purposes order_date = models.DateTimeField(editable=False, verbose_name=_('order date')) payment_date = models.DateTimeField(null=True, editable=False, verbose_name=_('payment date')) # TODO: (TAX) Add me? customer_tax_group = models.ForeignKey(CustomerTaxGroup, blank=True, null=True) language = LanguageField(blank=True, verbose_name=_('language')) customer_comment = models.TextField(blank=True, verbose_name=_('customer comment')) admin_comment = models.TextField(blank=True, verbose_name=_('admin comment/notes')) require_verification = models.BooleanField( default=False, verbose_name=_('requires verification')) all_verified = models.BooleanField(default=False, verbose_name=_('all lines verified')) marketing_permission = models.BooleanField( default=True, verbose_name=_('marketing permission')) common_select_related = ("billing_address", ) objects = OrderQuerySet.as_manager() class Meta: ordering = ("-id", ) verbose_name = _('order') verbose_name_plural = _('orders') def __str__(self): # pragma: no cover if self.billing_address_id: name = self.billing_address.name else: name = "-" if settings.SHOOP_ENABLE_MULTIPLE_SHOPS: return "Order %s (%s, %s)" % (self.identifier, self.shop.name, name) else: return "Order %s (%s)" % (self.identifier, name) def cache_prices(self): taxful_total = TaxfulPrice(0, self.currency) taxless_total = TaxlessPrice(0, self.currency) for line in self.lines.all(): taxful_total += line.taxful_total_price taxless_total += line.taxless_total_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.SHOOP_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) def _save_identifiers(self): self.identifier = "%s" % (get_order_identifier(self)) self.reference_number = get_reference_number(self) super(Order, self).save(update_fields=( "identifier", "reference_number", )) def full_clean(self, exclude=None, validate_unique=True): self._cache_values() return super(Order, self).full_clean(exclude, validate_unique) def create_immutable_address_copies(self): for field in ("billing_address", "shipping_address"): address = getattr(self, field, None) if address and not address.is_immutable: if address.pk: address = address.copy() address.set_immutable() else: address.set_immutable() setattr(self, field, address) def save(self, *args, **kwargs): if not self.creator_id: if not settings.SHOOP_ALLOW_ANONYMOUS_ORDERS: raise ValidationError( "Anonymous (userless) orders are not allowed " "when SHOOP_ALLOW_ANONYMOUS_ORDERS is not enabled.") self._cache_values() first_save = (not self.pk) self.create_immutable_address_copies() super(Order, self).save(*args, **kwargs) if first_save: # Have to do a double save the first time around to be able to save identifiers self._save_identifiers() def delete(self, using=None): if not self.deleted: self.deleted = True self.add_log_entry("Deleted.", kind=LogEntryKind.DELETION) # Bypassing local `save()` on purpose. super(Order, self).save(update_fields=("deleted", ), using=using) def set_canceled(self): if self.status.role != OrderStatusRole.CANCELED: self.status = OrderStatus.objects.get_default_canceled() self.save() def _set_paid(self): if self.payment_status != PaymentStatus.FULLY_PAID: # pragma: no branch self.add_log_entry(_('Order marked as paid.')) self.payment_status = PaymentStatus.FULLY_PAID self.payment_date = now() self.save() def is_paid(self): return (self.payment_status == PaymentStatus.FULLY_PAID) def get_total_paid_amount(self): amounts = self.payments.values_list('amount_value', flat=True) return Money(sum(amounts, Decimal(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: shoop.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 return payment def create_shipment(self, supplier, product_quantities): """ Create a shipment for this order from `product_quantities`. `product_quantities` is expected to be a dict mapping Product instances to quantities. Only quantities over 0 are taken into account, and if the mapping is empty or has no quantity value over 0, `NoProductsToShipException` will be raised. :param supplier: The Supplier for this product. No validation is made as to whether the given supplier supplies the products. :param product_quantities: a dict mapping Product instances to quantities to ship :type product_quantities: dict[shoop.shop.models.products.Product, decimal.Decimal] :raises: NoProductsToShipException :return: Saved, complete Shipment object :rtype: shoop.core.models.shipments.Shipment """ if not product_quantities or not any( quantity > 0 for quantity in product_quantities.values()): raise NoProductsToShipException( "No products to ship (`quantities` is empty or has no quantity over 0)." ) from .shipments import Shipment, ShipmentProduct shipment = Shipment(order=self, supplier=supplier) shipment.save() for product, quantity in product_quantities.items(): if quantity > 0: sp = ShipmentProduct(shipment=shipment, product=product, quantity=quantity) sp.cache_values() sp.save() shipment.cache_values() shipment.save() self.add_log_entry(_(u"Shipment #%d created.") % shipment.id) self.check_and_set_fully_shipped() return shipment def create_shipment_of_all_products(self, supplier=None): """ Create a shipment of all the products in this Order, no matter whether or not any have been previously marked as shipped or not. See the documentation for `create_shipment`. :param supplier: The Supplier to use. If `None`, the first supplier in the order is used. (If several are in the order, this fails.) :return: Saved, complete Shipment object :rtype: shoop.shop.models.shipments.Shipment """ suppliers_to_product_quantities = defaultdict( lambda: defaultdict(lambda: 0)) lines = (self.lines.filter(type=OrderLineType.PRODUCT).values_list( "supplier_id", "product_id", "quantity")) for supplier_id, product_id, quantity in lines: if product_id: suppliers_to_product_quantities[supplier_id][ product_id] += quantity if not suppliers_to_product_quantities: raise NoProductsToShipException( "Could not find any products to ship.") if supplier is None: if len(suppliers_to_product_quantities) > 1: # pragma: no cover raise ValueError( "Can only use create_shipment_of_all_products when there is only one supplier" ) supplier_id, quantities = suppliers_to_product_quantities.popitem() supplier = Supplier.objects.get(pk=supplier_id) else: quantities = suppliers_to_product_quantities[supplier.id] products = dict( (product.pk, product) for product in Product.objects.filter(pk__in=quantities.keys())) quantities = dict((products[product_id], quantity) for (product_id, quantity) in quantities.items()) return self.create_shipment(supplier, quantities) def check_all_verified(self): if not self.all_verified: new_all_verified = (not self.lines.filter(verified=False).exists()) if new_all_verified: self.all_verified = True if self.require_verification: self.add_log_entry( _('All rows requiring verification have been verified.' )) self.require_verification = False self.save() return self.all_verified def get_purchased_attachments(self): from .product_media import ProductMedia if self.payment_status != PaymentStatus.FULLY_PAID: return ProductMedia.objects.none() prods = self.lines.exclude(product=None).values_list("product_id", flat=True) return ProductMedia.objects.filter(product__in=prods, enabled=True, purchased=True) def get_tax_summary(self): """ :rtype: taxing.TaxSummary """ all_line_taxes = [] untaxed = TaxlessPrice(0, 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_total_price return taxing.TaxSummary.from_line_taxes(all_line_taxes, untaxed) def get_product_ids_and_quantities(self): quantities = defaultdict(lambda: 0) for product_id, quantity in self.lines.filter( type=OrderLineType.PRODUCT).values_list( "product_id", "quantity"): quantities[product_id] += quantity return dict(quantities) def is_complete(self): return (self.status.role == OrderStatusRole.COMPLETE) def can_set_complete(self): fully_shipped = (self.shipping_status == ShippingStatus.FULLY_SHIPPED) canceled = (self.status.role == OrderStatusRole.CANCELED) return (not self.is_complete()) and fully_shipped and (not canceled) def check_and_set_fully_shipped(self): if self.shipping_status != ShippingStatus.FULLY_SHIPPED: if not self.get_unshipped_products(): self.shipping_status = ShippingStatus.FULLY_SHIPPED self.add_log_entry( _(u"All products have been shipped. Fully Shipped status set." )) self.save(update_fields=("shipping_status", )) return True def get_known_additional_data(self): """ Get a list of "known additional data" in this order's payment_data, shipping_data and extra_data. The list is returned in the order the fields are specified in the settings entries for said known keys. `dict(that_list)` can of course be used to "flatten" the list into a dict. :return: list of 2-tuples. """ output = [] for data_dict, name_mapping in ( (self.payment_data, settings.SHOOP_ORDER_KNOWN_PAYMENT_DATA_KEYS), (self.shipping_data, settings.SHOOP_ORDER_KNOWN_SHIPPING_DATA_KEYS), (self.extra_data, settings.SHOOP_ORDER_KNOWN_EXTRA_DATA_KEYS), ): if hasattr(data_dict, "get"): for key, display_name in name_mapping: if key in data_dict: output.append( (force_text(display_name), data_dict[key])) return output def get_product_summary(self): """Return a dict of product IDs -> {ordered, unshipped, shipped}""" products = defaultdict(lambda: defaultdict(lambda: Decimal(0))) lines = (self.lines.filter(type=OrderLineType.PRODUCT).values_list( "product_id", "quantity")) for product_id, quantity in lines: products[product_id]['ordered'] += quantity products[product_id]['unshipped'] += quantity from .shipments import ShipmentProduct shipment_prods = (ShipmentProduct.objects.filter( shipment__order=self).values_list("product_id", "quantity")) for product_id, quantity in shipment_prods: products[product_id]['shipped'] += quantity products[product_id]['unshipped'] -= quantity return products def get_unshipped_products(self): return dict( (product, summary_datum) for product, summary_datum in self.get_product_summary().items() if summary_datum['unshipped']) def get_status_display(self): return force_text(self.status)
class Contact(PolymorphicShoopModel): 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')) 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
class Contact(NameMixin, PolymorphicModel): 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') ) 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) 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